"""Observable trajectory details and immutable run comparisons (AC-77).""" from services import assistant_store as store def step_details(run): components=run.get('components') or {} methods={c['id']:c for c in components.get('skills',[])+components.get('rules',[])} evidence={e['id']:e for e in run.get('evidence',[]) if e.get('id')} details={} for raw in run.get('steps',[]): key=raw['step_id']; component=methods.get(key,{}) checks=[{k:c.get(k) for k in ('code','title','result','fields','evidence_ids','suggested_action','basis_ids')} for c in raw.get('findings',[])] ids={e for c in checks for e in c.get('evidence_ids') or []} details[key]={'name':raw.get('name',key), 'action':component.get('instructions') or component.get('description') or raw.get('name',key), 'declared_inputs':component.get('inputs',[]), 'conditions':component.get('conditions',[]), 'exceptions':component.get('exception_note') or component.get('exceptions',[]), 'checks':checks, 'evidence':[{k:evidence[e].get(k) for k in ('id','field','value','source_id','locator','document_id')} for e in ids if e in evidence], 'missing_inputs':raw.get('missing_inputs',[])} return details def compare(db,project_id,left_id,right_id): left=store.get(db,'run',left_id);right=store.get(db,'run',right_id) if left.get('project_id')!=project_id or right.get('project_id')!=project_id: raise KeyError('运行不存在或不属于该项目') def summary(run): snapshot=store.get(db,'input_snapshot',run['input_snapshot_id']) components=run.get('components') or {} return {'id':run['id'],'status':run['status'],'created_at':run['created_at'], 'snapshot_id':run['input_snapshot_id'],'instruction':run.get('requirement',''), 'documents':[{k:d.get(k) for k in ('id','name','version','content_hash')} for d in snapshot.get('documents',[])], 'plan':{k:(components.get('plan') or {}).get(k) for k in ('id','version')}, 'components':[{'id':c['id'],'version':c.get('version'),'content_hash':c.get('content_hash')} for c in components.get('skills',[])+components.get('rules',[])], 'model':{k:(run.get('model_config') or {}).get(k) for k in ('available','name')}, 'checks':[{k:c.get(k) for k in ('code','title','result','suggested_action','evidence_ids')} for c in run.get('checks',[])]} a,b=summary(left),summary(right) old={c['code']:c for c in a['checks']};new={c['code']:c for c in b['checks']} open_results=('issue','unknown','error') delta={'resolved':[new[k] for k in new if k in old and old[k]['result'] in open_results and new[k]['result']=='pass'], 'new':[new[k] for k in new if k not in old and new[k]['result'] in open_results], 'remaining':[new[k] for k in new if k in old and new[k]['result'] in open_results], 'unexecuted':[old[k] for k in old if k not in new]} config_changed=a['plan']!=b['plan'] or a['components']!=b['components'] or a['model']!=b['model'] or a['instruction']!=b['instruction'] return {'left':a,'right':b,'delta':delta,'configuration_changed':config_changed,'read_only':True, 'note':'两次配置、模型或指令有变化,差异不能全部归因于补件。' if config_changed else '仅展示两次冻结结果差异;相关变化不能单独证明补件因果效应。'}