175 lines
12 KiB
Python
175 lines
12 KiB
Python
"""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.component_registry import resolve
|
||
|
||
|
||
def section_content(section_id, snapshot, run):
|
||
values = snapshot["values"]
|
||
unresolved = [f for f in run.get("findings", []) if f["status"] not in ("已解决", "不适用")]
|
||
if section_id == "overview":
|
||
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 "本次已执行的检查未发现待处理事项;不表示未执行的检查已通过。"
|
||
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本底稿仅供核查,不构成审批结论。"
|
||
|
||
|
||
class DocumentsMixin:
|
||
@staticmethod
|
||
def mark_stale(db, project_id, changed):
|
||
for finding in listing(db, "finding", project_id):
|
||
if "*" in changed or set(finding.get("fields", ["*"])) & set(changed) or not finding.get("fields"):
|
||
finding["stale"] = True
|
||
put(db, "finding", finding)
|
||
for report in listing(db, "report", project_id):
|
||
if report["status"] == "finalized":
|
||
continue
|
||
affected = False
|
||
for section in report["sections"]:
|
||
deps = section.get("dependencies", ["*"])
|
||
if "*" in deps or "*" in changed or set(deps) & set(changed):
|
||
section["stale"] = True
|
||
affected = True
|
||
if affected:
|
||
report["status"] = "stale"
|
||
put(db, "report", report)
|
||
|
||
def create_report(self, project_id, run_id, actor, template_id="template.due-diligence", idempotency_key=""):
|
||
with self.connect() as db:
|
||
db.execute("BEGIN IMMEDIATE")
|
||
request_hash = digest({"project_id": project_id, "run_id": run_id, "template_id": template_id})
|
||
if idempotency_key:
|
||
prior = db.execute("SELECT request_hash,object_id FROM assistant_idempotency WHERE actor=? AND operation='report' AND key=?", (actor, idempotency_key)).fetchone()
|
||
if prior:
|
||
if prior[0] != request_hash:
|
||
raise Conflict("幂等键已用于不同底稿请求")
|
||
return get(db, "report", prior[1])
|
||
project = get(db, "project", project_id)
|
||
run = get(db, "run", run_id)
|
||
if run["project_id"] != project_id:
|
||
raise KeyError("运行记录不存在")
|
||
if run["status"] not in ("completed", "partial") or not run.get("snapshot_id"):
|
||
raise Conflict("仅可从已完成或部分完成的冻结运行生成底稿")
|
||
snapshot = get(db, "snapshot", run["snapshot_id"])
|
||
template = resolve(db, template_id)
|
||
if template["kind"] != "template" or template["scope"] != project["data_domain"]:
|
||
raise ValueError("模板类型或数据域不匹配")
|
||
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"]]
|
||
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)
|
||
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"]})
|
||
return result
|
||
|
||
def report_action(self, project_id, report_id, actor, action, payload, expected):
|
||
with self.connect() as db:
|
||
db.execute("BEGIN IMMEDIATE")
|
||
report = get(db, "report", report_id)
|
||
if report["project_id"] != project_id:
|
||
raise KeyError("底稿不存在")
|
||
if report["revision"] != expected or report["status"] == "finalized":
|
||
raise Conflict("底稿已更新或已定稿,不可覆盖")
|
||
if action == "regenerate":
|
||
run = get(db, "run", payload["run_id"])
|
||
if run["project_id"] != project_id or run["status"] not in ("completed", "partial"):
|
||
raise ValueError("需选择本项目已结束的运行")
|
||
snapshot = get(db, "snapshot", run["snapshot_id"])
|
||
if snapshot["data_revision"] != get(db, "project", project_id)["data_revision"]:
|
||
raise Conflict("运行快照已过期,请先重新检查")
|
||
for section in report["sections"]:
|
||
candidate = {"content": section_content(section["id"], snapshot, run), "snapshot_id": snapshot["id"], "run_id": run["id"], "data_revision": snapshot["data_revision"]}
|
||
if section["locked"] or section["confirmed"]:
|
||
section.update(candidate=candidate, stale=True)
|
||
else:
|
||
section["history"].append({"content": section["content"], "snapshot_id": section["source_snapshot_id"], "actor": actor, "time": now()})
|
||
section.update(content=candidate["content"], source_snapshot_id=snapshot["id"], stale=False, candidate=None)
|
||
report.update(run_id=run["id"], snapshot_id=snapshot["id"], data_revision=snapshot["data_revision"], evidence=run["evidence"])
|
||
elif action == "section":
|
||
section = next((s for s in report["sections"] if s["id"] == payload.get("section_id")), None)
|
||
if not section:
|
||
raise KeyError("章节不存在")
|
||
section["history"].append({"content": section["content"], "snapshot_id": section["source_snapshot_id"], "actor": actor, "time": now()})
|
||
if payload.get("accept_candidate"):
|
||
candidate = section.get("candidate")
|
||
if not candidate:
|
||
raise Conflict("没有待采纳候选")
|
||
if candidate["data_revision"] != get(db, "project", project_id)["data_revision"]:
|
||
raise Conflict("候选内容已过期")
|
||
section.update(content=candidate["content"], source_snapshot_id=candidate["snapshot_id"], candidate=None, stale=False, confirmed=False, locked=False)
|
||
else:
|
||
if section["locked"] and payload.get("content", section["content"]) != section["content"]:
|
||
raise Conflict("请先解除锁定再编辑")
|
||
if "content" in payload:
|
||
if not isinstance(payload["content"], str) or len(payload["content"]) > 20000:
|
||
raise ValueError("章节内容过长")
|
||
if section["id"] in ("overview", "findings") and payload["content"] != section["content"]:
|
||
raise ValueError("事实和机器检查清单请通过补件与复核修正;核查意见章节可人工编辑")
|
||
section["content"] = payload["content"]
|
||
section["edited_by"] = actor
|
||
if payload.get("confirmed") and section["stale"]:
|
||
raise Conflict("过期章节需先重生成或采纳新候选")
|
||
section.update(locked=bool(payload.get("locked", section["locked"])), confirmed=bool(payload.get("confirmed", False)))
|
||
elif action == "finalize":
|
||
project = get(db, "project", project_id)
|
||
if report["data_revision"] != project["data_revision"] or any(s["stale"] or not s["confirmed"] or s.get("candidate") for s in report["sections"]):
|
||
raise Conflict("所有章节须确认且来源未过期,才能定稿")
|
||
final = dict(deepcopy(report), id=uid("final"), report_id=report_id, finalized_by=actor, finalized_at=now(), status="finalized")
|
||
final["content_hash"] = digest(final)
|
||
put(db, "report_final", final, immutable=True)
|
||
report.update(status="finalized", final_id=final["id"], finalized_by=actor)
|
||
else:
|
||
raise ValueError("不支持的底稿动作")
|
||
if action != "finalize":
|
||
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")})
|
||
return result
|
||
|
||
def export_report(self, project_id, report_id):
|
||
report = self.resource("report", report_id, project_id)
|
||
if report.get("final_id"):
|
||
report = self.resource("report_final", report["final_id"], project_id)
|
||
from docx import Document
|
||
from docx.shared import Pt, Cm, RGBColor
|
||
from docx.oxml.ns import qn
|
||
document = Document()
|
||
section = document.sections[0]
|
||
section.page_width, section.page_height = Cm(21), Cm(29.7)
|
||
section.top_margin = section.bottom_margin = Cm(2.2)
|
||
section.left_margin = section.right_margin = Cm(2.4)
|
||
for name in ("Normal", "Title", "Heading 1", "Heading 2"):
|
||
style = document.styles[name]
|
||
style.font.name = "Microsoft YaHei"
|
||
style._element.get_or_add_rPr().rFonts.set(qn("w:eastAsia"), "Microsoft YaHei")
|
||
style.font.color.rgb = RGBColor(0, 0, 0)
|
||
style.font.size = Pt(11 if name == "Normal" else 18 if name == "Title" else 13)
|
||
document.styles["Normal"].paragraph_format.line_spacing = 1.3
|
||
document.add_paragraph(report["template"]["name"], "Title")
|
||
document.add_paragraph(report["disclaimer"] + ("|已定稿" if report["status"] == "finalized" else "|草稿"))
|
||
document.add_paragraph(f"项目 {project_id}\n运行 {report['run_id']}\n快照 {report['snapshot_id']}")
|
||
for item in report["sections"]:
|
||
document.add_heading(item["title"], level=1)
|
||
if item["stale"]:
|
||
document.add_paragraph("本节来源已过期,需重新核查。")
|
||
for line in item["content"].splitlines():
|
||
document.add_paragraph(line)
|
||
document.add_heading("证据索引", level=1)
|
||
used = set()
|
||
for evidence in report["evidence"]:
|
||
if evidence["id"] not in used:
|
||
document.add_paragraph(f"{evidence['id']}\n{evidence['source']},{evidence['locator']},字段 {evidence['field']};值 {evidence.get('value')}。")
|
||
used.add(evidence["id"])
|
||
buffer = io.BytesIO()
|
||
document.save(buffer)
|
||
return buffer.getvalue()
|