2026-09-08 02:49:06 +08:00
|
|
|
|
"""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
|
2026-09-11 15:56:28 +08:00
|
|
|
|
return [d for d in docs if "*" in user.get("projects",[]) or d["project_id"] in user.get("projects",[])]
|
2026-09-08 02:49:06 +08:00
|
|
|
|
|
|
|
|
|
|
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:
|
2026-09-15 10:31:50 +08:00
|
|
|
|
generated=risk.local_model({"instructions":"生成“"+section["title"]+"”的报告候选稿,引用数据卡证据,不作审批决定。","native_skills":["financial-leasing-review-report/SKILL.md"]},context,doc["source_evidence"],system_prompt=PROMPT+"\n你当前承担报告助手任务:按指定章节撰写专业中文底稿,不编造金额、日期、审批结果,不覆盖已确认内容。")
|
2026-09-08 02:49:06 +08:00
|
|
|
|
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"'})
|