2026-09-11 15:56:28 +08:00
"""Snapshot-grounded report drafts, protected human text and immutable final copies."""
from copy import deepcopy
import io
2026-09-15 10:31:50 +08:00
from services.assistant_store import get , put , listing , event , uid , now , digest , Conflict , time_event , finish_time_event
2026-09-11 15:56:28 +08:00
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 "本次已执行的检查未发现待处理事项;不表示未执行的检查已通过。"
2026-09-15 10:31:50 +08:00
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 "尚无客户经理确认说明;风险回应及补件记录保留在项目问题清单。"
2026-09-11 15:56:28 +08:00
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" ]]
2026-09-15 10:31:50 +08:00
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仅提供核查建议。"
2026-09-11 15:56:28 +08:00
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 )
2026-09-15 10:31:50 +08:00
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 })
2026-09-11 15:56:28 +08:00
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 )
2026-09-15 10:31:50 +08:00
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" ])
2026-09-11 15:56:28 +08:00
else :
raise ValueError ( "不支持的底稿动作" )
2026-09-15 10:31:50 +08:00
if action not in ( "finalize" , "submit" ):
2026-09-11 15:56:28 +08:00
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 ()