153 lines
9.4 KiB
Python
153 lines
9.4 KiB
Python
"""Hybrid bootstrap and incremental, controller-owned consolidation bookkeeping."""
|
|
import copy
|
|
import hashlib
|
|
import json
|
|
from importlib.resources import files
|
|
from . import investigation_lean as previous
|
|
from .investigation_config import InvestigationError
|
|
|
|
VERSION='investigation_project_v20'
|
|
|
|
|
|
def legacy(context):
|
|
result=copy.deepcopy(context);result.pop('lean_protocol',None)
|
|
return result
|
|
|
|
|
|
def schema(context, *, strict=False):
|
|
result = _schema(context, strict=strict)
|
|
if context.get('assessment_scope_version') == 1:
|
|
if context.get('previous_result') is None:
|
|
result['properties']['assessment'] = {'type':'null'}
|
|
else:
|
|
result['properties']['assessment'] = next(copy.deepcopy(v) for v in
|
|
result['properties']['assessment']['anyOf'] if v.get('type') == 'object')
|
|
from .investigation_window import enabled, extend_recall
|
|
if enabled(context):
|
|
result = extend_recall(result)
|
|
if previous.checkpoint(context):
|
|
from .investigation_window import checkpoint_recall
|
|
result = checkpoint_recall(result)
|
|
return result
|
|
|
|
|
|
def _schema(context, *, strict=False):
|
|
c=legacy(context);old=previous.schema(c,strict=True)
|
|
if not previous.checkpoint(c):
|
|
branch=next(b for b in old['properties']['next']['oneOf'] if b['properties']['kind'].get('const')=='checkpoint')
|
|
branch['properties']['intent']={'enum':['completion','switch','contradiction','test','review']}
|
|
branch['required'].append('intent')
|
|
return old if strict else {**old,'properties':{**old['properties'],'next':{'type':'object','required':['kind'],
|
|
'properties':{'kind':{'enum':['tool','recall','checkpoint']}}}}}
|
|
from .investigation_compact import preparing_review
|
|
if not preparing_review(c):
|
|
old['properties']['next']['oneOf'].append({'type':'object','additionalProperties':False,
|
|
'required':['kind','hypothesis_id','reason'],'properties':{'kind':{'const':'switch'},
|
|
'hypothesis_id':{'type':'string','pattern':'^H[1-9][0-9]*$'},'reason':{'type':'string','minLength':1}}})
|
|
if 'properties' in old['properties']['next'] and 'enum' in old['properties']['next']['properties'].get('kind',{}) and not preparing_review(c):
|
|
old['properties']['next']['properties']['kind']['enum'].append('switch')
|
|
finding={'type':'object','additionalProperties':False,'required':['direction','text','evidence_refs'],
|
|
'properties':{'direction':{'enum':['support','against','neutral']},'text':{'type':'string','minLength':1,'pattern':r'\S'},
|
|
'evidence_refs':{'type':'array','minItems':1,'maxItems':8,'uniqueItems':True,
|
|
'items':{'type':'string','pattern':'^E[0-9]{6,}$'}}}}
|
|
props={'assessment':old['properties']['assessment'],'confidence':old['properties']['update']['properties']['confidence'],
|
|
'findings':{'type':'array','items':finding},'uncertainties':{'type':'array','items':{'type':'string'}},
|
|
'next_question':{'type':['string','null']},
|
|
'retire':{'type':'array','items':{'type':'object','additionalProperties':False,'required':['finding_id','reason'],
|
|
'properties':{'finding_id':{'type':'string'},'reason':{'type':'string','minLength':1,'pattern':r'\S'}}}},
|
|
'next':old['properties']['next']}
|
|
return {'type':'object','additionalProperties':False,'required':list(props),'properties':props}
|
|
|
|
|
|
def build(context):
|
|
from .model_context import model_json
|
|
c=legacy(context);request=previous.build(c);doc=json.loads(request['messages'][1]['content'])
|
|
contract=schema(context)
|
|
brief=context.get('working_brief',{}).get('brief',{})
|
|
if brief.get('next_question_details'):
|
|
if previous.checkpoint(c):doc['input']['working_brief']['next_question']=brief['next_question_details']
|
|
elif doc['input'].get('checkpoint_brief'):doc['input']['checkpoint_brief']['next_question']=brief['next_question_details']
|
|
if previous.checkpoint(c):
|
|
doc['task']=('assess_pending_result_for_early_review' if doc['task']=='assess_pending_result_for_early_review'
|
|
else 'consolidate_investigation_session')
|
|
doc['input']['checkpoint_intent']=context['lean_session'].get('intent','controller_boundary')
|
|
request['messages'][0]['content']=files('viano_bug_analysis.llm.prompts').joinpath('investigation_consolidate_v20.txt').read_text(encoding='utf-8')
|
|
else:
|
|
doc['input']['alternatives']={hid:{'title':h['proposal']['title'],'confidence':h['confidence'],'disposition':h['disposition']}
|
|
for hid,h in context['hypotheses'].items() if hid!=context['selected_hypothesis']}
|
|
doc['input']['tool_schema']['request_contract']=schema(context,strict=True)['properties']['next']
|
|
request['messages'][0]['content']=files('viano_bug_analysis.llm.prompts').joinpath('investigation_lean_v20.txt').read_text(encoding='utf-8')
|
|
doc['output_schema']=contract;request['messages'][1]['content']=model_json(doc)
|
|
request.update(prompt_version=VERSION,output_schema=contract)
|
|
return request
|
|
|
|
|
|
def chunks(text, width):
|
|
return [text[i:i+width] for i in range(0,len(text),width) if text[i:i+width].strip()]
|
|
|
|
|
|
def wire(context,reply):
|
|
"""Translate semantic additions into the frozen canonical brief contract.
|
|
|
|
Keep prior findings by default. Only explicit, justified retirements remove
|
|
them. Long text is split losslessly; it is never silently shortened.
|
|
"""
|
|
if not previous.checkpoint(context):return copy.deepcopy(reply)
|
|
old=context.get('working_brief',{}).get('brief',{}).get('findings',[])
|
|
retired={r['finding_id']:r['reason'] for r in reply['retire']}
|
|
if len(retired)!=len(reply['retire']) or set(retired)-{f['id'] for f in old}:
|
|
raise InvestigationError('INVESTIGATION_BRIEF_ID',feedback={'requirement':'retire_only_known_existing_finding_ids_once'})
|
|
findings=[copy.deepcopy(f) for f in old if f['id'] not in retired]
|
|
for f in reply['findings']:
|
|
parts=chunks(f['text'],300)
|
|
for index,part in enumerate(parts):
|
|
refs=sorted(f['evidence_refs'])
|
|
scope=('Captured query scope; see referenced evidence.' if len(parts)==1 else f'Captured query scope; text part {index+1}/{len(parts)}; see referenced evidence.')
|
|
existing=next((v for v in findings if v['direction']==f['direction'] and v['text']==part and sorted(v['evidence_refs'])==refs and (len(parts)==1 or v['scope']==scope)),None)
|
|
if existing:continue
|
|
payload=json.dumps([f['direction'],part,refs,index,len(parts)],ensure_ascii=False,separators=(',',':'))
|
|
fid='B'+str(int(hashlib.sha256(payload.encode()).hexdigest()[:20],16)).zfill(4)
|
|
if any(v['id']==fid for v in findings) or fid in retired:raise InvestigationError('INVESTIGATION_BRIEF_ID')
|
|
findings.append({'id':fid,'direction':f['direction'],'text':part,
|
|
'scope':scope,'evidence_refs':refs})
|
|
uncertainties=list(dict.fromkeys(part for text in reply['uncertainties'] for part in chunks(text,240)))
|
|
question=reply['next_question'] or 'No further question.'
|
|
if not question.strip():question='No further question.'
|
|
if len(findings)>16 or len(uncertainties)>6:
|
|
raise InvestigationError('INVESTIGATION_MEMORY_LIMIT',feedback={'requirement':'consolidate_redundant_findings_with_explicit_retirements_preserve_counterevidence'})
|
|
nxt=copy.deepcopy(reply['next'])
|
|
if nxt['kind']=='switch':
|
|
target=nxt['hypothesis_id'];h=context['hypotheses'].get(target)
|
|
from viano_bug_analysis.budget.engine import TERMINAL
|
|
if (target==context['selected_hypothesis'] or h is None or h['disposition']!='active'
|
|
or context['budget']['accounts'][target]['status'] in TERMINAL):
|
|
raise InvestigationError('INVESTIGATION_SWITCH_TARGET',feedback={'requirement':'choose_another_active_hypothesis_with_available_budget'})
|
|
nxt={'kind':'defer','reason':nxt['reason']}
|
|
return {'assessment':copy.deepcopy(reply['assessment']),'next':nxt,
|
|
'update':{'confidence':reply['confidence'],'add_questions':[],'resolve_questions':[],
|
|
'working_brief':{'format_version':2,'findings':findings,'uncertainties':uncertainties,
|
|
'next_question':question[:300],
|
|
'retired':[{'id':fid,'reason':'Explicit model retirement; full reason retained in bound LLM response.'} for fid in retired]}}}
|
|
|
|
|
|
def normalize(context,reply):
|
|
result=previous.normalize(legacy(context),wire(context,reply))
|
|
if previous.checkpoint(context) and reply['next_question'] and len(reply['next_question'])>300:
|
|
result['update']['working_brief']['next_question_details']=reply['next_question']
|
|
return result
|
|
|
|
|
|
def validate_reply(context,reply):
|
|
from jsonschema import Draft202012Validator
|
|
from viano_bug_analysis.llm.diagnostics import schema_feedback
|
|
error=next(Draft202012Validator(schema(context,strict=True)).iter_errors(reply),None)
|
|
if error:
|
|
if context.get('policy', {}).get('prompt_window_version') == 2:
|
|
from .investigation_contract import feedback
|
|
raise InvestigationError('INVESTIGATION_OUTPUT', feedback=feedback(error,reply))
|
|
from .tool_guidance import filter_feedback
|
|
raise InvestigationError('INVESTIGATION_OUTPUT',feedback=filter_feedback(previous.repair_feedback(schema_feedback(error,include_keyword=True))))
|
|
if previous.checkpoint(context):
|
|
from .investigation_compact import validate_tool_shape
|
|
validate_tool_shape(legacy(context),wire(context,reply))
|