按 docs/v2/05-implementation-plan.md §1 候选迁移表与 claude_use/T00 白名单, 从 55e2cdb 抽取 224 个文件(2.48 MB)到独立目录 F:/mini-OpenClaw-v2。 - 排除:凭据(.env/server-deploy.conf/旧DELIVERY.md)、运行数据(xingbang_data 等 4 个 SQLite)、依赖(venv/node_modules 符号链接)、归档(_backup*/test-evidence/ ori_file)、Office 临时文件 - 目标目录独立 git 仓库,初始恢复点;Gitea 远程与分支待 OQ-06 Q2 定案 - 未部署、未启动服务、未调用模型 Co-Authored-By: Claude Code <noreply@anthropic.com>
457 lines
30 KiB
Python
457 lines
30 KiB
Python
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.assertTrue(result.json()["history_reset"])
|
|
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"))
|
|
project = self.project("DEMO-B")
|
|
self.assertEqual(project["stage"], "补件中")
|
|
self.assertEqual(len(project["timeline"]), 3)
|
|
self.assertEqual([item["action"] for item in project["events"]], ["demo.reset"])
|
|
|
|
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_role_scoped_miniclaw_understands_risk_assessment_phrase(self):
|
|
env = {"XINGBANG_DEMO_ROLE_LOGIN": "1", "XINGBANG_DEMO_PROJECTS": "DEMO-A,DEMO-B,DEMO-C"}
|
|
with patch.dict(os.environ, env):
|
|
response = self.post(
|
|
"/commands",
|
|
{"project_id": "DEMO-A", "message": "帮我做一次项目风险研判"},
|
|
headers={"X-Xingbang-Demo-Role": "manager", "Idempotency-Key": "cloud-mini-openclaw-risk"},
|
|
)
|
|
self.assertEqual(response.status_code, 200, response.text)
|
|
self.assertEqual(response.json()["intent"], "start_run")
|
|
self.assertEqual(self.service.resource("run", response.json()["object_id"])["run_kind"], "pre_review")
|
|
|
|
def test_conversation_read_questions_receive_distinct_answers(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):
|
|
status = self.post("/commands", {"project_id": "DEMO-B", "message": "这个项目到哪一步了?"}, headers=headers)
|
|
next_step = self.post("/commands", {"project_id": "DEMO-B", "message": "我接下来要做什么?"}, headers=headers)
|
|
issues = self.post("/commands", {"project_id": "DEMO-B", "message": "现在有哪些风险?"}, headers=headers)
|
|
materials = self.post("/commands", {"project_id": "DEMO-B", "message": "还缺什么材料?"}, headers=headers)
|
|
unknown = self.post("/commands", {"project_id": "DEMO-B", "message": "请详细分析这个客户"}, headers=headers)
|
|
for response in (status, next_step, issues, materials, unknown):
|
|
self.assertEqual(response.status_code, 200, response.text)
|
|
self.assertEqual(status.json()["intent"], "project_status")
|
|
self.assertIn("当前处于", status.json()["message"])
|
|
self.assertEqual(next_step.json()["intent"], "next_step")
|
|
self.assertIn("下一步", next_step.json()["message"])
|
|
self.assertNotIn("lease.duplicate_count完整性", next_step.json()["message"])
|
|
self.assertNotEqual(status.json()["message"], next_step.json()["message"])
|
|
self.assertEqual(issues.json()["intent"], "project_issues")
|
|
self.assertIn("未闭环问题", issues.json()["message"])
|
|
self.assertEqual(materials.json()["intent"], "materials")
|
|
self.assertEqual(unknown.json()["intent"], "clarify")
|
|
|
|
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()
|