feat: 完成项目助手与监控演示并修复聊天及云部署配置

This commit is contained in:
NathanAlex7
2026-09-11 15:56:28 +08:00
parent c57d291f65
commit af1a1027d7
75 changed files with 5215 additions and 101 deletions
+278
View File
@@ -0,0 +1,278 @@
import json
import os
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from fastapi import FastAPI
from fastapi.testclient import TestClient
from api import project_assistant as api
from api import risk_agent_config as catalog
from services.project_assistant import ProjectAssistantService
from services.assistant_worker import DurableWorker
from services.assistant_store import get, put, listing
class ProjectAssistantTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
self.path = Path(self.temp.name)
self.service = ProjectAssistantService(self.path / "assistant.sqlite3")
self.patches = [patch.object(api, "service", lambda: self.service), patch.object(catalog, "DB_PATH", self.path / "catalog.sqlite3"),
patch.dict(os.environ, {"XINGBANG_AUTH_USERS": "", "XINGBANG_REQUIRE_AUTH": "0", "XINGBANG_LOCAL_MODEL_URL": ""})]
for p in self.patches:
p.start()
self.worker = DurableWorker(self.service)
app = FastAPI()
app.include_router(api.router)
self.client = TestClient(app)
self.base = "/api/xingbang/risk-agent/project-assistant"
def tearDown(self):
self.worker.stop()
self.client.close()
for p in reversed(self.patches):
p.stop()
self.temp.cleanup()
def post(self, path, body=None, **kwargs):
return self.client.post(self.base + path, json=body or {}, **kwargs)
def project(self, project="DEMO-B"):
response = self.client.get(self.base + "/projects/" + project)
self.assertEqual(response.status_code, 200, response.text)
return response.json()
def run_project(self, project="DEMO-B", requirement="重点核对现金流"):
response = self.post(f"/projects/{project}/runs", {"requirement": requirement})
self.assertEqual(response.status_code, 202, response.text)
self.assertEqual(response.json()["status"], "queued")
self.worker.process_next()
result = self.client.get(self.base + f"/projects/{project}/runs/" + response.json()["id"]).json()
self.assertIn(result["status"], ("completed", "partial"), result)
return result
def supplement(self, changes, project="DEMO-B"):
return self.post(f"/projects/{project}/supplements", {"changes": changes, "revision": self.project(project)["revision"], "reason": "已核对补充来源"})
def test_demo_b_end_to_end_keeps_missing_cashflow(self):
run = self.run_project()
self.assertEqual(run["summary"]["coverage"], "1.0800")
self.assertEqual(run["summary"]["open"], 4)
self.assertEqual(self.supplement({"due_diligence_liabilities": "13200", "duplicate_asset_count": 0}).status_code, 201)
after = self.run_project()
self.assertEqual(len(after["change_summary"]["resolved"]), 2)
cash = next(f for f in self.project()["findings"] if f["code"] == "financial.cashflow.missing")
self.assertNotEqual(cash["status"], "已解决")
scenario = self.post("/projects/DEMO-B/scenarios", {"facility_amount": "4000"}).json()
self.assertEqual(scenario["scenario"]["coverage"], "1.3500")
self.assertIn(cash["id"], scenario["unresolved"])
self.assertEqual(self.project()["facts"]["facility_amount"], "5000")
def test_model_unavailable_is_not_completed(self):
run = self.run_project("DEMO-A")
self.assertEqual(run["status"], "partial")
model = next(s for s in run["steps"] if s["step_id"] == "corporate.analysis")
self.assertEqual(model["status"], "failed")
self.assertIn("未配置", model["errors"][0]["message"])
def test_idempotency_and_cancel(self):
headers = {"Idempotency-Key": "repeat"}
first = self.post("/projects/DEMO-B/runs", headers=headers).json()
self.assertEqual(first["id"], self.post("/projects/DEMO-B/runs", headers=headers).json()["id"])
self.assertEqual(self.post("/projects/DEMO-A/runs", headers=headers).status_code, 409)
self.assertEqual(self.post("/projects/DEMO-B/runs/" + first["id"] + "/cancel").status_code, 200)
self.assertFalse(self.worker.process_next())
def test_snapshots_immutable_and_no_cross_run_report(self):
first = self.run_project()
self.supplement({"operating_cashflow": "900"})
second = self.run_project()
self.assertEqual(first["summary"]["open"], 4)
report = self.post("/projects/DEMO-B/reports", {"run_id": first["id"]}).json()
self.assertEqual(report["status"], "stale")
self.assertIn("经营现金流资料缺失", report["sections"][1]["content"])
self.assertNotEqual(first["snapshot_id"], second["snapshot_id"])
self.assertEqual(self.post("/projects/DEMO-A/reports", {"run_id": first["id"]}).status_code, 404)
def test_invalid_decimal_and_concurrency(self):
for amount in ("0", "-1", "NaN", "Infinity", "1e1000"):
self.assertEqual(self.post("/projects/DEMO-B/scenarios", {"facility_amount": amount}).status_code, 422)
body = {"changes": {"operating_cashflow": "900"}, "revision": 99, "reason": "补件"}
self.assertEqual(self.post("/projects/DEMO-B/supplements", body).status_code, 409)
def test_real_upload_candidates_and_provenance(self):
content = "due_diligence.liabilities,13200,CNY,万元,2025,合并\n".encode()
response = self.client.post(self.base + "/projects/DEMO-B/documents", files={"file": ("补件.csv", content, "text/csv")})
self.assertEqual(response.status_code, 201, response.text)
document = response.json()
self.assertEqual(document["candidates"][0]["locator"], "第1行")
self.assertEqual(self.project()["facts"]["due_diligence_liabilities"], "12800")
accepted = self.post("/projects/DEMO-B/supplements", {"candidate_ids": [document["candidates"][0]["id"]], "revision": self.project()["revision"], "reason": "确认财报口径"})
self.assertEqual(accepted.status_code, 201, accepted.text)
run = self.run_project()
self.assertTrue(any(e.get("document_id") == document["id"] and e["locator"] == "第1行" for e in run["evidence"]))
cross = self.client.get(self.base + "/projects/DEMO-A/documents/" + document["id"] + "/download")
self.assertEqual(cross.status_code, 404)
def test_unexecuted_check_does_not_resolve(self):
self.run_project()
with self.service.connect() as db:
release = get(db, "release", "corporate.default")
plan = get(db, "component_version", release["version_id"])
# Simulates a later published plan that removes the consistency check.
plan["skills"] = [s for s in plan["skills"] if s != "corporate.consistency"]
put(db, "component_version", plan)
self.supplement({"due_diligence_liabilities": "13200"})
self.run_project()
finding = next(f for f in self.project()["findings"] if f["code"] == "financial.liabilities.conflict")
self.assertNotEqual(finding["status"], "已解决")
self.assertTrue(finding["stale"])
def test_admin_project_permission_is_not_implicit(self):
users = [{"id": "it", "token": "it", "role": "admin", "projects": []}]
with patch.dict(os.environ, {"XINGBANG_AUTH_USERS": json.dumps(users)}):
headers = {"Authorization": "Bearer it"}
self.assertEqual(self.client.get(self.base + "/projects", headers=headers).json()["projects"], [])
self.assertEqual(self.client.get(self.base + "/projects/DEMO-B", headers=headers).status_code, 403)
self.assertEqual(self.client.get(self.base + "/components", headers=headers).status_code, 200)
def test_permission_revocation_before_worker(self):
user = {"id": "m", "token": "secret", "role": "manager", "projects": ["DEMO-B"]}
with patch.dict(os.environ, {"XINGBANG_AUTH_USERS": json.dumps([user])}):
response = self.post("/projects/DEMO-B/runs", headers={"Authorization": "Bearer secret"})
self.assertEqual(response.status_code, 202)
user["projects"] = []
with patch.dict(os.environ, {"XINGBANG_AUTH_USERS": json.dumps([user])}):
self.worker.process_next()
self.assertEqual(self.service.resource("run", response.json()["id"])["status"], "failed")
def test_expired_lease_recovers_and_keeps_snapshot(self):
first = self.post("/projects/DEMO-B/runs").json()
self.worker.claim()
with self.service.connect() as db:
db.execute("UPDATE assistant_jobs SET lease_until=0 WHERE id=?", (first["id"],))
replacement = DurableWorker(self.service)
self.assertTrue(replacement.process_next())
run = self.service.resource("run", first["id"])
self.assertEqual(run["attempt"], 2)
self.assertEqual(run["status"], "partial")
def test_report_lock_candidates_and_export(self):
first = self.run_project()
report = self.post("/projects/DEMO-B/reports", {"run_id": first["id"]}).json()
path = "/projects/DEMO-B/reports/" + report["id"] + "/actions"
edited = self.post(path, {"action": "section", "revision": report["revision"], "payload": {"section_id": "judgment", "content": "人工确认意见", "locked": True, "confirmed": True}}).json()
self.supplement({"operating_cashflow": "900"})
second = self.run_project()
report = self.service.resource("report", report["id"])
regenerated = self.post(path, {"action": "regenerate", "revision": report["revision"], "payload": {"run_id": second["id"]}}).json()
section = next(s for s in regenerated["sections"] if s["id"] == "judgment")
self.assertEqual(section["content"], "人工确认意见")
self.assertTrue(section["candidate"])
self.assertTrue(section["stale"])
export = self.client.get(self.base + "/projects/DEMO-B/reports/" + report["id"] + "/export")
self.assertEqual(export.status_code, 200)
self.assertTrue(export.content.startswith(b"PK"))
def test_report_idempotency_and_protected_fact_numbers(self):
run = self.run_project()
headers = {"Idempotency-Key": "same-report"}
first = self.post("/projects/DEMO-B/reports", {"run_id": run["id"]}, headers=headers).json()
repeated = self.post("/projects/DEMO-B/reports", {"run_id": run["id"]}, headers=headers).json()
self.assertEqual(first["id"], repeated["id"])
response = self.post("/projects/DEMO-B/reports/" + first["id"] + "/actions", {"action": "section", "revision": first["revision"], "payload": {"section_id": "overview", "content": "融资1万元"}})
self.assertEqual(response.status_code, 422)
def test_experience_evaluation_release_and_cross_project_application(self):
run = self.run_project()
feedback = self.post("/projects/DEMO-B/feedback", {"category": "rule_omission", "target_kind": "run", "target_id": run["id"], "note": "回款依赖应追加凭证检查"}).json()
payload = {"title": "回款依据核查", "trigger": "回款依赖为真", "action": "补充到账凭证", "exceptions": "已提供到账凭证", "feedback_id": feedback["id"],
"conditions": {"collection.dependency": True}, "required_field": "collection.evidence", "exception_field": "collection.evidence", "scope": "team", "team": "demo", "expires_at": "2099-01-01T00:00:00+00:00"}
draft = self.post("/projects/DEMO-B/experiences", payload)
self.assertEqual(draft.status_code, 201, draft.text)
draft = draft.json()
path = "/projects/DEMO-B/experiences/" + draft["id"] + "/actions"
result = self.post(path, {"action": "publish", "revision": draft["revision"], "note": "审核"})
self.assertEqual(result.status_code, 409)
evaluated = self.post(path, {"action": "evaluate", "revision": draft["revision"], "note": "隔离评测"}).json()
self.assertTrue(evaluated["evaluation"]["passed"])
published = self.post(path, {"action": "publish", "revision": evaluated["revision"], "note": "审核通过"})
self.assertEqual(published.status_code, 200, published.text)
c_run = self.run_project("DEMO-C")
self.assertTrue(any(f["code"].startswith("experience.") for f in c_run["findings"]))
self.assertEqual(len(self.project("DEMO-C")["experience_applications"]), 1)
a_run = self.run_project("DEMO-A")
self.assertFalse(any(f["code"].startswith("experience.") for f in a_run["findings"]))
self.assertNotIn("experience_versions", c_run)
def test_reset_is_explicit_scoped_and_archived(self):
self.run_project()
self.assertEqual(self.post("/demo/reset").status_code, 422)
result = self.post("/demo/reset", {"confirmation": "RESET-DEMO-A-B-C"})
self.assertEqual(result.status_code, 200, result.text)
self.assertEqual(self.project()["revision"], 1)
with self.service.connect() as db:
self.assertEqual(len(listing(db, "reset_archive")), 3)
self.assertTrue(listing(db, "component_version"))
def test_mock_and_readonly_sqlite_adapter_agree(self):
import sqlite3
from services.assistant_data import load_catalog, read_cards
from services.component_registry import freeze
project = self.service.resource("project", "DEMO-B")
with self.service.connect() as db:
components = freeze(db, "corporate.default", load_catalog())
mock_values, _, _, errors = read_cards(project, components)
self.assertEqual(errors, [])
sql_path = self.path / "source.sqlite3"
with sqlite3.connect(sql_path) as db:
for (table, sample), mapping in zip(catalog.SAMPLES.items(), catalog.MAPPINGS):
row = {key: None for key in sample[0]}
for field, column in mapping.items():
row[column] = project["values"].get(field)
row["project_id"] = project["id"]
if table == "project":
row["customer_name"] = project["customer"]
if table == "finance":
row.update(period="2025", scope="合并")
db.execute(f"CREATE TABLE {table} ({','.join(row)})")
db.execute(f"INSERT INTO {table} VALUES ({','.join('?' for _ in row)})", tuple(row.values()))
components["legacy_connections"] = [{"id": "conn-erp", "type": "SQLite", "data_domain": "demo", "database": str(sql_path), "allowed_tables": list(catalog.SAMPLES)}]
with patch.dict(os.environ, {"XINGBANG_SQLITE_ROOT": str(self.path)}):
values, _, _, errors = read_cards(project, components)
self.assertEqual(errors, [])
self.assertEqual(values, mock_values)
def test_concurrent_queued_projects_stay_isolated(self):
from concurrent.futures import ThreadPoolExecutor
queued = [self.post(f"/projects/{p}/runs").json() for p in ("DEMO-A", "DEMO-B", "DEMO-C")]
workers = [DurableWorker(self.service) for _ in queued]
with ThreadPoolExecutor(max_workers=3) as pool:
self.assertTrue(all(pool.map(lambda w: w.process_next(), workers)))
results = [self.service.resource("run", r["id"]) for r in queued]
self.assertEqual(len({r["snapshot_id"] for r in results}), 3)
for run in results:
self.assertTrue(all(e["project_id"] == run["project_id"] for e in run["evidence"]))
self.assertTrue(all(f["project_id"] == run["project_id"] for f in run["findings"]))
def test_legacy_migration_is_idempotent_and_references_remain_readable(self):
import sqlite3
target = self.path / "legacy.sqlite3"
project = {"id": "DEMO-B", "facts": {"operating_cashflow": "777"}}
with sqlite3.connect(target) as db:
db.execute("CREATE TABLE projects(id TEXT PRIMARY KEY,body TEXT,revision INTEGER)")
db.execute("INSERT INTO projects VALUES (?,?,2)", ("DEMO-B", json.dumps(project)))
for kind in ("runs", "snapshots", "findings", "scenarios", "reports", "experiences"):
db.execute(f"CREATE TABLE {kind}(id TEXT,body TEXT)")
db.execute("INSERT INTO reports VALUES (?,?)", ("old-report", json.dumps({"id": "old-report", "project_id": "DEMO-B", "sections": [{"content": "旧版人工正文"}]})))
migrated = ProjectAssistantService(target)
self.assertEqual(migrated.resource("project", "DEMO-B")["values"]["financial.operating_cashflow"], "777")
self.assertEqual(migrated.resource("legacy_archive", "reports:old-report", "DEMO-B")["body"]["sections"][0]["content"], "旧版人工正文")
ProjectAssistantService(target)
with migrated.connect() as db:
self.assertEqual(len(listing(db, "legacy_archive")), 1)
if __name__ == "__main__":
unittest.main()