feat: 完成三角色工作台及金租对公Skills全流程演示

This commit is contained in:
NathanAlex7
2026-09-15 10:32:24 +08:00
parent 5f0b1f9409
commit 47ec090345
58 changed files with 2534 additions and 609 deletions
+53 -2
View File
@@ -1,7 +1,7 @@
"""Snapshot-grounded report drafts, protected human text and immutable final copies."""
from copy import deepcopy
import io
from services.assistant_store import get, put, listing, event, uid, now, digest, Conflict
from services.assistant_store import get, put, listing, event, uid, now, digest, Conflict, time_event, finish_time_event
from services.component_registry import resolve
@@ -12,6 +12,19 @@ def section_content(section_id, snapshot, run):
return f"{snapshot['customer']},拟融资 {values.get('facility.amount', '未提供')} 万元;租赁物价值 {values.get('lease.total_value', '未提供')} 万元。\n期间:{snapshot.get('period', '待确认')};口径:{snapshot.get('scope', '待确认')}。\n价值覆盖率(比值):{run.get('summary', {}).get('coverage') or '未计算'}。"
if section_id == "findings":
return "\n".join(f"{i+1}. {f['title']}({f['result']}):{f['suggested_action']}\n依据:{'、'.join(f.get('evidence_ids', [])) or '缺少可验证证据'}" for i, f in enumerate(unresolved)) or "本次已执行的检查未发现待处理事项;不表示未执行的检查已通过。"
if section_id in ("completeness", "verification"):
missing = [f["title"] for f in unresolved if f.get("result") in ("unknown", "error")]
return ("待补充或无法完成核验:" + "、".join(missing)) if missing else "本次必做资料检查已执行,未发现明确缺件;仍需人工核对材料真实性和时效。"
if section_id == "financial":
assets = values.get("financial.total_assets", "未提供")
liabilities = values.get("financial.total_liabilities", "未提供")
cashflow = values.get("financial.operating_cashflow", "未提供")
return f"资产总额 {assets} 万元;负债总额 {liabilities} 万元;经营现金流 {cashflow} 万元。\n计算结果及口径以本次冻结数据卡和Skill执行记录为准。"
if section_id == "scenario":
return f"当前融资金额 {values.get('facility.amount', '未提供')} 万元,租赁物价值 {values.get('lease.total_value', '未提供')} 万元,价值覆盖率 {run.get('summary', {}).get('coverage') or '未计算'}。\n额度、期限、价格和增信条件由授权人员结合情景测算确认。"
if section_id == "manager_response":
notes = [f.get("human_note") for f in run.get("findings", []) if f.get("human_note")]
return "\n".join(f"{i+1}. {note}" for i, note in enumerate(notes)) or "尚无客户经理确认说明;风险回应及补件记录保留在项目问题清单。"
responses = [s.get("outputs", {}).get("requirement_response", "") for s in run["steps"] if s.get("outputs", {}).get("requirement_response")]
failed = [s["name"] for s in run["steps"] if s["status"] in ("failed", "partial", "skipped")]
return ("\n".join(responses) or "尚无经验证的模型核查意见,请人工补充判断。") + ("\n未完整执行:" + "、".join(failed) if failed else "") + "\n本底稿仅供核查,不构成审批结论。"
@@ -60,12 +73,31 @@ class DocumentsMixin:
stale = snapshot["data_revision"] != project["data_revision"]
sections = [{**deepcopy(s), "content": section_content(s["id"], snapshot, run), "locked": False, "confirmed": False,
"stale": stale, "source_snapshot_id": snapshot["id"], "candidate": None, "history": []} for s in template["sections"]]
if template_id in ("template.independent-review", "template.committee-brief", "template.review", "template.committee"):
decisions = listing(db, "review_decision", project_id)
judgment = next((section for section in sections if section["id"] == "judgment"), None)
if judgment and decisions:
current, seen = [], set()
for item in decisions:
key = item.get("node_id") or item.get("finding_id") or "overall"
if key in seen or (item.get("run_id") and item.get("run_id") != run_id):
continue
seen.add(key)
current.append(item)
judgment["content"] = "\n".join(
f"{item.get('node_title') or '项目整体'}|{item.get('decision')}:{item.get('opinion') or '评审员已确认'}\n"
f"数据来源:{'、'.join(item.get('evidence_ids', [])) or '该节点未取得可验证材料'};检查依据:{'、'.join(item.get('basis_ids', [])) or '人工专业判断'}"
for item in current
) + "\n\n以上意见由评审员确认;每项意见均关联冻结快照与证据索引,AI仅提供核查建议。"
report = {"id": uid("report"), "project_id": project_id, "run_id": run_id, "snapshot_id": snapshot["id"],
"template": deepcopy(template), "status": "stale" if stale else "draft", "sections": sections, "actor_id": actor,
"created_at": now(), "data_domain": project["data_domain"], "data_revision": snapshot["data_revision"],
"disclaimer": ("演示数据|" if project["data_domain"] == "demo" else "") + "核查底稿|不构成审批结论",
"evidence": deepcopy(run["evidence"])}
result = put(db, "report", report)
time_event(db, project_id, "report_drafting", "报告整理" if template_id == "template.ai-pre-review" else "评审报告编制",
"manager" if template_id == "template.ai-pre-review" else "reviewer", "active", actor, "report", report["id"],
detail={"template_id": template_id, "run_id": run_id})
if idempotency_key:
db.execute("INSERT INTO assistant_idempotency VALUES (?,?,?,?,?)", (actor, "report", idempotency_key, request_hash, result["id"]))
event(db, project_id, "report.created", actor, {"report_id": report["id"], "snapshot_id": snapshot["id"]})
@@ -127,9 +159,28 @@ class DocumentsMixin:
final["content_hash"] = digest(final)
put(db, "report_final", final, immutable=True)
report.update(status="finalized", final_id=final["id"], finalized_by=actor)
finish_time_event(db, "report", report_id)
elif action == "submit":
project = get(db, "project", project_id)
if report["data_revision"] != project["data_revision"] or any(s["stale"] for s in report["sections"]):
raise Conflict("报告来源已变化,请先重新预审并更新报告")
submissions = listing(db, "submission", project_id)
if any(item.get("status") == "submitted" for item in submissions):
raise Conflict("项目已在评审池中;如需更新,请先从评审池撤回再重新推送")
submission = {"id": uid("submission"), "project_id": project_id, "report_id": report_id, "run_id": report["run_id"],
"submitted_by": actor, "submitted_at": now(), "status": "submitted",
"sequence": 1 + max((item.get("sequence", 1) for item in submissions), default=0),
"content_hash": digest(report)}
put(db, "submission", submission, immutable=True)
report.update(status="submitted", submission_id=submission["id"], submitted_by=actor)
project.update(workflow_state="submitted", stage="待评审", submitted_at=submission["submitted_at"],
reviewer_id=None, review_claimed_at=None, review_started_at=None, review_decided_at=None)
put(db, "project", project)
finish_time_event(db, "report", report_id)
time_event(db, project_id, "review_queue", "评审排队", "reviewer", "queue", actor, "submission", submission["id"])
else:
raise ValueError("不支持的底稿动作")
if action != "finalize":
if action not in ("finalize", "submit"):
report["status"] = "stale" if any(s["stale"] for s in report["sections"]) else "draft"
result = put(db, "report", report, expected=expected)
event(db, project_id, "report." + action, actor, {"report_id": report_id, "section_id": payload.get("section_id")})
+41
View File
@@ -4,6 +4,7 @@ import sqlite3
from contextlib import contextmanager
from datetime import datetime, timezone
from hashlib import sha256
from pathlib import Path
from uuid import uuid4
@@ -50,6 +51,9 @@ def initialize(path):
lease_until REAL NOT NULL DEFAULT 0, attempts INTEGER NOT NULL DEFAULT 0);
CREATE TABLE IF NOT EXISTS assistant_meta (key TEXT PRIMARY KEY, value TEXT);
""")
schema = Path(__file__).resolve().parents[1] / "fixtures" / "xingbang_demo" / "business_flow_schema.sql"
if schema.exists():
db.executescript(schema.read_text(encoding="utf-8"))
def get(db, kind, object_id):
@@ -84,5 +88,42 @@ def event(db, project_id, action, actor, detail=None, run_id=None):
db.execute("INSERT INTO assistant_events(project_id,run_id,body) VALUES (?,?,?)", (project_id, run_id, json.dumps(body, ensure_ascii=False)))
def time_event(db, project_id, phase_code, phase_name, role, category, actor, source_type, source_id,
status="running", started_at=None, ended_at=None, detail=None, event_id=None):
"""Create an immutable phase interval; callers close it with finish_time_event."""
event_id = event_id or uid("time")
db.execute(
"INSERT INTO business_time_events VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)",
(event_id, project_id, phase_code, phase_name, role, category, status,
started_at or now(), ended_at, actor, source_type, source_id,
json.dumps(detail or {}, ensure_ascii=False)),
)
return event_id
def finish_time_event(db, source_type, source_id, status="completed", ended_at=None):
db.execute(
"UPDATE business_time_events SET status=?,ended_at=? WHERE source_type=? AND source_id=? AND status='running'",
(status, ended_at or now(), source_type, source_id),
)
def time_listing(db, project_ids=None):
query = "SELECT * FROM business_time_events"
params = []
if project_ids is not None:
if not project_ids:
return []
query += " WHERE project_id IN (" + ",".join("?" for _ in project_ids) + ")"
params.extend(project_ids)
query += " ORDER BY started_at"
result = []
for row in db.execute(query, params):
item = dict(row)
item["detail"] = json.loads(item["detail"] or "{}")
result.append(item)
return result
class Conflict(ValueError):
pass
+8 -2
View File
@@ -7,7 +7,7 @@ import time
from datetime import datetime, timezone, timedelta
from copy import deepcopy
from services.assistant_store import get, put, listing, event, uid, now, digest
from services.assistant_store import get, put, listing, event, uid, now, digest, finish_time_event
from services.assistant_data import read_cards
from services.skill_executor import execute_skill, execute_rules
from services.assistant_experiences import apply_experiences
@@ -22,6 +22,8 @@ def authorized(identity, project_id):
configured = os.getenv("XINGBANG_AUTH_USERS", "")
if identity.get("preview"):
return not configured and os.getenv("XINGBANG_REQUIRE_AUTH") != "1"
if identity.get("demo"):
return os.getenv("XINGBANG_DEMO_ROLE_LOGIN") == "1" and identity.get("role") in ("manager", "reviewer", "admin") and project_id in identity.get("projects", [])
try:
account = next((u for u in json.loads(configured or "[]") if u.get("id") == identity["id"]), None)
except ValueError:
@@ -163,7 +165,8 @@ class DurableWorker:
if not run:
return False
done = threading.Event()
threading.Thread(target=self.heartbeat, args=(run, done), daemon=True).start()
heartbeat_thread = threading.Thread(target=self.heartbeat, args=(run, done), daemon=True)
heartbeat_thread.start()
try:
if not authorized(run["identity"], run["project_id"]):
raise PermissionError("项目权限已撤销")
@@ -176,9 +179,11 @@ class DurableWorker:
current.update(status="failed", ended_at=now(), error="运行未完成:" + type(exc).__name__)
put(db, "run", current)
db.execute("UPDATE assistant_jobs SET state='failed' WHERE id=? AND owner=?", (run["id"], self.owner))
finish_time_event(db, "run", run["id"])
event(db, run["project_id"], "run.failed", self.owner, {"error_type": type(exc).__name__}, run["id"])
finally:
done.set()
heartbeat_thread.join(timeout=2)
return True
def execute(self, run):
@@ -288,6 +293,7 @@ class DurableWorker:
for application in applications:
put(db, "experience_application", application, immutable=True)
db.execute("UPDATE assistant_jobs SET state=? WHERE id=? AND owner=?", (current["status"], run["id"], self.owner))
finish_time_event(db, "run", run["id"])
self.service.mark_stale(db, run["project_id"], ["*"])
# A new result invalidates existing drafts, not the findings just reconciled.
for finding in findings:
@@ -0,0 +1,42 @@
"""Build the sanitized multi-table leasing database used by the local demo."""
import os
import sqlite3
from contextlib import closing
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
SCHEMA_PATH = ROOT / "fixtures" / "xingbang_demo" / "business_database.sql"
DEFAULT_PATH = ROOT / "xingbang_data" / "xingbang_business_demo.sqlite3"
def build_business_demo_database(target=DEFAULT_PATH, force=False):
target = Path(target).resolve()
if target.exists() and not force:
return target
target.parent.mkdir(parents=True, exist_ok=True)
temporary = target.with_name(target.name + ".building")
if temporary.exists():
temporary.unlink()
try:
with closing(sqlite3.connect(temporary)) as db:
db.executescript(SCHEMA_PATH.read_text(encoding="utf-8"))
result = db.execute("PRAGMA integrity_check").fetchone()[0]
if result != "ok":
raise RuntimeError("演示业务库完整性检查失败")
db.commit()
os.replace(temporary, target)
finally:
if temporary.exists():
temporary.unlink()
return target
def database_summary(target=DEFAULT_PATH):
target = Path(target).resolve()
with sqlite3.connect(target) as db:
tables = [row[0] for row in db.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name"
)]
counts = {table: db.execute(f'SELECT COUNT(*) FROM "{table}"').fetchone()[0] for table in tables}
return {"database": str(target), "tables": counts}
+39 -6
View File
@@ -15,16 +15,48 @@ def seed_components():
{"id": "corporate.cashflow", "name": "现金流完整性", "engine": "required", "inputs": ["financial.operating_cashflow"], "code": "financial.cashflow.missing", "title": "经营现金流资料缺失"},
{"id": "corporate.assets", "name": "设备清单存在重复项", "engine": "duplicate", "inputs": ["lease.duplicate_count"], "field": "lease.duplicate_count", "code": "lease.asset.duplicate"},
{"id": "corporate.coverage", "name": "租赁物价值覆盖测算", "engine": "ratio", "inputs": ["lease.total_value", "facility.amount"], "numerator": "lease.total_value", "denominator": "facility.amount", "output": "metrics.coverage"},
{"id": "corporate.analysis", "name": "项目综合核查", "engine": "model", "inputs": [], "allowed_tools": sorted(TOOLS), "native_skills": ["risk_evidence/SKILL.md"], "instructions": "逐项核查本次自然语言要求与语义规则,输出有证据的核查建议,不作审批结论。"},
{"id": "corporate.analysis", "name": "金租对公业务综合核查", "engine": "model", "inputs": [], "allowed_tools": sorted(TOOLS), "native_skills": ["risk_evidence/SKILL.md", "financial-leasing-lessee-review/SKILL.md", "financial-leasing-asset-review/SKILL.md", "financial-leasing-sale-leaseback-review/SKILL.md", "financial-leasing-guarantee-concentration/SKILL.md"], "instructions": "按金租对公业务方法逐项核查承租人、租赁物、售后回租适用性、增信与集中度,输出有证据的核查建议,不作审批结论。"},
]
items = [dict(s, kind="skill") for s in skills]
items.append({"id": "review.analysis", "kind": "skill", "name": "独立评审复核", "engine": "model", "inputs": [],
"allowed_tools": sorted(TOOLS), "native_skills": ["risk_evidence/SKILL.md", "financial-leasing-lessee-review/SKILL.md", "financial-leasing-asset-review/SKILL.md", "financial-leasing-sale-leaseback-review/SKILL.md", "financial-leasing-guarantee-concentration/SKILL.md", "financial-leasing-review-report/SKILL.md"],
"instructions": "按金租对公业务全链路独立复核送审事实、计算、风险回应与方案,只引用授权证据,明确分歧和待决策事项。"})
items += [{"id": "demo.coverage", "kind": "rule_set", "name": "演示覆盖规则", "conditions": [{"code": "lease.coverage.below_demo_threshold", "field": "metrics.coverage", "operator": "lt", "threshold": "1.2", "title": "租赁物价值覆盖低于演示阈值", "suggestion": "比较融资金额情景;120%仅为演示口径"}]}]
for template_id, name in (("due-diligence", "尽调草稿"), ("review", "评审底稿"), ("committee", "上会摘要")):
items.append({"id": "template." + template_id, "kind": "template", "name": name,
"sections": [{"id": "overview", "title": "项目事实", "dependencies": ["facility.amount", "lease.total_value"]},
{"id": "findings", "title": "待核实事项", "dependencies": ["*"]},
{"id": "judgment", "title": "核查意见与建议", "dependencies": ["*"]}]})
items.append({"id": "corporate.default", "kind": "plan", "name": "对公送审前检查", "skills": [s["id"] for s in skills], "rules": ["demo.coverage"], "template": "template.due-diligence", "legacy_plan_id": "plan-default", "budget": {"timeout_seconds": 120, "max_tool_calls": 12}, "dependencies": []})
items += [
{"id": "template.ai-pre-review", "kind": "template", "name": "AI预审报告(业务提交前)",
"audience": ["manager"], "sections": [
{"id": "overview", "title": "一、项目概况", "dependencies": ["facility.amount", "lease.total_value"]},
{"id": "completeness", "title": "二、资料完整性", "dependencies": ["*"]},
{"id": "financial", "title": "三、财务与现金流", "dependencies": ["financial.total_assets", "financial.total_liabilities", "financial.operating_cashflow"]},
{"id": "findings", "title": "四、主要风险与证据", "dependencies": ["*"]},
{"id": "manager_response", "title": "五、客户经理说明与补件", "dependencies": ["*"]},
{"id": "judgment", "title": "六、提交前建议", "dependencies": ["*"]}]},
{"id": "template.independent-review", "kind": "template", "name": "对公项目独立评审报告",
"audience": ["reviewer"], "sections": [
{"id": "overview", "title": "一、项目与授信概况", "dependencies": ["facility.amount", "lease.total_value"]},
{"id": "verification", "title": "二、资料与数据复核", "dependencies": ["*"]},
{"id": "financial", "title": "三、财务、现金流与偿债能力", "dependencies": ["*"]},
{"id": "scenario", "title": "四、方案与增信分析", "dependencies": ["facility.amount", "lease.total_value"]},
{"id": "findings", "title": "五、风险分歧与缓释措施", "dependencies": ["*"]},
{"id": "judgment", "title": "六、主审员独立意见", "dependencies": ["*"]}]},
{"id": "template.committee-brief", "kind": "template", "name": "上会审议摘要",
"audience": ["reviewer"], "sections": [
{"id": "overview", "title": "项目摘要", "dependencies": ["*"]},
{"id": "findings", "title": "核心风险", "dependencies": ["*"]},
{"id": "scenario", "title": "建议方案", "dependencies": ["*"]},
{"id": "judgment", "title": "待决策事项", "dependencies": ["*"]}]},
]
items += [
{"id": "corporate.default", "kind": "plan", "name": "对公送审前检查(兼容)", "audience": ["manager"], "run_kind": "pre_review", "skills": [s["id"] for s in skills], "rules": ["demo.coverage"], "template": "template.due-diligence", "legacy_plan_id": "plan-default", "budget": {"timeout_seconds": 120, "max_tool_calls": 12}, "dependencies": []},
{"id": "corporate.pre-review", "kind": "plan", "name": "客户经理AI预审", "audience": ["manager"], "run_kind": "pre_review", "skills": [s["id"] for s in skills], "rules": ["demo.coverage"], "template": "template.ai-pre-review", "legacy_plan_id": "plan-default", "budget": {"timeout_seconds": 120, "max_tool_calls": 12}, "dependencies": []},
{"id": "corporate.independent-review", "kind": "plan", "name": "评审员独立复核", "audience": ["reviewer"], "run_kind": "independent_review", "skills": [s["id"] for s in skills if s["id"] != "corporate.analysis"] + ["review.analysis"], "rules": ["demo.coverage"], "template": "template.independent-review", "legacy_plan_id": "plan-default", "budget": {"timeout_seconds": 180, "max_tool_calls": 18}, "dependencies": []},
{"id": "corporate.business-sql", "kind": "plan", "name": "多表业务库AI预审", "audience": ["manager"], "run_kind": "pre_review", "skills": [s["id"] for s in skills if s["id"] != "corporate.coverage"], "rules": ["demo.coverage"], "template": "template.ai-pre-review", "legacy_plan_id": "plan-business-sql", "budget": {"timeout_seconds": 120, "max_tool_calls": 12}, "dependencies": []},
]
return items
@@ -84,13 +116,14 @@ def validate(body):
def seed(db):
if listing(db, "release"):
return
for item in seed_components():
body = normalize(dict(item, status="published"), "system-demo")
validate(body)
put(db, "component_version", dict(body, id=body["id"] + "@" + body["version"], component_id=body["id"]), immutable=True)
put(db, "release", {"id": body["id"], "version_id": body["id"] + "@" + body["version"], "scope": body["scope"]})
version_id = body["id"] + "@" + body["version"]
if not any(v["id"] == version_id for v in listing(db, "component_version")):
put(db, "component_version", dict(body, id=version_id, component_id=body["id"]), immutable=True)
if not any(r["id"] == body["id"] for r in listing(db, "release")):
put(db, "release", {"id": body["id"], "version_id": version_id, "scope": body["scope"]})
def resolve(db, component_id):
+352 -23
View File
@@ -2,7 +2,8 @@
from copy import deepcopy
import json
from pathlib import Path
from services.assistant_store import transaction, initialize, get, put, listing, event, uid, now, digest, Conflict
from datetime import datetime, timezone, timedelta
from services.assistant_store import transaction, initialize, get, put, listing, event, uid, now, digest, Conflict, time_event, time_listing, finish_time_event
from services import component_registry as registry
from services.assistant_materials import demo_material, demo_assets, material, apply_candidate, FIELDS
from services.assistant_data import load_catalog
@@ -17,6 +18,15 @@ DB_PATH = Path(__file__).resolve().parents[1] / "xingbang_data" / "project_assis
FORMULA_VERSION = "lease-value-over-facility@1.0.0"
TERMINAL = {"completed", "partial", "failed", "cancelled"}
REVIEW_TOPICS = (
{"id": "subject", "title": "主体信息", "prefixes": ("project.", "customer.", "corporate.")},
{"id": "financial", "title": "财务与现金流", "prefixes": ("financial.", "due_diligence.", "metrics.")},
{"id": "lease_asset", "title": "租赁物", "prefixes": ("lease.", "asset.")},
{"id": "enhancement", "title": "增信措施", "prefixes": ("guarantee.", "enhancement.")},
{"id": "credit_legal", "title": "征信与涉诉", "prefixes": ("credit.", "legal.", "lawsuit.")},
{"id": "transaction", "title": "交易结构与还款", "prefixes": ("facility.", "collection.", "repayment.")},
)
class ProjectAssistantService(DocumentsMixin, ExperiencesMixin):
def __init__(self, db_path=None, fixture_path=None):
@@ -25,32 +35,50 @@ class ProjectAssistantService(DocumentsMixin, ExperiencesMixin):
initialize(self.db_path)
with self.connect() as db:
db.execute("BEGIN IMMEDIATE")
if db.execute("SELECT 1 FROM assistant_meta WHERE key='schema_v2'").fetchone():
return
registry.seed(db)
fixtures = json.loads(self.fixture_path.read_text(encoding="utf-8"))
legacy = "projects" in {r[0] for r in db.execute("SELECT name FROM sqlite_master WHERE type='table'")}
prior = {r[0]: json.loads(r[1]) for r in db.execute("SELECT id,body FROM projects")} if legacy else {}
for item in fixtures["projects"]:
body = deepcopy(item)
if item["id"] in prior:
body["facts"].update(prior[item["id"]]["facts"])
body["migration_note"] = "沿用旧项目人工数据;旧版证据按未知来源归档,待重新核查"
self._seed_project(db, body, fixtures["data_domain"])
if legacy:
for table in ("runs", "snapshots", "findings", "scenarios", "reports", "experiences"):
for row in db.execute("SELECT id,body FROM " + table):
old = json.loads(row[1])
put(db, "legacy_archive", {"id": table + ":" + row[0], "project_id": old.get("project_id", ""), "kind": table,
"legacy_id": row[0], "body": old, "provenance": "legacy_unknown"}, immutable=True)
event(db, "", "migration.v2", "system", {"projects": list(prior), "legacy_tables": "retained_read_only"})
db.execute("INSERT INTO assistant_meta VALUES ('schema_v2',?)", (now(),))
if not db.execute("SELECT 1 FROM assistant_meta WHERE key='schema_v2'").fetchone():
registry.seed(db)
legacy = "projects" in {r[0] for r in db.execute("SELECT name FROM sqlite_master WHERE type='table'")}
prior = {r[0]: json.loads(r[1]) for r in db.execute("SELECT id,body FROM projects")} if legacy else {}
for item in fixtures["projects"]:
body = deepcopy(item)
if item["id"] in prior:
body["facts"].update(prior[item["id"]]["facts"])
body["migration_note"] = "沿用旧项目人工数据;旧版证据按未知来源归档,待重新核查"
self._seed_project(db, body, fixtures["data_domain"])
if legacy:
for table in ("runs", "snapshots", "findings", "scenarios", "reports", "experiences"):
for row in db.execute("SELECT id,body FROM " + table):
old = json.loads(row[1])
put(db, "legacy_archive", {"id": table + ":" + row[0], "project_id": old.get("project_id", ""), "kind": table,
"legacy_id": row[0], "body": old, "provenance": "legacy_unknown"}, immutable=True)
event(db, "", "migration.v2", "system", {"projects": list(prior), "legacy_tables": "retained_read_only"})
db.execute("INSERT INTO assistant_meta VALUES ('schema_v2',?)", (now(),))
registry.seed(db)
if not db.execute("SELECT 1 FROM assistant_meta WHERE key='business_flow_v1'").fetchone():
for item in fixtures["projects"]:
if not db.execute("SELECT 1 FROM business_time_events WHERE project_id=?", (item["id"],)).fetchone():
self._seed_timeline(db, item)
project = get(db, "project", item["id"])
for key in ("workflow_state", "manager_name", "reviewer_name", "stage"):
if key in item:
project[key] = item[key]
put(db, "project", project)
db.execute("INSERT INTO assistant_meta VALUES ('business_flow_v1',?)", (now(),))
if not db.execute("SELECT 1 FROM assistant_meta WHERE key='review_pool_v1'").fetchone():
for item in fixtures["projects"]:
if item.get("reviewer_id"):
project = get(db, "project", item["id"])
if not project.get("reviewer_id") and project.get("workflow_state") == "under_review":
project["reviewer_id"] = item["reviewer_id"]
put(db, "project", project)
db.execute("INSERT INTO assistant_meta VALUES ('review_pool_v1',?)", (now(),))
def connect(self):
return transaction(self.db_path)
def _seed_project(self, db, item, domain):
project = {k: deepcopy(v) for k, v in item.items() if k != "documents"}
project = {k: deepcopy(v) for k, v in item.items() if k not in ("documents", "timeline_demo")}
project.update(data_domain=domain, data_revision=1, period="2025", scope="合并", values={}, sources={}, selected_sources={}, document_ids=[], pending_candidates=[])
document = demo_material(project)
put(db, "document", document, immutable=True)
@@ -64,6 +92,58 @@ class ProjectAssistantService(DocumentsMixin, ExperiencesMixin):
apply_candidate(project, candidate, "demo-fixture", "按设备唯一编号去重计算,重复项仍保留核查")
put(db, "project", project)
def _seed_timeline(self, db, item):
anchor = datetime.now(timezone.utc)
for index, phase in enumerate(item.get("timeline_demo", [])):
started = anchor - timedelta(hours=float(phase["started_hours_ago"]))
duration = phase.get("duration_hours")
ended = started + timedelta(hours=float(duration)) if duration is not None else None
time_event(
db, item["id"], phase["phase_code"], phase["phase_name"], phase["role"], phase["category"],
"demo-fixture", "fixture", f"{item['id']}:{index}", status="completed" if ended else "running",
started_at=started.isoformat(), ended_at=ended.isoformat() if ended else None,
detail={"demo": True}, event_id=f"time-{item['id'].lower()}-{index + 1}",
)
@staticmethod
def _hours(started_at, ended_at=None):
start = datetime.fromisoformat(started_at)
end = datetime.fromisoformat(ended_at) if ended_at else datetime.now(timezone.utc)
return round(max(0, (end - start).total_seconds() / 3600), 2)
def project_timeline(self, project_id):
with self.connect() as db:
events = time_listing(db, [project_id])
for item in events:
item["duration_hours"] = self._hours(item["started_at"], item["ended_at"])
return events
def time_dashboard(self, project_ids):
with self.connect() as db:
events = time_listing(db, project_ids)
projects = {p["id"]: p for p in listing(db, "project") if p["id"] in project_ids}
phase_totals, category_totals, project_totals = {}, {}, {}
enriched = []
for item in events:
duration = self._hours(item["started_at"], item["ended_at"])
current = dict(item, duration_hours=duration)
enriched.append(current)
phase_totals.setdefault(item["phase_code"], {"code": item["phase_code"], "name": item["phase_name"], "hours": 0, "count": 0})
phase_totals[item["phase_code"]]["hours"] += duration
phase_totals[item["phase_code"]]["count"] += 1
category_totals[item["category"]] = round(category_totals.get(item["category"], 0) + duration, 2)
project_totals[item["project_id"]] = round(project_totals.get(item["project_id"], 0) + duration, 2)
phases = [dict(value, hours=round(value["hours"], 2), average_hours=round(value["hours"] / value["count"], 2)) for value in phase_totals.values()]
project_rows = [dict(project_id=key, customer=projects.get(key, {}).get("customer", key), industry=projects.get(key, {}).get("industry", ""),
stage=projects.get(key, {}).get("stage", ""), total_hours=value,
events=[e for e in enriched if e["project_id"] == key]) for key, value in project_totals.items()]
totals = sorted(project_totals.values())
median = totals[len(totals) // 2] if totals else 0
return {"projects": project_rows, "phases": phases, "categories": category_totals,
"summary": {"project_count": len(project_rows), "median_hours": median,
"active_hours": category_totals.get("active", 0), "wait_hours": category_totals.get("external_wait", 0) + category_totals.get("queue", 0),
"system_hours": category_totals.get("system", 0)}}
def resource(self, kind, object_id, project_id=None):
with self.connect() as db:
body = get(db, kind, object_id)
@@ -79,21 +159,193 @@ class ProjectAssistantService(DocumentsMixin, ExperiencesMixin):
with self.connect() as db:
projects = listing(db, "project")
for project in projects:
timeline = time_listing(db, [project["id"]])
queue = next((item for item in reversed(timeline) if item["phase_code"] == "review_queue"), None)
review = next((item for item in reversed(timeline) if item["phase_code"] == "independent_review"), None)
submitted_at = project.get("submitted_at") or (queue and queue["started_at"])
project["submitted_at"] = submitted_at
project["review_started_at"] = project.get("review_started_at") or (review and review["started_at"])
project["review_wait_hours"] = self._hours(submitted_at, project["review_started_at"]) if submitted_at else None
project["review_state"] = "评审中" if project.get("reviewer_id") else "待领取" if submitted_at else "尚未送审"
runs = listing(db, "run", project["id"])
project["latest_run"] = self.public_run(runs[0]) if runs else None
project["open_findings"] = sum(f["status"] not in ("已解决", "不适用") for f in listing(db, "finding", project["id"]))
reports = listing(db, "report", project["id"])
if not runs:
project["next_action"] = "发起AI预审"
elif runs[0]["status"] in ("queued", "running"):
project["next_action"] = "查看预审进度"
elif project["open_findings"]:
project["next_action"] = f"处理{project['open_findings']}项风险"
elif not reports:
project["next_action"] = "生成预审报告"
elif project.get("workflow_state") != "submitted":
project["next_action"] = "提交正式评审"
else:
project["next_action"] = "查看评审进度"
return sorted(projects, key=lambda p: p["id"])
@staticmethod
def _topic_for_field(field):
return next((topic["id"] for topic in REVIEW_TOPICS if any(field.startswith(prefix) for prefix in topic["prefixes"])), "subject")
@classmethod
def _topic_for_check(cls, check):
code = check.get("code", "").lower()
if any(word in code for word in ("asset", "lease", "coverage")):
return "lease_asset"
if any(word in code for word in ("financial", "cashflow", "liabilit", "leverage")):
return "financial"
if any(word in code for word in ("credit", "legal", "lawsuit")):
return "credit_legal"
if any(word in code for word in ("guarantee", "enhancement")):
return "enhancement"
if any(word in code for word in ("facility", "collection", "repayment")):
return "transaction"
return cls._topic_for_field(next(iter(check.get("fields", [])), ""))
def _review_topics(self, db, project_id, runs=None, decisions=None):
runs = runs if runs is not None else listing(db, "run", project_id)
decisions = decisions if decisions is not None else listing(db, "review_decision", project_id)
run = next((item for item in runs if item.get("run_kind") == "independent_review" and item.get("status") in ("completed", "partial")), None)
checks = run.get("checks", []) if run else []
evidence = run.get("evidence", []) if run else []
result = []
for definition in REVIEW_TOPICS:
topic_checks = [item for item in checks if self._topic_for_check(item) == definition["id"]]
evidence_ids = {key for item in topic_checks for key in item.get("evidence_ids", [])}
topic_sources = [item for item in evidence if item.get("id") in evidence_ids or self._topic_for_field(item.get("field", "")) == definition["id"]]
seen = set()
topic_sources = [item for item in topic_sources if not (item.get("id") in seen or seen.add(item.get("id")))]
basis_ids = sorted({key for item in topic_checks for key in item.get("basis_ids", [])})
states = {item.get("result") for item in topic_checks}
ai_status = "not_run" if not run else "issue" if states & {"issue", "error"} else "missing" if "unknown" in states or not topic_sources else "pass"
current_decision = next((item for item in decisions if item.get("node_id") == definition["id"] and item.get("run_id") == (run or {}).get("id")), None)
result.append({
"id": definition["id"], "title": definition["title"], "ai_status": ai_status,
"checks": topic_checks, "sources": topic_sources, "evidence_ids": [item.get("id") for item in topic_sources],
"basis_ids": basis_ids, "run_id": (run or {}).get("id"), "snapshot_id": (run or {}).get("snapshot_id"),
"decision": current_decision,
})
return result
def review_pool(self, actor, role):
with self.connect() as db:
rows = []
for project in listing(db, "project"):
submissions = listing(db, "submission", project["id"])
if role == "manager":
submissions = [item for item in submissions if item.get("submitted_by") == actor]
if not submissions:
continue
submission = submissions[0]
if role == "reviewer" and (submission.get("status") != "submitted" or project.get("reviewer_id") not in (None, actor)):
continue
ended_at = project.get("review_claimed_at") if submission.get("status") == "submitted" else submission.get("ended_at")
rows.append({
"project_id": project["id"], "customer": project["customer"], "industry": project["industry"],
"stage": project.get("stage", ""), "submission_id": submission["id"], "submission_version": submission.get("sequence", 1),
"submitted_at": submission["submitted_at"], "wait_hours": self._hours(submission["submitted_at"], ended_at),
"status": submission.get("status", "submitted"), "reviewer_id": project.get("reviewer_id"),
"review_claimed_at": project.get("review_claimed_at"), "report_id": submission["report_id"],
})
return sorted(rows, key=lambda item: item["wait_hours"], reverse=True)
def withdraw_review(self, project_id, actor):
with self.connect() as db:
db.execute("BEGIN IMMEDIATE")
project = get(db, "project", project_id)
submission = next((item for item in listing(db, "submission", project_id) if item.get("status") == "submitted"), None)
if not submission:
raise Conflict("当前没有可撤回的送审版本")
if submission.get("submitted_by") != actor:
raise PermissionError("只能撤回本人提交的项目")
ended_at = now()
previous_reviewer = project.get("reviewer_id")
submission.update(status="withdrawn", withdrawn_at=ended_at, withdrawn_by=actor, ended_at=ended_at)
put(db, "submission", submission)
for assignment in listing(db, "review_assignment", project_id):
if assignment.get("status") == "active":
assignment.update(status="withdrawn", ended_at=ended_at)
put(db, "review_assignment", assignment)
finish_time_event(db, "review_assignment", assignment["id"], "cancelled", ended_at)
finish_time_event(db, "submission", submission["id"], "cancelled", ended_at)
project.update(workflow_state="withdrawn", stage="已撤回", reviewer_id=None, review_claimed_at=None, review_started_at=None)
put(db, "project", project)
event(db, project_id, "review.withdrawn", actor, {"submission_id": submission["id"], "previous_reviewer": previous_reviewer})
return {"project_id": project_id, "submission_id": submission["id"], "status": "withdrawn", "withdrawn_at": ended_at}
def resubmit_review(self, project_id, actor):
with self.connect() as db:
db.execute("BEGIN IMMEDIATE")
project = get(db, "project", project_id)
if any(item.get("status") == "submitted" for item in listing(db, "submission", project_id)):
raise Conflict("项目已在评审池中,无需重复推送")
prior = next((item for item in listing(db, "submission", project_id) if item.get("submitted_by") == actor), None)
if not prior:
raise Conflict("未找到本人可重新推送的历史版本")
report = get(db, "report", prior["report_id"])
if report.get("data_revision") != project.get("data_revision") or any(section.get("stale") for section in report.get("sections", [])):
raise Conflict("项目数据或报告已变化,请先重新预审并生成报告")
sequence = 1 + max((item.get("sequence", 1) for item in listing(db, "submission", project_id)), default=0)
submission = {"id": uid("submission"), "project_id": project_id, "report_id": report["id"], "run_id": report["run_id"],
"submitted_by": actor, "submitted_at": now(), "status": "submitted", "sequence": sequence,
"content_hash": digest(report), "replaces_submission_id": prior["id"]}
put(db, "submission", submission, immutable=True)
report.update(status="submitted", submission_id=submission["id"], submitted_by=actor)
put(db, "report", report)
project.update(workflow_state="submitted", stage="待评审", submitted_at=submission["submitted_at"], reviewer_id=None,
review_claimed_at=None, review_started_at=None, review_decided_at=None)
put(db, "project", project)
time_event(db, project_id, "review_queue", "评审排队", "reviewer", "queue", actor, "submission", submission["id"])
event(db, project_id, "review.resubmitted", actor, {"submission_id": submission["id"], "replaces": prior["id"]})
return submission
def claim_review(self, project_id, actor):
with self.connect() as db:
db.execute("BEGIN IMMEDIATE")
project = get(db, "project", project_id)
if project.get("workflow_state") not in ("submitted", "under_review"):
raise Conflict("项目尚未由客户经理提交到评审池")
owner = project.get("reviewer_id")
if owner and owner != actor:
raise Conflict("该项目已被其他评审员领取")
submissions = [item for item in listing(db, "submission", project_id) if item.get("status") == "submitted"]
if not submissions:
raise Conflict("未找到客户经理的正式提交记录")
submission = submissions[0]
if owner == actor:
return {"project_id": project_id, "reviewer_id": actor, "claimed_at": project.get("review_claimed_at"),
"submitted_at": submission["submitted_at"], "wait_hours": self._hours(submission["submitted_at"], project.get("review_claimed_at"))}
claimed_at = now()
assignment = put(db, "review_assignment", {"id": "assignment-" + submission["id"], "project_id": project_id,
"submission_id": submission["id"], "reviewer_id": actor, "submitted_at": submission["submitted_at"],
"claimed_at": claimed_at, "status": "active"}, immutable=True)
project.update(workflow_state="under_review", stage="评审办理", reviewer_id=actor,
review_claimed_at=claimed_at, review_started_at=claimed_at)
put(db, "project", project)
finish_time_event(db, "submission", submission["id"])
time_event(db, project_id, "review_handling", "评审办理", "reviewer", "active", actor,
"review_assignment", assignment["id"], detail={"submitted_at": submission["submitted_at"]})
event(db, project_id, "review.claimed", actor, {"submission_id": submission["id"], "claimed_at": claimed_at})
return dict(assignment, wait_hours=self._hours(submission["submitted_at"], claimed_at))
def get_project(self, project_id):
with self.connect() as db:
project = get(db, "project", project_id)
for plural, kind in (("runs", "run"), ("findings", "finding"), ("scenarios", "scenario"), ("reports", "report"), ("experiences", "experience"), ("feedback", "feedback"), ("experience_applications", "experience_application")):
for plural, kind in (("runs", "run"), ("findings", "finding"), ("scenarios", "scenario"), ("reports", "report"), ("experiences", "experience"), ("feedback", "feedback"), ("experience_applications", "experience_application"), ("review_decisions", "review_decision")):
project[plural] = listing(db, kind, project_id)
project["runs"] = [self.public_run(r) for r in project["runs"]]
project["documents"] = [{k: v for k, v in get(db, "document", key).items() if k not in ("raw_base64", "chunks")} for key in project["document_ids"]]
project["legacy_records"] = [{"id": x["id"], "kind": x["kind"], "provenance": x["provenance"]} for x in listing(db, "legacy_archive", project_id)]
project["events"] = [{"seq": r[0], **json.loads(r[1])} for r in db.execute("SELECT seq,body FROM assistant_events WHERE project_id=? ORDER BY seq DESC LIMIT 100", (project_id,))]
project["plans"] = [{"id": v["component_id"], "name": v["name"], "version": v["version"]} for v in listing(db, "component_version") if v["kind"] == "plan" and v["scope"] == project["data_domain"] and get(db, "release", v["component_id"])["version_id"] == v["id"]]
project["plans"] = [{"id": v["component_id"], "name": v["name"], "version": v["version"],
"audience": v.get("audience", []), "run_kind": v.get("run_kind", "pre_review"), "template": v.get("template")}
for v in listing(db, "component_version") if v["kind"] == "plan" and v["scope"] == project["data_domain"] and get(db, "release", v["component_id"])["version_id"] == v["id"]]
project["timeline"] = time_listing(db, [project_id])
for item in project["timeline"]:
item["duration_hours"] = self._hours(item["started_at"], item["ended_at"])
project["review_topics"] = self._review_topics(db, project_id, project["runs"], project["review_decisions"])
return project
def enqueue(self, project_id, identity, requirement="", plan_id="corporate.default", idempotency_key="", expected=None, feedback_id=None):
@@ -116,6 +368,11 @@ class ProjectAssistantService(DocumentsMixin, ExperiencesMixin):
components = registry.freeze(db, plan_id, catalog)
if components["plan"]["scope"] != project["data_domain"]:
raise ValueError("方案与项目数据域不一致")
audience = components["plan"].get("audience", [])
if identity.get("role") != "admin" and audience and identity.get("role") not in audience:
raise PermissionError("当前身份不能使用该业务方案")
if components["plan"].get("run_kind") == "independent_review" and project.get("reviewer_id") != identity.get("id"):
raise PermissionError("请先从待评审项目池领取该项目")
documents = [{k: v for k, v in get(db, "document", key).items() if k != "raw_base64"} for key in project["document_ids"]]
for document in documents:
for index, chunk in enumerate(document["chunks"]):
@@ -124,12 +381,20 @@ class ProjectAssistantService(DocumentsMixin, ExperiencesMixin):
put(db, "input_snapshot", snapshot, immutable=True)
run = {"id": uid("run"), "project_id": project_id, "actor_id": identity["id"], "identity": identity, "status": "queued",
"created_at": now(), "requirement": requirement, "input_snapshot_id": snapshot["id"], "snapshot_id": None,
"run_kind": components["plan"].get("run_kind", "pre_review"), "plan_id": plan_id,
"components": components, "model_config": model, "steps": [], "summary": {}, "evidence": [],
"feature_flags": flags(),
"feedback_context": feedback,
"component_versions": [{"id": s["id"], "version": s["version"], "content_hash": s["content_hash"]} for s in [components["plan"]] + components["skills"] + components["rules"] + [components["template"]]]}
run = put(db, "run", run)
db.execute("INSERT INTO assistant_jobs(id,project_id,state) VALUES (?,?, 'queued')", (run["id"], project_id))
phase_code = "independent_review" if run["run_kind"] == "independent_review" else "pre_review"
if phase_code == "independent_review":
project.update(workflow_state="under_review", stage="独立评审")
put(db, "project", project)
time_event(db, project_id, phase_code, "独立评审" if phase_code == "independent_review" else "AI预审",
"reviewer" if phase_code == "independent_review" else "system", "active" if phase_code == "independent_review" else "system",
identity["id"], "run", run["id"], detail={"plan_id": plan_id})
if idempotency_key:
db.execute("INSERT INTO assistant_idempotency VALUES (?,?,?,?,?)", (identity["id"], "run", idempotency_key, request_hash, run["id"]))
event(db, project_id, "run.queued", identity["id"], {"input_snapshot_id": snapshot["id"], "component_hash": components["content_hash"], "requirement": requirement}, run["id"])
@@ -146,6 +411,7 @@ class ProjectAssistantService(DocumentsMixin, ExperiencesMixin):
run.update(status="cancelled", ended_at=now())
put(db, "run", run)
db.execute("UPDATE assistant_jobs SET state='cancelled',owner=NULL,lease_until=0 WHERE id=?", (run_id,))
finish_time_event(db, "run", run_id, "cancelled")
event(db, project_id, "run.cancelled", actor, run_id=run_id)
return self.public_run(run)
@@ -218,6 +484,69 @@ class ProjectAssistantService(DocumentsMixin, ExperiencesMixin):
event(db, project_id, "finding.action", actor, {"finding_id": finding_id, "status": status, "note": note})
return result
def review_decision(self, project_id, actor, decision, opinion, finding_id=None, node_id=None):
allowed = ("确认无异常", "确认风险可控", "认可AI判断", "要求补件", "转现场核查", "不采纳AI判断", "不适用", "批准", "退回")
if decision not in allowed:
raise ValueError("请选择有效评审结论")
if decision in ("确认风险可控", "要求补件", "转现场核查", "不采纳AI判断", "不适用", "退回") and not opinion.strip():
raise ValueError("当前结论必须选择或填写评审意见")
with self.connect() as db:
db.execute("BEGIN IMMEDIATE")
project = get(db, "project", project_id)
if project.get("reviewer_id") != actor:
raise PermissionError("请先从待评审项目池领取该项目")
if finding_id:
finding = get(db, "finding", finding_id)
if finding["project_id"] != project_id:
raise KeyError("问题不存在")
topics = self._review_topics(db, project_id)
topic = next((item for item in topics if item["id"] == node_id), None) if node_id else None
if node_id and not topic:
raise ValueError("必审事项不存在")
if finding_id:
run = next((item for item in listing(db, "run", project_id) if item["id"] == finding.get("last_seen_run_id")), None)
evidence_ids, basis_ids = finding.get("evidence_ids", []), finding.get("basis_ids", [])
trace_sources = [item for item in (run or {}).get("evidence", []) if item.get("id") in evidence_ids]
elif topic:
run = next((item for item in listing(db, "run", project_id) if item["id"] == topic.get("run_id")), None)
evidence_ids, basis_ids, trace_sources = topic["evidence_ids"], topic["basis_ids"], topic["sources"]
else:
run, evidence_ids, basis_ids, trace_sources = None, [], [], []
if node_id and not run:
raise Conflict("请先完成独立评审检查,再确认必审事项")
if not finding_id and not node_id and decision == "批准":
current = {item["id"]: item.get("decision") for item in topics}
missing = [item["title"] for item in topics if not current[item["id"]]]
blocking = [item["title"] for item in topics if current[item["id"]] and current[item["id"]].get("decision") in ("要求补件", "转现场核查", "认可AI判断")]
if missing:
raise Conflict("请先确认全部必审事项:" + "、".join(missing))
if blocking:
raise Conflict("以下事项尚未闭环,不能批准:" + "、".join(blocking))
body = {"id": uid("review"), "project_id": project_id, "finding_id": finding_id, "node_id": node_id,
"node_title": topic.get("title") if topic else None, "decision": decision, "opinion": opinion.strip(),
"actor_id": actor, "updated_at": now(), "run_id": (run or {}).get("id"),
"source_snapshot_id": (run or {}).get("snapshot_id"), "evidence_ids": evidence_ids,
"basis_ids": basis_ids, "trace_sources": deepcopy(trace_sources),
"decision_refs": [item.get("decision", {}).get("id") for item in topics if item.get("decision")] if not node_id and not finding_id else []}
result = put(db, "review_decision", body, immutable=True)
if not finding_id and decision in ("批准", "退回"):
project.update(workflow_state="review_approved" if decision == "批准" else "returned",
stage="评审通过" if decision == "批准" else "退回补件", review_decided_at=body["updated_at"])
put(db, "project", project)
finish_time_event(db, "run", next((r["id"] for r in listing(db, "run", project_id) if r.get("run_kind") == "independent_review"), ""))
assignment = next((item for item in listing(db, "review_assignment", project_id) if item.get("status") == "active" and item.get("reviewer_id") == actor), None)
if assignment:
assignment.update(status="completed", decided_at=body["updated_at"], decision=decision)
put(db, "review_assignment", assignment)
finish_time_event(db, "review_assignment", assignment["id"])
submission = next((item for item in listing(db, "submission", project_id) if item.get("status") == "submitted"), None)
if submission:
submission.update(status="approved" if decision == "批准" else "returned", ended_at=body["updated_at"], decision_id=result["id"])
put(db, "submission", submission)
event(db, project_id, "review.decision", actor, {"finding_id": finding_id, "node_id": node_id, "decision": decision,
"opinion": opinion, "snapshot_id": body["source_snapshot_id"], "evidence_ids": evidence_ids})
return result
def compare_scenario(self, project_id, amount, actor):
with self.connect() as db:
project = get(db, "project", project_id)