Files
mini-openclaw/backend/services/assistant_documents.py
T

175 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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()