覆盖 AC-50/51/52(BR-48/49、E-28)。审计确认的 P1-3 缺口"历史提炼与卡片 不在实时 API 调用图上"由此关闭:提炼、候选治理、卡片进方案全部有真实调用方。 后端: - contracts:新增 E-28 EXTRACTION_UNAVAILABLE(运行级,不进 HTTP 信封)与 KnowledgeExtraction 记录类型。契约层强制"失败不得带候选", AC-50「模型不可用⇒不产生候选」是类型约束而不是文档约定。 - services/experience_extraction.py(新):真实提炼入口。模型不可用或调用失败 → 记 E-28 且 candidate_id 为空;不产半成品候选。传输纪律同 assistant_model (禁进程内代理、零重试),且 tools=[] 使"越权读材料"在结构上不可能。 自然语言修订同样必须真实调用模型,没有"把中文猜成条件"的回退路径。 - services/knowledge_cards.py:新增 current_draft/revise/list_cards 与 registry_conditions。修订只产草稿、保留 origin 与来源、升版本记 parent_version; test_cases 纳入可改字段——它是 component_evaluation 对自定义 Skill 的发布门禁, 不开放则提炼出的候选永远卡在评测门外。 - services/project_assistant.py:enqueue 改用 review_plan.assemble(), 让 T06-b 的依赖预检(E-19 可定位文案)与维度解析不再被绕过。 - api/prescreen.py:case-timeline、knowledge-extractions、knowledge-candidates (创建/修订/状态机)、review-plans(装配/状态机)。角色门禁与既有 /components 入口一致;id 与数据域由服务端生成;按 id 的路由一律在 run() 内 查归属,否则 KeyError 会变成 500 而不是 404。 - component_registry/review_plan:下沉 current_draft/version_key 与 list_plans。 前端: - 新增 /experience-center 独立入口(BR-50)与 ExperienceCenter 组件; AgentNav 增加「历史经验加工」。 - originBadge 此前零调用方、.origin-* 无任何 CSS——守卫只 grep 库文件字符串, 于是"测试通过但功能不存在"。本批补齐消费方与样式,并用左边框样式区分四种来源, 保证去色仍可区分(AC-42/BR-48)。 - 守卫语义按 AC-55 调整:由"禁止新建路由"改为"禁止重复页面" (面板不得搬到独立路由、已合并页必须保持重定向),并新增 experience-center-ui 守卫(已登记 verify.py,共 12 项检查)。 测试:新增 36 项(提炼 15 / 候选治理 15 / 卡片到运行 6),全量 unittest 无回归; 两次同项目运行对照证明卡片确实改变了核查结果,且 basis_ids 指向 卡片id@版本。 未闭合(记录在 17 的剩余缺口):真实模型提炼尚未实测(需端点); 界面未经浏览器点击验证;例外边界未进入可求值语言;规则类候选不在本批。 Co-Authored-By: Claude Code <noreply@anthropic.com>
623 lines
45 KiB
Python
623 lines
45 KiB
Python
"""Versioned project facade with durable jobs and resource-scoped operations."""
|
|
from copy import deepcopy
|
|
import json
|
|
from pathlib import Path
|
|
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, review_plan
|
|
from services.assistant_materials import demo_material, demo_assets, material, apply_candidate, FIELDS
|
|
from services.assistant_data import load_catalog
|
|
from services.assistant_model import configuration
|
|
from services.assistant_settings import flags
|
|
from services.assistant_documents import DocumentsMixin
|
|
from services.assistant_experiences import ExperiencesMixin
|
|
from services.skill_executor import ratio, number, execute_skill
|
|
|
|
FIXTURE_PATH = Path(__file__).resolve().parents[1] / "fixtures" / "xingbang_demo" / "projects.json"
|
|
DB_PATH = Path(__file__).resolve().parents[1] / "xingbang_data" / "project_assistant.sqlite3"
|
|
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.", "insurance.")},
|
|
{"id": "enhancement", "title": "增信措施", "prefixes": ("guarantee.", "enhancement.", "deposit.")},
|
|
{"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):
|
|
self.db_path = Path(db_path or DB_PATH)
|
|
self.fixture_path = Path(fixture_path or FIXTURE_PATH)
|
|
initialize(self.db_path)
|
|
with self.connect() as db:
|
|
db.execute("BEGIN IMMEDIATE")
|
|
fixtures = json.loads(self.fixture_path.read_text(encoding="utf-8"))
|
|
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 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)
|
|
project["document_ids"].append(document["id"])
|
|
for candidate in document["candidates"]:
|
|
apply_candidate(project, candidate, "demo-fixture", "演示源文件初始化" if not project.get("migration_note") else "旧数据迁移,待重新核查")
|
|
assets = demo_assets(project)
|
|
put(db, "document", assets, immutable=True)
|
|
project["document_ids"].append(assets["id"])
|
|
for candidate in assets["candidates"]:
|
|
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)
|
|
if project_id is not None and body.get("project_id") != project_id:
|
|
raise KeyError("资源不属于该项目")
|
|
return body
|
|
|
|
@staticmethod
|
|
def public_run(run):
|
|
return {k: v for k, v in run.items() if k not in ("identity", "components", "model_config", "experience_versions")}
|
|
|
|
def list_projects(self):
|
|
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
|
|
decisions = listing(db, "review_decision", project["id"])
|
|
latest_return = next((item for item in decisions if item.get("decision") == "退回" and not item.get("finding_id") and not item.get("node_id")), None)
|
|
project["return_reason"] = (latest_return or {}).get("opinion", "")
|
|
project["returned_at"] = project.get("review_decided_at") or (latest_return or {}).get("updated_at")
|
|
project["review_state"] = "已退回" if project.get("workflow_state") == "returned" else "评审中" 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 project.get("workflow_state") == "returned":
|
|
project["next_action"] = "查看退回要求并补充材料"
|
|
elif 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"), ("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"],
|
|
"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):
|
|
catalog, model = load_catalog(), configuration()
|
|
request_hash = digest({"project": project_id, "requirement": requirement, "plan": plan_id, "expected": expected, "feedback_id": feedback_id})
|
|
with self.connect() as db:
|
|
db.execute("BEGIN IMMEDIATE")
|
|
if idempotency_key:
|
|
prior = db.execute("SELECT request_hash,object_id FROM assistant_idempotency WHERE actor=? AND operation=? AND key=?", (identity["id"], "run", idempotency_key)).fetchone()
|
|
if prior:
|
|
if prior[0] != request_hash:
|
|
raise Conflict("幂等键已用于不同请求")
|
|
return self.public_run(get(db, "run", prior[1]))
|
|
project = get(db, "project", project_id)
|
|
feedback = get(db, "feedback", feedback_id) if feedback_id else None
|
|
if feedback and (feedback["project_id"] != project_id or feedback["category"] in ("expression", "factual", "data")):
|
|
raise ValueError("此反馈不能用于本项目的核查经验提炼")
|
|
if expected is not None and project["revision"] != expected:
|
|
raise Conflict("项目已更新,请刷新后重新检查")
|
|
# 走 review_plan.assemble 而不是裸 registry.freeze:装配前先做依赖预检
|
|
# (E-19 的可定位文案)与维度解析,否则"草稿/已撤回组件不得进入新运行"
|
|
# 只能靠 freeze 抛裸 KeyError,用户看到的是"对象不存在"(T06-b 已实现但此前
|
|
# 被绕过——审计据此把 T06 记为部分完成)。
|
|
components = review_plan.assemble(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"]):
|
|
chunk.setdefault("evidence_id", "evdoc-" + digest([document["id"], document["content_hash"], index, chunk["locator"]])[:32])
|
|
snapshot = dict(deepcopy(project), id=uid("input"), project_id=project_id, project_revision=project["revision"], documents=documents, created_at=now())
|
|
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"])
|
|
return self.public_run(run)
|
|
|
|
def cancel(self, project_id, run_id, actor):
|
|
with self.connect() as db:
|
|
db.execute("BEGIN IMMEDIATE")
|
|
run = get(db, "run", run_id)
|
|
if run["project_id"] != project_id:
|
|
raise KeyError("运行不存在")
|
|
if run["status"] in TERMINAL:
|
|
raise Conflict("运行已结束")
|
|
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)
|
|
|
|
def upload(self, project_id, filename, content, actor):
|
|
document = material(project_id, filename, content, actor)
|
|
with self.connect() as db:
|
|
db.execute("BEGIN IMMEDIATE")
|
|
project = get(db, "project", project_id)
|
|
prior = listing(db, "document", project_id)
|
|
duplicate = next((d for d in prior if d["content_hash"] == document["content_hash"]), None)
|
|
if duplicate:
|
|
return {k: v for k, v in duplicate.items() if k != "raw_base64"}
|
|
document["version"] = 1 + max((d["version"] for d in prior if d["name"] == document["name"]), default=0)
|
|
put(db, "document", document, immutable=True)
|
|
project["document_ids"].append(document["id"])
|
|
project.setdefault("pending_candidates", []).extend(c["id"] for c in document["candidates"])
|
|
project["data_revision"] += 1
|
|
put(db, "project", project)
|
|
self.mark_stale(db, project_id, ["*"])
|
|
event(db, project_id, "document.uploaded", actor, {"document_id": document["id"], "hash": document["content_hash"]})
|
|
return {k: v for k, v in document.items() if k != "raw_base64"}
|
|
|
|
def supplement(self, project_id, actor, changes, finding_ids, expected, reason, candidate_ids=None):
|
|
if not reason.strip() or (not changes and not candidate_ids):
|
|
raise ValueError("请填写变更字段或材料候选以及确认依据")
|
|
if set(changes) - (set(FIELDS) - {"facility_amount", "lease_asset_value"}):
|
|
raise ValueError("补件字段不受支持;融资金额应使用情景采纳")
|
|
with self.connect() as db:
|
|
db.execute("BEGIN IMMEDIATE")
|
|
project = get(db, "project", project_id)
|
|
if project["revision"] != expected:
|
|
raise Conflict("项目数据已更新,请刷新")
|
|
findings = {f["id"]: f for f in listing(db, "finding", project_id)}
|
|
if set(finding_ids) - set(findings):
|
|
raise ValueError("关联问题不属于该项目")
|
|
candidates = [c for d in listing(db, "document", project_id) for c in d["candidates"] if c["id"] in (candidate_ids or [])]
|
|
if len(candidates) != len(set(candidate_ids or [])):
|
|
raise ValueError("材料候选不存在或不属于项目")
|
|
for field, value in changes.items():
|
|
standard = FIELDS[field]
|
|
source = next((r for r in project["sources"].get(standard, []) if r["id"] == project["selected_sources"].get(standard)), {})
|
|
candidates.append({"field": standard, "value": value, "source": "人工补充", "locator": "补件说明:" + reason,
|
|
**{k: source.get(k) for k in ("period", "scope", "unit", "currency")}})
|
|
if len({c["field"] for c in candidates}) != len(candidates):
|
|
raise ValueError("同一字段有多个候选,请明确唯一来源")
|
|
before = deepcopy(project["values"])
|
|
for candidate in candidates:
|
|
apply_candidate(project, candidate, actor, reason)
|
|
if candidate.get("id") in project.get("pending_candidates", []):
|
|
project["pending_candidates"].remove(candidate["id"])
|
|
project["data_revision"] += 1
|
|
project = put(db, "project", project, expected=expected)
|
|
changed = [k for k, v in project["values"].items() if before.get(k) != v]
|
|
self.mark_stale(db, project_id, changed or ["*"])
|
|
event(db, project_id, "supplement.accepted", actor, {"fields": changed, "finding_ids": finding_ids, "reason": reason, "data_revision": project["data_revision"]})
|
|
return {"project_id": project_id, "revision": project["revision"], "changed_fields": changed, "status": "待重新检查"}
|
|
|
|
def finding_action(self, project_id, finding_id, actor, status, note, expected):
|
|
if status not in ("待处理", "待补件", "待复核", "不适用") or not note.strip():
|
|
raise ValueError("需选择有效状态并填写说明;已解决仅由有依据的检查确认")
|
|
with self.connect() as db:
|
|
db.execute("BEGIN IMMEDIATE")
|
|
finding = get(db, "finding", finding_id)
|
|
if finding["project_id"] != project_id:
|
|
raise KeyError("问题不存在")
|
|
finding["history"].append({"time": now(), "actor": actor, "from": finding["status"], "to": status, "note": note, "type": "human"})
|
|
finding.update(status=status, human_status=status, human_note=note)
|
|
result = put(db, "finding", finding, expected=expected)
|
|
self.mark_stale(db, project_id, ["*"])
|
|
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)
|
|
source = project["values"]
|
|
selected = {f: [r for r in refs if r["id"] == project["selected_sources"].get(f)] for f, refs in project["sources"].items()}
|
|
skill = {"id": "scenario.coverage", "version": "1.0.0", "engine": "ratio", "numerator": "lease.total_value", "denominator": "facility.amount", "output": "metrics.coverage"}
|
|
baseline = execute_skill(skill, source, selected)[0]["metrics.coverage"]
|
|
proposed = execute_skill(skill, dict(source, **{"facility.amount": amount}), selected)[0]["metrics.coverage"]
|
|
scenario = {"id": uid("scenario"), "project_id": project_id, "status": "comparison", "actor_id": actor, "created_at": now(),
|
|
"project_revision": project["revision"], "data_revision": project["data_revision"], "formula_version": FORMULA_VERSION,
|
|
"sources": deepcopy(project["sources"]), "baseline": {"facility_amount": source["facility.amount"], "coverage": baseline},
|
|
"scenario": {"facility_amount": str(number(amount)), "coverage": proposed}, "delta": str(number(proposed) - number(baseline)),
|
|
"unresolved": [f["id"] for f in listing(db, "finding", project_id) if f["status"] not in ("已解决", "不适用") and f["code"] != "lease.coverage.below_demo_threshold"],
|
|
"assumptions": ["租赁物价值与评估时点不变", "金额单位万元,币种人民币", "120%仅为演示阈值,非正式制度"], "data_domain": project["data_domain"]}
|
|
result = put(db, "scenario", scenario, immutable=True)
|
|
event(db, project_id, "scenario.compared", actor, {"scenario_id": result["id"]})
|
|
return result
|
|
|
|
def adopt_scenario(self, project_id, scenario_id, actor, expected, reason):
|
|
if not reason.strip():
|
|
raise ValueError("请填写采纳理由")
|
|
with self.connect() as db:
|
|
db.execute("BEGIN IMMEDIATE")
|
|
scenario = get(db, "scenario", scenario_id)
|
|
project = get(db, "project", project_id)
|
|
if scenario["project_id"] != project_id:
|
|
raise KeyError("情景不存在")
|
|
if scenario["project_revision"] != project["revision"] or expected != project["revision"]:
|
|
raise Conflict("情景基线已过期,请重新比较")
|
|
selected = next(r for r in project["sources"]["facility.amount"] if r["id"] == project["selected_sources"]["facility.amount"])
|
|
candidate = {**selected, "field": "facility.amount", "value": scenario["scenario"]["facility_amount"], "source": "采纳融资情景", "locator": scenario_id}
|
|
apply_candidate(project, candidate, actor, reason)
|
|
project["data_revision"] += 1
|
|
result = put(db, "project", project, expected=expected)
|
|
self.mark_stale(db, project_id, ["facility.amount", "metrics.coverage"])
|
|
event(db, project_id, "scenario.adopted", actor, {"scenario_id": scenario_id, "reason": reason})
|
|
return {"project_id": project_id, "revision": result["revision"], "status": "待重新检查"}
|
|
|
|
def reset_demo(self, actor, confirmation):
|
|
if confirmation != "RESET-DEMO-A-B-C":
|
|
raise ValueError("请输入 RESET-DEMO-A-B-C 确认仅重置三个演示实例")
|
|
fixtures = json.loads(self.fixture_path.read_text(encoding="utf-8"))
|
|
with self.connect() as db:
|
|
db.execute("BEGIN IMMEDIATE")
|
|
for item in fixtures["projects"]:
|
|
current = get(db, "project", item["id"])
|
|
if current["data_domain"] != "demo":
|
|
raise ValueError("禁止重置正式项目")
|
|
records = [dict(r) for r in db.execute("SELECT * FROM assistant_objects WHERE project_id=? OR (kind='project' AND id=?)", (item["id"], item["id"]))]
|
|
events = [dict(r) for r in db.execute("SELECT * FROM assistant_events WHERE project_id=? ORDER BY seq", (item["id"],))]
|
|
time_events = [dict(r) for r in db.execute("SELECT * FROM business_time_events WHERE project_id=? ORDER BY started_at", (item["id"],))]
|
|
put(db, "reset_archive", {"id": uid("reset"), "source_project_id": item["id"], "records": records,
|
|
"events": events, "time_events": time_events, "actor": actor, "created_at": now()}, immutable=True)
|
|
db.execute("DELETE FROM assistant_jobs WHERE project_id=?", (item["id"],))
|
|
db.execute("DELETE FROM assistant_idempotency WHERE object_id IN (SELECT id FROM assistant_objects WHERE project_id=?)", (item["id"],))
|
|
db.execute("DELETE FROM assistant_objects WHERE project_id=? OR (kind='project' AND id=?)", (item["id"], item["id"]))
|
|
db.execute("DELETE FROM assistant_events WHERE project_id=?", (item["id"],))
|
|
db.execute("DELETE FROM business_time_events WHERE project_id=?", (item["id"],))
|
|
self._seed_project(db, item, "demo")
|
|
self._seed_timeline(db, item)
|
|
event(db, item["id"], "demo.reset", actor, {"recoverable": True})
|
|
return {"status": "reset", "data_domain": "demo", "projects": len(fixtures["projects"]),
|
|
"recoverable": True, "history_reset": True}
|