"""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()