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_multitable_business_sql_plan_reaches_project_pre_review(self): project = self.project("DEMO-A") queued = self.post("/projects/DEMO-A/runs", { "plan_id": "corporate.business-sql", "revision": project["revision"], "requirement": "核对真实多表数据卡", }) self.assertEqual(queued.status_code, 202, queued.text) self.assertTrue(self.worker.process_next()) run = self.service.resource("run", queued.json()["id"]) self.assertIn(run["status"], ("completed", "partial"), run) snapshot = self.service.resource("snapshot", run["snapshot_id"]) self.assertEqual( {item["card_id"] for item in snapshot["datasets"]}, {"card-business-project", "card-business-finance", "card-business-credit", "card-business-lease", "card-business-repayment"}, ) project_source = next(item for item in run["evidence"] if item["field"] == "facility.amount" and item.get("card_id") == "card-business-project") self.assertEqual(project_source["value"], "3000.0") self.assertEqual(project_source["locator"], "数据卡字段 facility_amount") 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) def test_role_specific_plans_time_dashboard_and_demo_worker(self): env = {"XINGBANG_DEMO_ROLE_LOGIN": "1", "XINGBANG_DEMO_PROJECTS": "DEMO-A,DEMO-B,DEMO-C"} headers = {"X-Xingbang-Demo-Role": "manager"} with patch.dict(os.environ, env): project = self.client.get(self.base + "/projects/DEMO-B", headers=headers).json() self.assertIn("corporate.pre-review", [p["id"] for p in project["plans"]]) self.assertNotIn("corporate.independent-review", [p["id"] for p in project["plans"]]) denied = self.post("/projects/DEMO-B/runs", {"plan_id": "corporate.independent-review"}, headers=headers) self.assertEqual(denied.status_code, 403) queued = self.post("/projects/DEMO-B/runs", {"plan_id": "corporate.pre-review"}, headers=headers).json() self.assertTrue(self.worker.process_next()) self.assertIn(self.service.resource("run", queued["id"])["status"], ("completed", "partial")) dashboard = self.client.get(self.base + "/time-dashboard", headers=headers) self.assertEqual(dashboard.status_code, 200, dashboard.text) self.assertGreaterEqual(dashboard.json()["summary"]["project_count"], 3) timeline = self.service.get_project("DEMO-B")["timeline"] live = next(e for e in timeline if e["source_id"] == queued["id"]) self.assertEqual(live["status"], "completed") def test_conversation_command_uses_business_service_and_submits_report(self): env = {"XINGBANG_DEMO_ROLE_LOGIN": "1", "XINGBANG_DEMO_PROJECTS": "DEMO-A,DEMO-B,DEMO-C"} headers = {"X-Xingbang-Demo-Role": "manager"} with patch.dict(os.environ, env): command = self.post("/commands", {"project_id": "DEMO-A", "message": "请对这个项目进行AI预审"}, headers={**headers, "Idempotency-Key": "chat-run"}) self.assertEqual(command.status_code, 200, command.text) self.assertEqual(command.json()["intent"], "start_run") self.worker.process_next() report = self.post("/commands", {"project_id": "DEMO-A", "message": "生成AI预审报告"}, headers={**headers, "Idempotency-Key": "chat-report"}) self.assertEqual(report.status_code, 200, report.text) self.assertEqual(report.json()["intent"], "create_report") submitted = self.post("/commands", {"project_id": "DEMO-A", "message": "提交正式评审"}, headers=headers) self.assertEqual(submitted.status_code, 200, submitted.text) self.assertEqual(submitted.json()["intent"], "submit_review") self.assertEqual(submitted.json()["url"], "/review-pool?submitted=DEMO-A") self.assertEqual(self.service.resource("project", "DEMO-A")["workflow_state"], "submitted") reviewer_list = self.client.get(self.base + "/projects", headers={"X-Xingbang-Demo-Role": "reviewer"}) reviewer_a = next(item for item in reviewer_list.json()["projects"] if item["id"] == "DEMO-A") self.assertEqual(reviewer_a["submitted_at"], self.service.resource("project", "DEMO-A")["submitted_at"]) self.assertEqual(reviewer_a["review_state"], "待领取") reviewer = {"X-Xingbang-Demo-Role": "reviewer", "Idempotency-Key": "chat-review"} claimed = self.post("/projects/DEMO-A/review-claim", {}, headers={"X-Xingbang-Demo-Role": "reviewer"}) self.assertEqual(claimed.status_code, 201, claimed.text) self.assertEqual(claimed.json()["submitted_at"], reviewer_a["submitted_at"]) self.assertGreaterEqual(claimed.json()["wait_hours"], 0) independent = self.post("/commands", {"project_id": "DEMO-A", "message": "发起独立评审"}, headers=reviewer) self.assertEqual(independent.status_code, 200, independent.text) self.assertEqual(independent.json()["intent"], "start_run") self.assertEqual(self.service.resource("run", independent.json()["object_id"])["run_kind"], "independent_review") decision = self.post("/projects/DEMO-A/review-decisions", {"decision": "退回", "opinion": "补充最新银行流水"}, headers={"X-Xingbang-Demo-Role": "reviewer"}) self.assertEqual(decision.status_code, 200, decision.text) self.assertEqual(decision.json()["decision"], "退回") manager_list = self.client.get(self.base + "/projects", headers={"X-Xingbang-Demo-Role": "manager"}) returned = next(item for item in manager_list.json()["projects"] if item["id"] == "DEMO-A") self.assertEqual(returned["workflow_state"], "returned") self.assertEqual(returned["review_state"], "已退回") self.assertEqual(returned["return_reason"], "补充最新银行流水") self.assertEqual(returned["next_action"], "查看退回要求并补充材料") self.assertTrue(returned["returned_at"]) def test_full_review_topics_gate_approval_and_freeze_trace(self): pre_run = self.run_project("DEMO-A") report = self.post("/projects/DEMO-A/reports", {"run_id": pre_run["id"]}).json() submitted = self.post("/projects/DEMO-A/reports/" + report["id"] + "/actions", {"action": "submit", "revision": report["revision"], "payload": {}}) self.assertEqual(submitted.status_code, 200, submitted.text) self.assertEqual(self.post("/projects/DEMO-A/review-claim").status_code, 201) queued = self.post("/projects/DEMO-A/runs", {"plan_id": "corporate.independent-review", "revision": self.project("DEMO-A")["revision"]}) self.assertEqual(queued.status_code, 202, queued.text) self.worker.process_next() project = self.project("DEMO-A") self.assertEqual(len(project["review_topics"]), 6) topic_codes = [check["code"] for topic in project["review_topics"] for check in topic["checks"]] self.assertEqual(len(topic_codes), len(set(topic_codes))) lease_topic = next(item for item in project["review_topics"] if item["id"] == "lease_asset") self.assertTrue(any("coverage" in check["code"] for check in lease_topic["checks"])) denied = self.post("/projects/DEMO-A/review-decisions", {"decision": "批准"}) self.assertEqual(denied.status_code, 409, denied.text) run_id = queued.json()["id"] for topic in project["review_topics"]: decision = "确认无异常" if topic["ai_status"] == "pass" else "不适用" response = self.post("/projects/DEMO-A/review-decisions", { "node_id": topic["id"], "decision": decision, "opinion": "该领域不适用于本项目" if decision == "不适用" else "", }) self.assertEqual(response.status_code, 200, response.text) trace = response.json() self.assertEqual(trace["run_id"], run_id) self.assertEqual(trace["source_snapshot_id"], queued.json().get("snapshot_id") or self.service.resource("run", run_id)["snapshot_id"]) self.assertIn("evidence_ids", trace) self.assertIn("basis_ids", trace) approved = self.post("/projects/DEMO-A/review-decisions", {"decision": "批准"}) self.assertEqual(approved.status_code, 200, approved.text) self.assertEqual(len(approved.json()["decision_refs"]), 6) def test_manager_review_pool_withdraw_and_resubmit_preserves_versions(self): env = {"XINGBANG_DEMO_ROLE_LOGIN": "1", "XINGBANG_DEMO_PROJECTS": "DEMO-A,DEMO-B,DEMO-C"} manager = {"X-Xingbang-Demo-Role": "manager"} with patch.dict(os.environ, env): queued = self.post("/projects/DEMO-A/runs", {"plan_id": "corporate.pre-review"}, headers=manager) self.assertEqual(queued.status_code, 202, queued.text) self.worker.process_next() report = self.post("/projects/DEMO-A/reports", {"run_id": queued.json()["id"], "template_id": "template.ai-pre-review"}, headers=manager).json() submitted = self.post("/projects/DEMO-A/reports/" + report["id"] + "/actions", {"action": "submit", "revision": report["revision"], "payload": {}}, headers=manager) self.assertEqual(submitted.status_code, 200, submitted.text) pool = self.client.get(self.base + "/review-pool", headers=manager).json()["projects"] self.assertEqual(next(item for item in pool if item["project_id"] == "DEMO-A")["status"], "submitted") withdrawn = self.post("/projects/DEMO-A/review-withdraw", headers=manager) self.assertEqual(withdrawn.status_code, 200, withdrawn.text) self.assertEqual(withdrawn.json()["status"], "withdrawn") repeated = self.post("/projects/DEMO-A/review-resubmit", headers=manager) self.assertEqual(repeated.status_code, 201, repeated.text) self.assertEqual(repeated.json()["sequence"], 2) reviewer_pool = self.client.get(self.base + "/review-pool", headers={"X-Xingbang-Demo-Role": "reviewer"}).json()["projects"] self.assertTrue(any(item["project_id"] == "DEMO-A" and item["status"] == "submitted" for item in reviewer_pool)) with self.service.connect() as db: versions = listing(db, "submission", "DEMO-A") self.assertEqual(len(versions), 2) self.assertEqual(versions[1]["status"], "withdrawn") if __name__ == "__main__": unittest.main()