"""Report and experience workflow backed by the first agent's immutable runs.""" import json from copy import deepcopy from io import BytesIO from uuid import uuid4 from fastapi import APIRouter, Depends, HTTPException, Request from fastapi.responses import Response from pydantic import BaseModel, Field from api.risk_agent_access import authorize, check_project from api import risk_agent_config as risk router=APIRouter(prefix="/api/xingbang/risk-agent/reports",tags=["报告与经验助手"],dependencies=[Depends(authorize)]) TEMPLATES=[{"id":"corporate-v1","name":"对公项目评审底稿(示例)","version":"1","sections":["项目事实","风险与证据","评审判断与缓释措施","方案调整与待落实条件"]}] def storage(db): db.execute("CREATE TABLE IF NOT EXISTS report_documents (id TEXT PRIMARY KEY, body TEXT)") def save(db, doc): db.execute("INSERT OR REPLACE INTO report_documents VALUES (?,?)",(doc["id"],json.dumps(doc,ensure_ascii=False))) def read(db, doc_id, request): row=db.execute("SELECT body FROM report_documents WHERE id=?",(doc_id,)).fetchone() if not row: raise HTTPException(404,"报告不存在") doc=json.loads(row[0]);check_project(request,doc["project_id"]) return doc def permitted(request, roles): if not request.state.identity["preview"] and request.state.identity["role"] not in roles: raise HTTPException(403,"当前身份没有此操作权限") @router.get("/templates") def templates():return TEMPLATES @router.get("/documents") def documents(request:Request): with risk._db() as db: storage(db) docs=[json.loads(r[0]) for r in db.execute("SELECT body FROM report_documents ORDER BY rowid DESC")] user=request.state.identity return [d for d in docs if "*" in user.get("projects",[]) or d["project_id"] in user.get("projects",[])] class Create(BaseModel): run_id:str template_id:str="corporate-v1" confirmation:str=Field(min_length=1,max_length=3000) @router.post("/documents") def create(payload:Create,request:Request): permitted(request,("admin","reviewer")) if not payload.confirmation.strip():raise HTTPException(422,"请填写来源确认意见") template=next((t for t in TEMPLATES if t["id"]==payload.template_id),None) if not template:raise HTTPException(422,"模板不存在") with risk._db() as db: storage(db) row=db.execute("SELECT body FROM runs WHERE id=?",(payload.run_id,)).fetchone() if not row:raise HTTPException(404,"研判记录不存在") source=json.loads(row[0]);check_project(request,source["project_id"]) facts="\n".join(f"{k}: {v if v is not None else '缺失 / 待核实'}" for k,v in source["values"].items()) risks="\n\n".join(f"{f['title']}({f['category']})\n核查建议:{f['suggestion']}\n来源:{json.dumps(f.get('evidence',[]),ensure_ascii=False)}" for f in source["findings"]) or "所选检查未发现异常,不代表项目无风险。" body=[facts,risks,"待主审员补充:风险判断、理由及缓释措施。","待补充:额度、期限、报价、担保调整及待落实条件。"] doc={"id":uuid4().hex,"project_id":source["project_id"],"source_id":source["id"],"source_time":source["time"],"source_values":source["values"],"source_findings":source["findings"],"source_datasets":source.get("datasets",[]),"source_evidence":source.get("evidence",{}),"template":deepcopy(template),"title":source["project_id"]+" 评审底稿","version":1,"status":"draft","created_by":request.state.identity["id"],"confirmation":payload.confirmation,"sections":[{"id":str(i),"title":title,"text":body[i],"locked":False} for i,title in enumerate(template["sections"])],"events":[],"experience":None,"generation":"结构化底稿,未使用模型"} save(db,doc);risk.audit(db,request,"report_create",{"id":doc["id"],"source":payload.run_id}) return doc class Action(BaseModel): version:int kind:str section_id:str="" text:str=Field(default="",max_length=50000) note:str=Field(default="",max_length=10000) locked:bool=False approval_result:str=Field(default="",max_length=5000) post_lease_result:str=Field(default="",max_length=5000) @router.post("/documents/{doc_id}/actions") def action(doc_id:str,payload:Action,request:Request): permitted(request,("admin","reviewer")) with risk._db() as db: storage(db);db.execute("BEGIN IMMEDIATE") doc=read(db,doc_id,request) if payload.version!=doc["version"]:raise HTTPException(409,"报告已更新,请刷新后操作") before=deepcopy(doc) if payload.kind in ("edit","lock"): if doc["status"]!="draft":raise HTTPException(409,"已定稿内容不可修改") section=next((s for s in doc["sections"] if s["id"]==payload.section_id),None) if not section:raise HTTPException(404,"章节不存在") if payload.kind=="edit": if payload.section_id in ("0","1"):raise HTTPException(409,"事实与证据章节来自固定快照,请更新来源后创建新版报告") if section["locked"]:raise HTTPException(409,"已确认锁定的章节不能覆盖,请先解锁") section["text"]=payload.text else:section["locked"]=payload.locked elif payload.kind=="finalize": permitted(request,("reviewer",)) if doc["status"]!="draft" or not all(s["locked"] for s in doc["sections"]) or not payload.note.strip(): raise HTTPException(409,"需要逐章确认锁定并填写定稿意见") doc.update(status="final",finalized_by=request.state.identity["id"],finalized_at=risk._now()) doc["experience"]={"status":"草稿","facts":deepcopy(doc["source_values"]),"risks":deepcopy(doc["source_findings"]),"judgment":doc["sections"][2]["text"],"measures":doc["sections"][3]["text"],"approval_result":"","post_lease_result":"","history":[]} elif payload.kind=="experience": permitted(request,("reviewer",)) card=doc["experience"] if not card or not payload.note.strip():raise HTTPException(422,"经验卡不存在或未填写确认意见") states=["草稿","主审员已确认","部门审核","可检索","已验证","已过期/归档"] index=states.index(card["status"]) if index==5:raise HTTPException(409,"经验卡已归档") next_state=states[index+1] if next_state=="可检索" and not request.state.identity["preview"] and request.state.identity["id"]==doc["finalized_by"]: raise HTTPException(403,"共享经验需由独立审核人确认") approval=payload.approval_result.strip() or card["approval_result"] outcome=payload.post_lease_result.strip() or card["post_lease_result"] if next_state=="可检索" and not approval:raise HTTPException(422,"可检索经验需补充审批结果") if next_state=="已验证" and not outcome:raise HTTPException(422,"已验证案例必须有租后观察结果") card.update(status=next_state,approval_result=approval,post_lease_result=outcome) card["history"].append({"state":next_state,"note":payload.note,"actor":request.state.identity["id"],"time":risk._now()}) else:raise HTTPException(422,"不支持的操作") doc["version"]+=1 doc["events"].append({"action":payload.kind,"actor":request.state.identity["id"],"time":risk._now(),"note":payload.note,"previous_sections":before["sections"] if payload.kind=="edit" else None}) save(db,doc);risk.audit(db,request,"report_"+payload.kind,{"id":doc_id,"version":doc["version"]}) return doc @router.post("/documents/{doc_id}/generate") def generate(doc_id:str,payload:Action,request:Request): permitted(request,("admin","reviewer")) with risk._db() as db: storage(db);doc=read(db,doc_id,request) section=next((s for s in doc["sections"] if s["id"]==payload.section_id),None) if doc["status"]!="draft" or doc["version"]!=payload.version or not section or section["locked"] or payload.section_id in ("0","1"): raise HTTPException(409,"仅可为当前版本的未锁定判断章节生成候选稿") from graph.risk_task import PROMPT context={"peer_datasets":doc["source_datasets"],"scalar_values":doc["source_values"],"rule_findings":doc["source_findings"],"human_rules":[{"instructions":"撰写报告章节:"+section["title"]+"。保留事实与判断边界,缺少依据时写待核实。"}]} if len(json.dumps([context,doc["source_evidence"]],ensure_ascii=False))>120000: raise HTTPException(422,"来源数据过大,请缩小数据卡查询范围后重新研判") try: generated=risk.local_model({"instructions":"生成“"+section["title"]+"”的报告候选稿,引用数据卡证据,不作审批决定。","native_skills":["financial-leasing-review-report/SKILL.md"]},context,doc["source_evidence"],system_prompt=PROMPT+"\n你当前承担报告助手任务:按指定章节撰写专业中文底稿,不编造金额、日期、审批结果,不覆盖已确认内容。") except Exception: raise HTTPException(503,"mini-openclaw 章节生成未完成,请检查本地模型配置与工具调用能力。已保存内容未改变。") from None with risk._db() as db: db.execute("BEGIN IMMEDIATE");current=read(db,doc_id,request) if current["version"]!=payload.version:raise HTTPException(409,"生成期间报告已修改,请重新生成") current.setdefault("proposals",[]).append({"section_id":payload.section_id,"text":generated["text"],"trace":generated,"time":risk._now()}) current["version"]+=1;save(db,current) risk.audit(db,request,"report_generate",{"id":doc_id,"section":payload.section_id}) return current @router.get("/documents/{doc_id}/export") def export(doc_id:str,request:Request): with risk._db() as db: storage(db);doc=read(db,doc_id,request) from docx import Document document=Document();document.add_heading(doc["title"],0) document.add_paragraph(f"状态:{'已定稿' if doc['status']=='final' else '草稿,待人工确认'};版本:{doc['version']};源研判:{doc['source_id']}") for section in doc["sections"]: document.add_heading(section["title"],1) for paragraph in section["text"].splitlines():document.add_paragraph(paragraph) document.add_paragraph("本文件基于示例模板,所有事实与判断须经授权人员复核。") buffer=BytesIO();document.save(buffer) return Response(buffer.getvalue(),media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",headers={"Content-Disposition":f'attachment; filename="report-{doc_id}.docx"'})