Guide entity resolution with descriptive names and scoped citations

This commit is contained in:
Nico committed 2026-10-15 12:55:46 +02:00
1 parent 2fb81ef4af
commit 482df4579d
7 files changed
+196 -16

No files matched your search

+51 -11
View File
@@ -62,16 +62,16 @@ class Model:
version = request["prompt_version"]
if version == self.unavailable_stage:
raise ProviderUnavailable("LLM_UNAVAILABLE")
if version.startswith('entity_resolution_') and version.endswith(('_v2','_v3')):
if version.startswith('entity_resolution_') and version.endswith(('_v2','_v3','_v4')):
# This is the channel json_object providers actually receive.
assert json.loads(request['messages'][1]['content'])['output_schema'] == request['output_schema']
version = version.replace('_v1', '_v3').replace('_v2', '_v3') if version.startswith('entity_resolution_') else version
if version == "entity_resolution_plan_v3":
version = version.replace('_v1', '_v4').replace('_v2', '_v4').replace('_v3', '_v4') if version.startswith('entity_resolution_') else version
if version == "entity_resolution_plan_v4":
document = copy.deepcopy(self.plan)
if self.invalid_plan:
self.invalid_plan -= 1
document["searches"][0]["term"] = "invented device"
elif version == "entity_resolution_assessment_v3":
elif version == "entity_resolution_assessment_v4":
document = {"resolutions": [{"search_index": i, "status": self.status,
"candidate_id": self.candidate if self.status == "selected" else None,
"reason": "The affected subject is the wall switch; the operator is an interaction participant.",
@@ -132,7 +132,7 @@ def test_resolves_unrecorded_subject_preserves_case_and_portable_replay(built_ca
model = Model(); service = service_for(store)
result = run(store, model, service)
assert result.status == "COMPLETED" and PackageValidator().validate(result.package).valid
assert [r["prompt_version"] for r in model.requests] == ["entity_resolution_plan_v3","entity_resolution_assessment_v3","case_interpretation_resolved_v6"]
assert [r["prompt_version"] for r in model.requests] == ["entity_resolution_plan_v4","entity_resolution_assessment_v4","case_interpretation_resolved_v6"]
assert len(service.database.calls) == 1
assert service.database.calls[0]["filters"] == [{"column":"name","operator":"contains","value":"switch"}]
with PackageReader(result.package) as reader:
@@ -200,7 +200,7 @@ def test_database_outage_preserves_accepted_plan(built_case,monkeypatch):
def test_provider_retry_resume_uses_frozen_input(built_case):
store=built_case[0];model=Model(); model.unavailable_stage='entity_resolution_plan_v3'; service=service_for(store)
store=built_case[0];model=Model(); model.unavailable_stage='entity_resolution_plan_v4'; service=service_for(store)
# Tiny positive retry delay permits resumption in this offline fixture.
g=gateway(model,retry_delay_seconds=0.001)
owner=CaseInterpreter(store,g,project_research=service)
@@ -344,7 +344,7 @@ def test_missing_capture_feedback_resumes_and_replays_without_repeating_research
class MissingCaptureModel(Model):
async def generate(self, config, request, *, max_response_bytes):
response = await super().generate(config, request, max_response_bytes=max_response_bytes)
if request['prompt_version'] == 'entity_resolution_assessment_v3':
if request['prompt_version'] in {'entity_resolution_assessment_v3','entity_resolution_assessment_v4'}:
data = json.loads(request['messages'][1]['content'])
if 'validation_feedback' not in data:
for resolution in response.document['resolutions']:
@@ -359,6 +359,11 @@ def test_missing_capture_feedback_resumes_and_replays_without_repeating_research
store = built_case[0]
model = MissingCaptureModel()
service = service_for(store)
from viano_bug_analysis.analysis.entity_resolution import EntityResolver
owner=CaseInterpreter(store,gateway(model),project_research=service)
context=context_of(store)
EntityResolver(owner).save(context,'input',{'context':context,'policy':policy(SETTINGS),
'binding':service.database.binding(),'protocol_version':3,'interpretation_protocol_version':6})
rejected = run(store, model, service)
assert rejected.status == 'REJECTED'
assert rejected.code == 'ENTITY_RESOLUTION_CAPTURE_SOURCE'
@@ -383,7 +388,7 @@ def test_third_assessment_can_use_lower_reasoning_without_resetting_history(buil
efforts = []
async def generate(self, config, request, *, max_response_bytes):
self.efforts.append((request['prompt_version'], config.reasoning_effort))
if request['prompt_version']=='entity_resolution_assessment_v3' and self.failures:
if request['prompt_version']=='entity_resolution_assessment_v4' and self.failures:
self.failures -= 1
raise LLMError('LLM_INCOMPLETE_OUTPUT')
return await super().generate(config, request, max_response_bytes=max_response_bytes)
@@ -401,9 +406,9 @@ def test_third_assessment_can_use_lower_reasoning_without_resetting_history(buil
result = asyncio.run(owner.interpret('case-A','stable-package'))
assert result.status == 'COMPLETED'
assert len(service.database.calls) == 1
assert model.efforts == [('entity_resolution_plan_v3','medium'),
('entity_resolution_assessment_v3','medium'),('entity_resolution_assessment_v3','medium'),
('entity_resolution_assessment_v3','low'),('case_interpretation_resolved_v6','medium')]
assert model.efforts == [('entity_resolution_plan_v4','medium'),
('entity_resolution_assessment_v4','medium'),('entity_resolution_assessment_v4','medium'),
('entity_resolution_assessment_v4','low'),('case_interpretation_resolved_v6','medium')]
with PackageReader(result.package) as reader:
saved = json.loads(reader.read_bytes('analysis/interpretations/0001/entity-resolution/assessment.json'))
assert saved['payload']['attempt'] == 3
@@ -426,3 +431,38 @@ def test_completed_interpretation_keeps_its_historical_contract(built_case, vers
assert model.requests[-1]['prompt_version']==f'case_interpretation_resolved_v{version}'
assert run(store,model).reused
assert len(model.requests)==3
def test_v4_assessment_schema_requires_capture_and_original_mention_per_index():
from jsonschema import Draft202012Validator
from viano_bug_analysis.analysis.entity_resolution import request_for
context={'sources':{'O0001':{'kind':'observation','data':{'text':'Missing switch'}}}}
plan={'searches':[PLAN['searches'][0],{**PLAN['searches'][0],'term':'Missing switch'}]}
request=request_for('assessment',context,policy(SETTINGS),plan=plan,captures=[{},{}],candidates=[],protocol_version=4)
validator=Draft202012Validator(request['output_schema'])
valid={'resolutions':[{'search_index':i,'status':'no_match','candidate_id':None,
'reason':'No rows','source_ids':['O0001',f'V{i+1:04d}']} for i in range(2)]}
validator.validate(valid)
invalid=copy.deepcopy(valid)
invalid['resolutions'][1]['source_ids']=['O0001','V0001']
assert list(validator.iter_errors(invalid))
invalid=copy.deepcopy(valid)
invalid['resolutions'][0]['source_ids']=['V0001']
assert list(validator.iter_errors(invalid))
def test_description_matches_prioritize_specific_names_without_automatic_selection():
from viano_bug_analysis.analysis.entity_resolution import resolution_guidance
context={'sources':{
'F0001':{'kind':'fact','data':{'name':'project_description','value':{'texts':[
{'column':'title','text':'Find the Wild Sample Creature!'}]}}},
'F0002':{'kind':'fact','data':{'name':'project_entity_name','value':{
'type':'creature','id':'42','name':'Known quest giver'}}}}}
candidates=[{'candidate_id':'K0001','type':'creature','id':'17','name':'Wild Sample Creature'},
{'candidate_id':'K0002','type':'creature','id':'18','name':'Sample Creature'}]
guidance=resolution_guidance(context,candidates)
assert guidance['known_name_bindings'][0]['id']=='42'
matches=guidance['candidate_description_matches']
assert next(m for m in matches if m['candidate_id']=='K0002')['more_specific_matches']==['K0001']
assert next(m for m in matches if m['candidate_id']=='K0001')['more_specific_matches']==[]
assert 'selected' not in str(guidance)
+1 -1
View File
@@ -123,7 +123,7 @@ def test_repeated_resolution_of_one_id_produces_one_entity_in_complete_pipeline(
for term in ['switch','The switch']]}
class Repeated(Model):
async def generate(self,config,request,*,max_response_bytes):
if request['prompt_version']=='entity_resolution_assessment_v3':
if request['prompt_version']=='entity_resolution_assessment_v4':
self.requests.append(copy.deepcopy(request))
data=json.loads(request['messages'][1]['content'])
resolutions=[]
+2 -2
View File
@@ -75,12 +75,12 @@ def test_real_contextual_identity_resolution_with_fixture_llm(service, tmp_path)
async def generate(self, config, request, *, max_response_bytes):
self.calls.append(request['prompt_version'])
data=json.loads(request['messages'][1]['content'])
if request['prompt_version']=='entity_resolution_plan_v3':
if request['prompt_version']=='entity_resolution_plan_v4':
self.mention=next(k for k,v in data['context']['sources'].items() if v['kind']=='fact'
and v['data'].get('name')=='project_description'
and 'Wild Wolpertinger' in json.dumps(v['data']['value']))
doc={'searches':[{'catalog':'creatures','term':'Wild Wolpertinger','role':'affected_subject','source_ids':[self.mention]}]}
elif request['prompt_version']=='entity_resolution_assessment_v3':
elif request['prompt_version']=='entity_resolution_assessment_v4':
candidates=data['candidates']
assert len(candidates)==1 and candidates[0]['id']=='23487'
assert data['captures']['V0001']['result']['items'][0]['row']['name']=='Wild Wolpertinger'