2026-09-11 15:56:28 +08:00
"""Versioned project facade with durable jobs and resource-scoped operations."""
from copy import deepcopy
import json
from pathlib import Path
2026-09-15 10:31:50 +08:00
from datetime import datetime , timezone , timedelta
from services.assistant_store import transaction , initialize , get , put , listing , event , uid , now , digest , Conflict , time_event , time_listing , finish_time_event
2026-09-11 15:56:28 +08:00
from services import component_registry as registry
from services.assistant_materials import demo_material , demo_assets , material , apply_candidate , FIELDS
from services.assistant_data import load_catalog
from services.assistant_model import configuration
from services.assistant_settings import flags
from services.assistant_documents import DocumentsMixin
from services.assistant_experiences import ExperiencesMixin
from services.skill_executor import ratio , number , execute_skill
FIXTURE_PATH = Path ( __file__ ) . resolve () . parents [ 1 ] / "fixtures" / "xingbang_demo" / "projects.json"
DB_PATH = Path ( __file__ ) . resolve () . parents [ 1 ] / "xingbang_data" / "project_assistant.sqlite3"
FORMULA_VERSION = "lease-value-over-facility@1.0.0"
TERMINAL = { "completed" , "partial" , "failed" , "cancelled" }
2026-09-15 10:31:50 +08:00
REVIEW_TOPICS = (
{ "id" : "subject" , "title" : "主体信息" , "prefixes" : ( "project." , "customer." , "corporate." )},
{ "id" : "financial" , "title" : "财务与现金流" , "prefixes" : ( "financial." , "due_diligence." , "metrics." )},
{ "id" : "lease_asset" , "title" : "租赁物" , "prefixes" : ( "lease." , "asset." )},
{ "id" : "enhancement" , "title" : "增信措施" , "prefixes" : ( "guarantee." , "enhancement." )},
{ "id" : "credit_legal" , "title" : "征信与涉诉" , "prefixes" : ( "credit." , "legal." , "lawsuit." )},
{ "id" : "transaction" , "title" : "交易结构与还款" , "prefixes" : ( "facility." , "collection." , "repayment." )},
)
2026-09-11 15:56:28 +08:00
class ProjectAssistantService ( DocumentsMixin , ExperiencesMixin ):
def __init__ ( self , db_path = None , fixture_path = None ):
self . db_path = Path ( db_path or DB_PATH )
self . fixture_path = Path ( fixture_path or FIXTURE_PATH )
initialize ( self . db_path )
with self . connect () as db :
db . execute ( "BEGIN IMMEDIATE" )
fixtures = json . loads ( self . fixture_path . read_text ( encoding = "utf-8" ))
2026-09-15 10:31:50 +08:00
if not db . execute ( "SELECT 1 FROM assistant_meta WHERE key='schema_v2'" ) . fetchone ():
registry . seed ( db )
legacy = "projects" in { r [ 0 ] for r in db . execute ( "SELECT name FROM sqlite_master WHERE type='table'" )}
prior = { r [ 0 ]: json . loads ( r [ 1 ]) for r in db . execute ( "SELECT id,body FROM projects" )} if legacy else {}
for item in fixtures [ "projects" ]:
body = deepcopy ( item )
if item [ "id" ] in prior :
body [ "facts" ] . update ( prior [ item [ "id" ]][ "facts" ])
body [ "migration_note" ] = "沿用旧项目人工数据;旧版证据按未知来源归档,待重新核查"
self . _seed_project ( db , body , fixtures [ "data_domain" ])
if legacy :
for table in ( "runs" , "snapshots" , "findings" , "scenarios" , "reports" , "experiences" ):
for row in db . execute ( "SELECT id,body FROM " + table ):
old = json . loads ( row [ 1 ])
put ( db , "legacy_archive" , { "id" : table + ":" + row [ 0 ], "project_id" : old . get ( "project_id" , "" ), "kind" : table ,
"legacy_id" : row [ 0 ], "body" : old , "provenance" : "legacy_unknown" }, immutable = True )
event ( db , "" , "migration.v2" , "system" , { "projects" : list ( prior ), "legacy_tables" : "retained_read_only" })
db . execute ( "INSERT INTO assistant_meta VALUES ('schema_v2',?)" , ( now (),))
registry . seed ( db )
if not db . execute ( "SELECT 1 FROM assistant_meta WHERE key='business_flow_v1'" ) . fetchone ():
for item in fixtures [ "projects" ]:
if not db . execute ( "SELECT 1 FROM business_time_events WHERE project_id=?" , ( item [ "id" ],)) . fetchone ():
self . _seed_timeline ( db , item )
project = get ( db , "project" , item [ "id" ])
for key in ( "workflow_state" , "manager_name" , "reviewer_name" , "stage" ):
if key in item :
project [ key ] = item [ key ]
put ( db , "project" , project )
db . execute ( "INSERT INTO assistant_meta VALUES ('business_flow_v1',?)" , ( now (),))
if not db . execute ( "SELECT 1 FROM assistant_meta WHERE key='review_pool_v1'" ) . fetchone ():
for item in fixtures [ "projects" ]:
if item . get ( "reviewer_id" ):
project = get ( db , "project" , item [ "id" ])
if not project . get ( "reviewer_id" ) and project . get ( "workflow_state" ) == "under_review" :
project [ "reviewer_id" ] = item [ "reviewer_id" ]
put ( db , "project" , project )
db . execute ( "INSERT INTO assistant_meta VALUES ('review_pool_v1',?)" , ( now (),))
2026-09-11 15:56:28 +08:00
def connect ( self ):
return transaction ( self . db_path )
def _seed_project ( self , db , item , domain ):
2026-09-15 10:31:50 +08:00
project = { k : deepcopy ( v ) for k , v in item . items () if k not in ( "documents" , "timeline_demo" )}
2026-09-11 15:56:28 +08:00
project . update ( data_domain = domain , data_revision = 1 , period = "2025" , scope = "合并" , values = {}, sources = {}, selected_sources = {}, document_ids = [], pending_candidates = [])
document = demo_material ( project )
put ( db , "document" , document , immutable = True )
project [ "document_ids" ] . append ( document [ "id" ])
for candidate in document [ "candidates" ]:
apply_candidate ( project , candidate , "demo-fixture" , "演示源文件初始化" if not project . get ( "migration_note" ) else "旧数据迁移,待重新核查" )
assets = demo_assets ( project )
put ( db , "document" , assets , immutable = True )
project [ "document_ids" ] . append ( assets [ "id" ])
for candidate in assets [ "candidates" ]:
apply_candidate ( project , candidate , "demo-fixture" , "按设备唯一编号去重计算,重复项仍保留核查" )
put ( db , "project" , project )
2026-09-15 10:31:50 +08:00
def _seed_timeline ( self , db , item ):
anchor = datetime . now ( timezone . utc )
for index , phase in enumerate ( item . get ( "timeline_demo" , [])):
started = anchor - timedelta ( hours = float ( phase [ "started_hours_ago" ]))
duration = phase . get ( "duration_hours" )
ended = started + timedelta ( hours = float ( duration )) if duration is not None else None
time_event (
db , item [ "id" ], phase [ "phase_code" ], phase [ "phase_name" ], phase [ "role" ], phase [ "category" ],
"demo-fixture" , "fixture" , f " { item [ 'id' ] } : { index } " , status = "completed" if ended else "running" ,
started_at = started . isoformat (), ended_at = ended . isoformat () if ended else None ,
detail = { "demo" : True }, event_id = f "time- { item [ 'id' ] . lower () } - { index + 1 } " ,
)
@staticmethod
def _hours ( started_at , ended_at = None ):
start = datetime . fromisoformat ( started_at )
end = datetime . fromisoformat ( ended_at ) if ended_at else datetime . now ( timezone . utc )
return round ( max ( 0 , ( end - start ) . total_seconds () / 3600 ), 2 )
def project_timeline ( self , project_id ):
with self . connect () as db :
events = time_listing ( db , [ project_id ])
for item in events :
item [ "duration_hours" ] = self . _hours ( item [ "started_at" ], item [ "ended_at" ])
return events
def time_dashboard ( self , project_ids ):
with self . connect () as db :
events = time_listing ( db , project_ids )
projects = { p [ "id" ]: p for p in listing ( db , "project" ) if p [ "id" ] in project_ids }
phase_totals , category_totals , project_totals = {}, {}, {}
enriched = []
for item in events :
duration = self . _hours ( item [ "started_at" ], item [ "ended_at" ])
current = dict ( item , duration_hours = duration )
enriched . append ( current )
phase_totals . setdefault ( item [ "phase_code" ], { "code" : item [ "phase_code" ], "name" : item [ "phase_name" ], "hours" : 0 , "count" : 0 })
phase_totals [ item [ "phase_code" ]][ "hours" ] += duration
phase_totals [ item [ "phase_code" ]][ "count" ] += 1
category_totals [ item [ "category" ]] = round ( category_totals . get ( item [ "category" ], 0 ) + duration , 2 )
project_totals [ item [ "project_id" ]] = round ( project_totals . get ( item [ "project_id" ], 0 ) + duration , 2 )
phases = [ dict ( value , hours = round ( value [ "hours" ], 2 ), average_hours = round ( value [ "hours" ] / value [ "count" ], 2 )) for value in phase_totals . values ()]
project_rows = [ dict ( project_id = key , customer = projects . get ( key , {}) . get ( "customer" , key ), industry = projects . get ( key , {}) . get ( "industry" , "" ),
stage = projects . get ( key , {}) . get ( "stage" , "" ), total_hours = value ,
events = [ e for e in enriched if e [ "project_id" ] == key ]) for key , value in project_totals . items ()]
totals = sorted ( project_totals . values ())
median = totals [ len ( totals ) // 2 ] if totals else 0
return { "projects" : project_rows , "phases" : phases , "categories" : category_totals ,
"summary" : { "project_count" : len ( project_rows ), "median_hours" : median ,
"active_hours" : category_totals . get ( "active" , 0 ), "wait_hours" : category_totals . get ( "external_wait" , 0 ) + category_totals . get ( "queue" , 0 ),
"system_hours" : category_totals . get ( "system" , 0 )}}
2026-09-11 15:56:28 +08:00
def resource ( self , kind , object_id , project_id = None ):
with self . connect () as db :
body = get ( db , kind , object_id )
if project_id is not None and body . get ( "project_id" ) != project_id :
raise KeyError ( "资源不属于该项目" )
return body
@staticmethod
def public_run ( run ):
return { k : v for k , v in run . items () if k not in ( "identity" , "components" , "model_config" , "experience_versions" )}
def list_projects ( self ):
with self . connect () as db :
projects = listing ( db , "project" )
for project in projects :
2026-09-15 10:31:50 +08:00
timeline = time_listing ( db , [ project [ "id" ]])
queue = next (( item for item in reversed ( timeline ) if item [ "phase_code" ] == "review_queue" ), None )
review = next (( item for item in reversed ( timeline ) if item [ "phase_code" ] == "independent_review" ), None )
submitted_at = project . get ( "submitted_at" ) or ( queue and queue [ "started_at" ])
project [ "submitted_at" ] = submitted_at
project [ "review_started_at" ] = project . get ( "review_started_at" ) or ( review and review [ "started_at" ])
project [ "review_wait_hours" ] = self . _hours ( submitted_at , project [ "review_started_at" ]) if submitted_at else None
2026-09-15 11:56:32 +08:00
decisions = listing ( db , "review_decision" , project [ "id" ])
latest_return = next (( item for item in decisions if item . get ( "decision" ) == "退回" and not item . get ( "finding_id" ) and not item . get ( "node_id" )), None )
project [ "return_reason" ] = ( latest_return or {}) . get ( "opinion" , "" )
project [ "returned_at" ] = project . get ( "review_decided_at" ) or ( latest_return or {}) . get ( "updated_at" )
project [ "review_state" ] = "已退回" if project . get ( "workflow_state" ) == "returned" else "评审中" if project . get ( "reviewer_id" ) else "待领取" if submitted_at else "尚未送审"
2026-09-11 15:56:28 +08:00
runs = listing ( db , "run" , project [ "id" ])
project [ "latest_run" ] = self . public_run ( runs [ 0 ]) if runs else None
project [ "open_findings" ] = sum ( f [ "status" ] not in ( "已解决" , "不适用" ) for f in listing ( db , "finding" , project [ "id" ]))
2026-09-15 10:31:50 +08:00
reports = listing ( db , "report" , project [ "id" ])
2026-09-15 11:56:32 +08:00
if project . get ( "workflow_state" ) == "returned" :
project [ "next_action" ] = "查看退回要求并补充材料"
elif not runs :
2026-09-15 10:31:50 +08:00
project [ "next_action" ] = "发起AI预审"
elif runs [ 0 ][ "status" ] in ( "queued" , "running" ):
project [ "next_action" ] = "查看预审进度"
elif project [ "open_findings" ]:
project [ "next_action" ] = f "处理 { project [ 'open_findings' ] } 项风险"
elif not reports :
project [ "next_action" ] = "生成预审报告"
elif project . get ( "workflow_state" ) != "submitted" :
project [ "next_action" ] = "提交正式评审"
else :
project [ "next_action" ] = "查看评审进度"
2026-09-11 15:56:28 +08:00
return sorted ( projects , key = lambda p : p [ "id" ])
2026-09-15 10:31:50 +08:00
@staticmethod
def _topic_for_field ( field ):
return next (( topic [ "id" ] for topic in REVIEW_TOPICS if any ( field . startswith ( prefix ) for prefix in topic [ "prefixes" ])), "subject" )
@classmethod
def _topic_for_check ( cls , check ):
code = check . get ( "code" , "" ) . lower ()
if any ( word in code for word in ( "asset" , "lease" , "coverage" )):
return "lease_asset"
if any ( word in code for word in ( "financial" , "cashflow" , "liabilit" , "leverage" )):
return "financial"
if any ( word in code for word in ( "credit" , "legal" , "lawsuit" )):
return "credit_legal"
if any ( word in code for word in ( "guarantee" , "enhancement" )):
return "enhancement"
if any ( word in code for word in ( "facility" , "collection" , "repayment" )):
return "transaction"
return cls . _topic_for_field ( next ( iter ( check . get ( "fields" , [])), "" ))
def _review_topics ( self , db , project_id , runs = None , decisions = None ):
runs = runs if runs is not None else listing ( db , "run" , project_id )
decisions = decisions if decisions is not None else listing ( db , "review_decision" , project_id )
run = next (( item for item in runs if item . get ( "run_kind" ) == "independent_review" and item . get ( "status" ) in ( "completed" , "partial" )), None )
checks = run . get ( "checks" , []) if run else []
evidence = run . get ( "evidence" , []) if run else []
result = []
for definition in REVIEW_TOPICS :
topic_checks = [ item for item in checks if self . _topic_for_check ( item ) == definition [ "id" ]]
evidence_ids = { key for item in topic_checks for key in item . get ( "evidence_ids" , [])}
topic_sources = [ item for item in evidence if item . get ( "id" ) in evidence_ids or self . _topic_for_field ( item . get ( "field" , "" )) == definition [ "id" ]]
seen = set ()
topic_sources = [ item for item in topic_sources if not ( item . get ( "id" ) in seen or seen . add ( item . get ( "id" )))]
basis_ids = sorted ({ key for item in topic_checks for key in item . get ( "basis_ids" , [])})
states = { item . get ( "result" ) for item in topic_checks }
ai_status = "not_run" if not run else "issue" if states & { "issue" , "error" } else "missing" if "unknown" in states or not topic_sources else "pass"
current_decision = next (( item for item in decisions if item . get ( "node_id" ) == definition [ "id" ] and item . get ( "run_id" ) == ( run or {}) . get ( "id" )), None )
result . append ({
"id" : definition [ "id" ], "title" : definition [ "title" ], "ai_status" : ai_status ,
"checks" : topic_checks , "sources" : topic_sources , "evidence_ids" : [ item . get ( "id" ) for item in topic_sources ],
"basis_ids" : basis_ids , "run_id" : ( run or {}) . get ( "id" ), "snapshot_id" : ( run or {}) . get ( "snapshot_id" ),
"decision" : current_decision ,
})
return result
def review_pool ( self , actor , role ):
with self . connect () as db :
rows = []
for project in listing ( db , "project" ):
submissions = listing ( db , "submission" , project [ "id" ])
if role == "manager" :
submissions = [ item for item in submissions if item . get ( "submitted_by" ) == actor ]
if not submissions :
continue
submission = submissions [ 0 ]
if role == "reviewer" and ( submission . get ( "status" ) != "submitted" or project . get ( "reviewer_id" ) not in ( None , actor )):
continue
ended_at = project . get ( "review_claimed_at" ) if submission . get ( "status" ) == "submitted" else submission . get ( "ended_at" )
rows . append ({
"project_id" : project [ "id" ], "customer" : project [ "customer" ], "industry" : project [ "industry" ],
"stage" : project . get ( "stage" , "" ), "submission_id" : submission [ "id" ], "submission_version" : submission . get ( "sequence" , 1 ),
"submitted_at" : submission [ "submitted_at" ], "wait_hours" : self . _hours ( submission [ "submitted_at" ], ended_at ),
"status" : submission . get ( "status" , "submitted" ), "reviewer_id" : project . get ( "reviewer_id" ),
"review_claimed_at" : project . get ( "review_claimed_at" ), "report_id" : submission [ "report_id" ],
})
return sorted ( rows , key = lambda item : item [ "wait_hours" ], reverse = True )
def withdraw_review ( self , project_id , actor ):
with self . connect () as db :
db . execute ( "BEGIN IMMEDIATE" )
project = get ( db , "project" , project_id )
submission = next (( item for item in listing ( db , "submission" , project_id ) if item . get ( "status" ) == "submitted" ), None )
if not submission :
raise Conflict ( "当前没有可撤回的送审版本" )
if submission . get ( "submitted_by" ) != actor :
raise PermissionError ( "只能撤回本人提交的项目" )
ended_at = now ()
previous_reviewer = project . get ( "reviewer_id" )
submission . update ( status = "withdrawn" , withdrawn_at = ended_at , withdrawn_by = actor , ended_at = ended_at )
put ( db , "submission" , submission )
for assignment in listing ( db , "review_assignment" , project_id ):
if assignment . get ( "status" ) == "active" :
assignment . update ( status = "withdrawn" , ended_at = ended_at )
put ( db , "review_assignment" , assignment )
finish_time_event ( db , "review_assignment" , assignment [ "id" ], "cancelled" , ended_at )
finish_time_event ( db , "submission" , submission [ "id" ], "cancelled" , ended_at )
project . update ( workflow_state = "withdrawn" , stage = "已撤回" , reviewer_id = None , review_claimed_at = None , review_started_at = None )
put ( db , "project" , project )
event ( db , project_id , "review.withdrawn" , actor , { "submission_id" : submission [ "id" ], "previous_reviewer" : previous_reviewer })
return { "project_id" : project_id , "submission_id" : submission [ "id" ], "status" : "withdrawn" , "withdrawn_at" : ended_at }
def resubmit_review ( self , project_id , actor ):
with self . connect () as db :
db . execute ( "BEGIN IMMEDIATE" )
project = get ( db , "project" , project_id )
if any ( item . get ( "status" ) == "submitted" for item in listing ( db , "submission" , project_id )):
raise Conflict ( "项目已在评审池中,无需重复推送" )
prior = next (( item for item in listing ( db , "submission" , project_id ) if item . get ( "submitted_by" ) == actor ), None )
if not prior :
raise Conflict ( "未找到本人可重新推送的历史版本" )
report = get ( db , "report" , prior [ "report_id" ])
if report . get ( "data_revision" ) != project . get ( "data_revision" ) or any ( section . get ( "stale" ) for section in report . get ( "sections" , [])):
raise Conflict ( "项目数据或报告已变化,请先重新预审并生成报告" )
sequence = 1 + max (( item . get ( "sequence" , 1 ) for item in listing ( db , "submission" , project_id )), default = 0 )
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" : sequence ,
"content_hash" : digest ( report ), "replaces_submission_id" : prior [ "id" ]}
put ( db , "submission" , submission , immutable = True )
report . update ( status = "submitted" , submission_id = submission [ "id" ], submitted_by = actor )
put ( db , "report" , report )
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 )
time_event ( db , project_id , "review_queue" , "评审排队" , "reviewer" , "queue" , actor , "submission" , submission [ "id" ])
event ( db , project_id , "review.resubmitted" , actor , { "submission_id" : submission [ "id" ], "replaces" : prior [ "id" ]})
return submission
def claim_review ( self , project_id , actor ):
with self . connect () as db :
db . execute ( "BEGIN IMMEDIATE" )
project = get ( db , "project" , project_id )
if project . get ( "workflow_state" ) not in ( "submitted" , "under_review" ):
raise Conflict ( "项目尚未由客户经理提交到评审池" )
owner = project . get ( "reviewer_id" )
if owner and owner != actor :
raise Conflict ( "该项目已被其他评审员领取" )
submissions = [ item for item in listing ( db , "submission" , project_id ) if item . get ( "status" ) == "submitted" ]
if not submissions :
raise Conflict ( "未找到客户经理的正式提交记录" )
submission = submissions [ 0 ]
if owner == actor :
return { "project_id" : project_id , "reviewer_id" : actor , "claimed_at" : project . get ( "review_claimed_at" ),
"submitted_at" : submission [ "submitted_at" ], "wait_hours" : self . _hours ( submission [ "submitted_at" ], project . get ( "review_claimed_at" ))}
claimed_at = now ()
assignment = put ( db , "review_assignment" , { "id" : "assignment-" + submission [ "id" ], "project_id" : project_id ,
"submission_id" : submission [ "id" ], "reviewer_id" : actor , "submitted_at" : submission [ "submitted_at" ],
"claimed_at" : claimed_at , "status" : "active" }, immutable = True )
project . update ( workflow_state = "under_review" , stage = "评审办理" , reviewer_id = actor ,
review_claimed_at = claimed_at , review_started_at = claimed_at )
put ( db , "project" , project )
finish_time_event ( db , "submission" , submission [ "id" ])
time_event ( db , project_id , "review_handling" , "评审办理" , "reviewer" , "active" , actor ,
"review_assignment" , assignment [ "id" ], detail = { "submitted_at" : submission [ "submitted_at" ]})
event ( db , project_id , "review.claimed" , actor , { "submission_id" : submission [ "id" ], "claimed_at" : claimed_at })
return dict ( assignment , wait_hours = self . _hours ( submission [ "submitted_at" ], claimed_at ))
2026-09-11 15:56:28 +08:00
def get_project ( self , project_id ):
with self . connect () as db :
project = get ( db , "project" , project_id )
2026-09-15 10:31:50 +08:00
for plural , kind in (( "runs" , "run" ), ( "findings" , "finding" ), ( "scenarios" , "scenario" ), ( "reports" , "report" ), ( "experiences" , "experience" ), ( "feedback" , "feedback" ), ( "experience_applications" , "experience_application" ), ( "review_decisions" , "review_decision" )):
2026-09-11 15:56:28 +08:00
project [ plural ] = listing ( db , kind , project_id )
project [ "runs" ] = [ self . public_run ( r ) for r in project [ "runs" ]]
project [ "documents" ] = [{ k : v for k , v in get ( db , "document" , key ) . items () if k not in ( "raw_base64" , "chunks" )} for key in project [ "document_ids" ]]
project [ "legacy_records" ] = [{ "id" : x [ "id" ], "kind" : x [ "kind" ], "provenance" : x [ "provenance" ]} for x in listing ( db , "legacy_archive" , project_id )]
project [ "events" ] = [{ "seq" : r [ 0 ], ** json . loads ( r [ 1 ])} for r in db . execute ( "SELECT seq,body FROM assistant_events WHERE project_id=? ORDER BY seq DESC LIMIT 100" , ( project_id ,))]
2026-09-15 10:31:50 +08:00
project [ "plans" ] = [{ "id" : v [ "component_id" ], "name" : v [ "name" ], "version" : v [ "version" ],
"audience" : v . get ( "audience" , []), "run_kind" : v . get ( "run_kind" , "pre_review" ), "template" : v . get ( "template" )}
for v in listing ( db , "component_version" ) if v [ "kind" ] == "plan" and v [ "scope" ] == project [ "data_domain" ] and get ( db , "release" , v [ "component_id" ])[ "version_id" ] == v [ "id" ]]
project [ "timeline" ] = time_listing ( db , [ project_id ])
for item in project [ "timeline" ]:
item [ "duration_hours" ] = self . _hours ( item [ "started_at" ], item [ "ended_at" ])
project [ "review_topics" ] = self . _review_topics ( db , project_id , project [ "runs" ], project [ "review_decisions" ])
2026-09-11 15:56:28 +08:00
return project
def enqueue ( self , project_id , identity , requirement = "" , plan_id = "corporate.default" , idempotency_key = "" , expected = None , feedback_id = None ):
catalog , model = load_catalog (), configuration ()
request_hash = digest ({ "project" : project_id , "requirement" : requirement , "plan" : plan_id , "expected" : expected , "feedback_id" : feedback_id })
with self . connect () as db :
db . execute ( "BEGIN IMMEDIATE" )
if idempotency_key :
prior = db . execute ( "SELECT request_hash,object_id FROM assistant_idempotency WHERE actor=? AND operation=? AND key=?" , ( identity [ "id" ], "run" , idempotency_key )) . fetchone ()
if prior :
if prior [ 0 ] != request_hash :
raise Conflict ( "幂等键已用于不同请求" )
return self . public_run ( get ( db , "run" , prior [ 1 ]))
project = get ( db , "project" , project_id )
feedback = get ( db , "feedback" , feedback_id ) if feedback_id else None
if feedback and ( feedback [ "project_id" ] != project_id or feedback [ "category" ] in ( "expression" , "factual" , "data" )):
raise ValueError ( "此反馈不能用于本项目的核查经验提炼" )
if expected is not None and project [ "revision" ] != expected :
raise Conflict ( "项目已更新,请刷新后重新检查" )
components = registry . freeze ( db , plan_id , catalog )
if components [ "plan" ][ "scope" ] != project [ "data_domain" ]:
raise ValueError ( "方案与项目数据域不一致" )
2026-09-15 10:31:50 +08:00
audience = components [ "plan" ] . get ( "audience" , [])
if identity . get ( "role" ) != "admin" and audience and identity . get ( "role" ) not in audience :
raise PermissionError ( "当前身份不能使用该业务方案" )
if components [ "plan" ] . get ( "run_kind" ) == "independent_review" and project . get ( "reviewer_id" ) != identity . get ( "id" ):
raise PermissionError ( "请先从待评审项目池领取该项目" )
2026-09-11 15:56:28 +08:00
documents = [{ k : v for k , v in get ( db , "document" , key ) . items () if k != "raw_base64" } for key in project [ "document_ids" ]]
for document in documents :
for index , chunk in enumerate ( document [ "chunks" ]):
chunk . setdefault ( "evidence_id" , "evdoc-" + digest ([ document [ "id" ], document [ "content_hash" ], index , chunk [ "locator" ]])[: 32 ])
snapshot = dict ( deepcopy ( project ), id = uid ( "input" ), project_id = project_id , project_revision = project [ "revision" ], documents = documents , created_at = now ())
put ( db , "input_snapshot" , snapshot , immutable = True )
run = { "id" : uid ( "run" ), "project_id" : project_id , "actor_id" : identity [ "id" ], "identity" : identity , "status" : "queued" ,
"created_at" : now (), "requirement" : requirement , "input_snapshot_id" : snapshot [ "id" ], "snapshot_id" : None ,
2026-09-15 10:31:50 +08:00
"run_kind" : components [ "plan" ] . get ( "run_kind" , "pre_review" ), "plan_id" : plan_id ,
2026-09-11 15:56:28 +08:00
"components" : components , "model_config" : model , "steps" : [], "summary" : {}, "evidence" : [],
"feature_flags" : flags (),
"feedback_context" : feedback ,
"component_versions" : [{ "id" : s [ "id" ], "version" : s [ "version" ], "content_hash" : s [ "content_hash" ]} for s in [ components [ "plan" ]] + components [ "skills" ] + components [ "rules" ] + [ components [ "template" ]]]}
run = put ( db , "run" , run )
db . execute ( "INSERT INTO assistant_jobs(id,project_id,state) VALUES (?,?, 'queued')" , ( run [ "id" ], project_id ))
2026-09-15 10:31:50 +08:00
phase_code = "independent_review" if run [ "run_kind" ] == "independent_review" else "pre_review"
if phase_code == "independent_review" :
project . update ( workflow_state = "under_review" , stage = "独立评审" )
put ( db , "project" , project )
time_event ( db , project_id , phase_code , "独立评审" if phase_code == "independent_review" else "AI预审" ,
"reviewer" if phase_code == "independent_review" else "system" , "active" if phase_code == "independent_review" else "system" ,
identity [ "id" ], "run" , run [ "id" ], detail = { "plan_id" : plan_id })
2026-09-11 15:56:28 +08:00
if idempotency_key :
db . execute ( "INSERT INTO assistant_idempotency VALUES (?,?,?,?,?)" , ( identity [ "id" ], "run" , idempotency_key , request_hash , run [ "id" ]))
event ( db , project_id , "run.queued" , identity [ "id" ], { "input_snapshot_id" : snapshot [ "id" ], "component_hash" : components [ "content_hash" ], "requirement" : requirement }, run [ "id" ])
return self . public_run ( run )
def cancel ( self , project_id , run_id , actor ):
with self . connect () as db :
db . execute ( "BEGIN IMMEDIATE" )
run = get ( db , "run" , run_id )
if run [ "project_id" ] != project_id :
raise KeyError ( "运行不存在" )
if run [ "status" ] in TERMINAL :
raise Conflict ( "运行已结束" )
run . update ( status = "cancelled" , ended_at = now ())
put ( db , "run" , run )
db . execute ( "UPDATE assistant_jobs SET state='cancelled',owner=NULL,lease_until=0 WHERE id=?" , ( run_id ,))
2026-09-15 10:31:50 +08:00
finish_time_event ( db , "run" , run_id , "cancelled" )
2026-09-11 15:56:28 +08:00
event ( db , project_id , "run.cancelled" , actor , run_id = run_id )
return self . public_run ( run )
def upload ( self , project_id , filename , content , actor ):
document = material ( project_id , filename , content , actor )
with self . connect () as db :
db . execute ( "BEGIN IMMEDIATE" )
project = get ( db , "project" , project_id )
prior = listing ( db , "document" , project_id )
duplicate = next (( d for d in prior if d [ "content_hash" ] == document [ "content_hash" ]), None )
if duplicate :
return { k : v for k , v in duplicate . items () if k != "raw_base64" }
document [ "version" ] = 1 + max (( d [ "version" ] for d in prior if d [ "name" ] == document [ "name" ]), default = 0 )
put ( db , "document" , document , immutable = True )
project [ "document_ids" ] . append ( document [ "id" ])
project . setdefault ( "pending_candidates" , []) . extend ( c [ "id" ] for c in document [ "candidates" ])
project [ "data_revision" ] += 1
put ( db , "project" , project )
self . mark_stale ( db , project_id , [ "*" ])
event ( db , project_id , "document.uploaded" , actor , { "document_id" : document [ "id" ], "hash" : document [ "content_hash" ]})
return { k : v for k , v in document . items () if k != "raw_base64" }
def supplement ( self , project_id , actor , changes , finding_ids , expected , reason , candidate_ids = None ):
if not reason . strip () or ( not changes and not candidate_ids ):
raise ValueError ( "请填写变更字段或材料候选以及确认依据" )
if set ( changes ) - ( set ( FIELDS ) - { "facility_amount" , "lease_asset_value" }):
raise ValueError ( "补件字段不受支持;融资金额应使用情景采纳" )
with self . connect () as db :
db . execute ( "BEGIN IMMEDIATE" )
project = get ( db , "project" , project_id )
if project [ "revision" ] != expected :
raise Conflict ( "项目数据已更新,请刷新" )
findings = { f [ "id" ]: f for f in listing ( db , "finding" , project_id )}
if set ( finding_ids ) - set ( findings ):
raise ValueError ( "关联问题不属于该项目" )
candidates = [ c for d in listing ( db , "document" , project_id ) for c in d [ "candidates" ] if c [ "id" ] in ( candidate_ids or [])]
if len ( candidates ) != len ( set ( candidate_ids or [])):
raise ValueError ( "材料候选不存在或不属于项目" )
for field , value in changes . items ():
standard = FIELDS [ field ]
source = next (( r for r in project [ "sources" ] . get ( standard , []) if r [ "id" ] == project [ "selected_sources" ] . get ( standard )), {})
candidates . append ({ "field" : standard , "value" : value , "source" : "人工补充" , "locator" : "补件说明:" + reason ,
** { k : source . get ( k ) for k in ( "period" , "scope" , "unit" , "currency" )}})
if len ({ c [ "field" ] for c in candidates }) != len ( candidates ):
raise ValueError ( "同一字段有多个候选,请明确唯一来源" )
before = deepcopy ( project [ "values" ])
for candidate in candidates :
apply_candidate ( project , candidate , actor , reason )
if candidate . get ( "id" ) in project . get ( "pending_candidates" , []):
project [ "pending_candidates" ] . remove ( candidate [ "id" ])
project [ "data_revision" ] += 1
project = put ( db , "project" , project , expected = expected )
changed = [ k for k , v in project [ "values" ] . items () if before . get ( k ) != v ]
self . mark_stale ( db , project_id , changed or [ "*" ])
event ( db , project_id , "supplement.accepted" , actor , { "fields" : changed , "finding_ids" : finding_ids , "reason" : reason , "data_revision" : project [ "data_revision" ]})
return { "project_id" : project_id , "revision" : project [ "revision" ], "changed_fields" : changed , "status" : "待重新检查" }
def finding_action ( self , project_id , finding_id , actor , status , note , expected ):
if status not in ( "待处理" , "待补件" , "待复核" , "不适用" ) or not note . strip ():
raise ValueError ( "需选择有效状态并填写说明;已解决仅由有依据的检查确认" )
with self . connect () as db :
db . execute ( "BEGIN IMMEDIATE" )
finding = get ( db , "finding" , finding_id )
if finding [ "project_id" ] != project_id :
raise KeyError ( "问题不存在" )
finding [ "history" ] . append ({ "time" : now (), "actor" : actor , "from" : finding [ "status" ], "to" : status , "note" : note , "type" : "human" })
finding . update ( status = status , human_status = status , human_note = note )
result = put ( db , "finding" , finding , expected = expected )
self . mark_stale ( db , project_id , [ "*" ])
event ( db , project_id , "finding.action" , actor , { "finding_id" : finding_id , "status" : status , "note" : note })
return result
2026-09-15 10:31:50 +08:00
def review_decision ( self , project_id , actor , decision , opinion , finding_id = None , node_id = None ):
allowed = ( "确认无异常" , "确认风险可控" , "认可AI判断" , "要求补件" , "转现场核查" , "不采纳AI判断" , "不适用" , "批准" , "退回" )
if decision not in allowed :
raise ValueError ( "请选择有效评审结论" )
if decision in ( "确认风险可控" , "要求补件" , "转现场核查" , "不采纳AI判断" , "不适用" , "退回" ) and not opinion . strip ():
raise ValueError ( "当前结论必须选择或填写评审意见" )
with self . connect () as db :
db . execute ( "BEGIN IMMEDIATE" )
project = get ( db , "project" , project_id )
if project . get ( "reviewer_id" ) != actor :
raise PermissionError ( "请先从待评审项目池领取该项目" )
if finding_id :
finding = get ( db , "finding" , finding_id )
if finding [ "project_id" ] != project_id :
raise KeyError ( "问题不存在" )
topics = self . _review_topics ( db , project_id )
topic = next (( item for item in topics if item [ "id" ] == node_id ), None ) if node_id else None
if node_id and not topic :
raise ValueError ( "必审事项不存在" )
if finding_id :
run = next (( item for item in listing ( db , "run" , project_id ) if item [ "id" ] == finding . get ( "last_seen_run_id" )), None )
evidence_ids , basis_ids = finding . get ( "evidence_ids" , []), finding . get ( "basis_ids" , [])
trace_sources = [ item for item in ( run or {}) . get ( "evidence" , []) if item . get ( "id" ) in evidence_ids ]
elif topic :
run = next (( item for item in listing ( db , "run" , project_id ) if item [ "id" ] == topic . get ( "run_id" )), None )
evidence_ids , basis_ids , trace_sources = topic [ "evidence_ids" ], topic [ "basis_ids" ], topic [ "sources" ]
else :
run , evidence_ids , basis_ids , trace_sources = None , [], [], []
if node_id and not run :
raise Conflict ( "请先完成独立评审检查,再确认必审事项" )
if not finding_id and not node_id and decision == "批准" :
current = { item [ "id" ]: item . get ( "decision" ) for item in topics }
missing = [ item [ "title" ] for item in topics if not current [ item [ "id" ]]]
blocking = [ item [ "title" ] for item in topics if current [ item [ "id" ]] and current [ item [ "id" ]] . get ( "decision" ) in ( "要求补件" , "转现场核查" , "认可AI判断" )]
if missing :
raise Conflict ( "请先确认全部必审事项:" + "、" . join ( missing ))
if blocking :
raise Conflict ( "以下事项尚未闭环,不能批准:" + "、" . join ( blocking ))
body = { "id" : uid ( "review" ), "project_id" : project_id , "finding_id" : finding_id , "node_id" : node_id ,
"node_title" : topic . get ( "title" ) if topic else None , "decision" : decision , "opinion" : opinion . strip (),
"actor_id" : actor , "updated_at" : now (), "run_id" : ( run or {}) . get ( "id" ),
"source_snapshot_id" : ( run or {}) . get ( "snapshot_id" ), "evidence_ids" : evidence_ids ,
"basis_ids" : basis_ids , "trace_sources" : deepcopy ( trace_sources ),
"decision_refs" : [ item . get ( "decision" , {}) . get ( "id" ) for item in topics if item . get ( "decision" )] if not node_id and not finding_id else []}
result = put ( db , "review_decision" , body , immutable = True )
if not finding_id and decision in ( "批准" , "退回" ):
project . update ( workflow_state = "review_approved" if decision == "批准" else "returned" ,
stage = "评审通过" if decision == "批准" else "退回补件" , review_decided_at = body [ "updated_at" ])
put ( db , "project" , project )
finish_time_event ( db , "run" , next (( r [ "id" ] for r in listing ( db , "run" , project_id ) if r . get ( "run_kind" ) == "independent_review" ), "" ))
assignment = next (( item for item in listing ( db , "review_assignment" , project_id ) if item . get ( "status" ) == "active" and item . get ( "reviewer_id" ) == actor ), None )
if assignment :
assignment . update ( status = "completed" , decided_at = body [ "updated_at" ], decision = decision )
put ( db , "review_assignment" , assignment )
finish_time_event ( db , "review_assignment" , assignment [ "id" ])
submission = next (( item for item in listing ( db , "submission" , project_id ) if item . get ( "status" ) == "submitted" ), None )
if submission :
submission . update ( status = "approved" if decision == "批准" else "returned" , ended_at = body [ "updated_at" ], decision_id = result [ "id" ])
put ( db , "submission" , submission )
event ( db , project_id , "review.decision" , actor , { "finding_id" : finding_id , "node_id" : node_id , "decision" : decision ,
"opinion" : opinion , "snapshot_id" : body [ "source_snapshot_id" ], "evidence_ids" : evidence_ids })
return result
2026-09-11 15:56:28 +08:00
def compare_scenario ( self , project_id , amount , actor ):
with self . connect () as db :
project = get ( db , "project" , project_id )
source = project [ "values" ]
selected = { f : [ r for r in refs if r [ "id" ] == project [ "selected_sources" ] . get ( f )] for f , refs in project [ "sources" ] . items ()}
skill = { "id" : "scenario.coverage" , "version" : "1.0.0" , "engine" : "ratio" , "numerator" : "lease.total_value" , "denominator" : "facility.amount" , "output" : "metrics.coverage" }
baseline = execute_skill ( skill , source , selected )[ 0 ][ "metrics.coverage" ]
proposed = execute_skill ( skill , dict ( source , ** { "facility.amount" : amount }), selected )[ 0 ][ "metrics.coverage" ]
scenario = { "id" : uid ( "scenario" ), "project_id" : project_id , "status" : "comparison" , "actor_id" : actor , "created_at" : now (),
"project_revision" : project [ "revision" ], "data_revision" : project [ "data_revision" ], "formula_version" : FORMULA_VERSION ,
"sources" : deepcopy ( project [ "sources" ]), "baseline" : { "facility_amount" : source [ "facility.amount" ], "coverage" : baseline },
"scenario" : { "facility_amount" : str ( number ( amount )), "coverage" : proposed }, "delta" : str ( number ( proposed ) - number ( baseline )),
"unresolved" : [ f [ "id" ] for f in listing ( db , "finding" , project_id ) if f [ "status" ] not in ( "已解决" , "不适用" ) and f [ "code" ] != "lease.coverage.below_demo_threshold" ],
"assumptions" : [ "租赁物价值与评估时点不变" , "金额单位万元,币种人民币" , "120%仅为演示阈值,非正式制度" ], "data_domain" : project [ "data_domain" ]}
result = put ( db , "scenario" , scenario , immutable = True )
event ( db , project_id , "scenario.compared" , actor , { "scenario_id" : result [ "id" ]})
return result
def adopt_scenario ( self , project_id , scenario_id , actor , expected , reason ):
if not reason . strip ():
raise ValueError ( "请填写采纳理由" )
with self . connect () as db :
db . execute ( "BEGIN IMMEDIATE" )
scenario = get ( db , "scenario" , scenario_id )
project = get ( db , "project" , project_id )
if scenario [ "project_id" ] != project_id :
raise KeyError ( "情景不存在" )
if scenario [ "project_revision" ] != project [ "revision" ] or expected != project [ "revision" ]:
raise Conflict ( "情景基线已过期,请重新比较" )
selected = next ( r for r in project [ "sources" ][ "facility.amount" ] if r [ "id" ] == project [ "selected_sources" ][ "facility.amount" ])
candidate = { ** selected , "field" : "facility.amount" , "value" : scenario [ "scenario" ][ "facility_amount" ], "source" : "采纳融资情景" , "locator" : scenario_id }
apply_candidate ( project , candidate , actor , reason )
project [ "data_revision" ] += 1
result = put ( db , "project" , project , expected = expected )
self . mark_stale ( db , project_id , [ "facility.amount" , "metrics.coverage" ])
event ( db , project_id , "scenario.adopted" , actor , { "scenario_id" : scenario_id , "reason" : reason })
return { "project_id" : project_id , "revision" : result [ "revision" ], "status" : "待重新检查" }
def reset_demo ( self , actor , confirmation ):
if confirmation != "RESET-DEMO-A-B-C" :
raise ValueError ( "请输入 RESET-DEMO-A-B-C 确认仅重置三个演示实例" )
fixtures = json . loads ( self . fixture_path . read_text ( encoding = "utf-8" ))
with self . connect () as db :
db . execute ( "BEGIN IMMEDIATE" )
for item in fixtures [ "projects" ]:
current = get ( db , "project" , item [ "id" ])
if current [ "data_domain" ] != "demo" :
raise ValueError ( "禁止重置正式项目" )
records = [ dict ( r ) for r in db . execute ( "SELECT * FROM assistant_objects WHERE project_id=? OR (kind='project' AND id=?)" , ( item [ "id" ], item [ "id" ]))]
put ( db , "reset_archive" , { "id" : uid ( "reset" ), "source_project_id" : item [ "id" ], "records" : records , "actor" : actor , "created_at" : now ()}, immutable = True )
db . execute ( "DELETE FROM assistant_jobs WHERE project_id=?" , ( item [ "id" ],))
db . execute ( "DELETE FROM assistant_idempotency WHERE object_id IN (SELECT id FROM assistant_objects WHERE project_id=?)" , ( item [ "id" ],))
db . execute ( "DELETE FROM assistant_objects WHERE project_id=? OR (kind='project' AND id=?)" , ( item [ "id" ], item [ "id" ]))
self . _seed_project ( db , item , "demo" )
event ( db , item [ "id" ], "demo.reset" , actor , { "recoverable" : True })
return { "status" : "reset" , "data_domain" : "demo" , "projects" : len ( fixtures [ "projects" ]), "recoverable" : True }