dp-zp-agent/test/test_discovery_expansion.py
jp170na fcfed6a818 Stabilizácia odpovedí
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 16:00:48 +01:00

433 lines
21 KiB
Python

import json
import pytest
from tools.knowledge.sources import profile_units
from tools.knowledge.discovery import expand_and_select
from tools.knowledge.query import ContentAnswerRejected, ContentModelUnavailable
Q='Kto označoval dáta? Na čo mali dáta slúžiť?'
def rows():
return profile_units({'title':'Eva Nová','path':'pages/students/2020/eva/README.md',
'content':'# Bakalárska práca 2025\nStav:\n- Ručne označené dáta.\nÚlohy:\n- Dáta majú slúžiť na učenie klasifikátora.\n# Diplomová práca 2027\nStav:\n- Hotový prekladač.'},include_records=True)
def run(mutation=None):
pool=rows();seed=next(r for r in pool if 'označené' in r['text'])
def complete(messages):
p=json.loads(messages[-1]['content']);sc=p['scopes'][0]
assert p['activity_question']=='Kto označoval dáta'
assert p['question']==Q
assert all('prekladač' not in r['text'] for r in sc['evidence'])
purpose=next(r['id'] for r in sc['evidence'] if 'klasifikátora' in r['text'])
assert p['follow_up_parts']==[{'part_id':'P1','text':'Na čo mali dáta slúžiť'}]
part={'part_id':'P1','evidence_ids':[purpose]}
choice={'scope_id':sc['scope_id'],'matches_activity':True,'primary_ids':sc['seed_ids'],'parts':[part]}
if mutation:mutation(choice)
return {'choices':[{'message':{'content':json.dumps({'scopes':[choice]})}}]}
return expand_and_select(Q,[seed],pool,complete)
def test_context_from_same_work_answers_purpose_without_changing_source():
selected,notes,_=run()
assert len(selected)==2 and not notes
assert all(r['work_year']==2025 for r in selected)
original={r['id']:r for r in rows()}
assert all(r==original[r['id']] for r in selected)
assert 'planned' in {r['phase'] for r in selected}
@pytest.mark.parametrize('mutation',[
lambda c:c['parts'][0].update(evidence_ids=['invented']),
lambda c:c.update(primary_ids=[]),
lambda c:c.update(matches_activity=False),
])
def test_invalid_ids_unproven_activity_and_changed_question_are_rejected(mutation):
with pytest.raises(ContentAnswerRejected):run(mutation)
def test_missing_detail_is_named_without_inventing_purpose():
selected,notes,_=run(lambda c:c['parts'][0].update(evidence_ids=[]))
assert len(selected)==1 and 'Na čo mali dáta slúžiť' in notes[0]
def test_negative_scope_with_empty_part_decisions_adds_no_evidence():
def reject(choice):
choice.update(matches_activity=False, primary_ids=[])
choice['parts'][0]['evidence_ids']=[]
selected,notes,_=run(reject)
assert selected==[] and notes==[]
def test_primary_activity_can_include_verified_same_work_expansion():
def add_detail(choice):
choice['primary_ids'] += choice['parts'][0]['evidence_ids']
selected, notes, _ = run(add_detail)
assert len(selected) == 2 and not notes
def test_discovery_seed_query_does_not_include_secondary_requirements():
from tools.answers.question_intent import discovery_topic
question = 'Kto označoval dáta v roku 2025? Ako ich vyhodnotil a na čo slúžili?'
assert discovery_topic(question) == 'Kto označoval dáta v roku 2025'
assert discovery_topic('Kto označoval dáta a ako ich vyhodnotil') == 'Kto označoval dáta a ako ich vyhodnotil'
def test_model_outage_does_not_claim_empty_evidence():
pool=rows();seed=next(r for r in pool if 'označené' in r['text'])
def fail(_):raise RuntimeError('PRIVATE')
with pytest.raises(ContentModelUnavailable,match='unavailable'):
expand_and_select(Q,[seed],pool,fail)
def _students(names):
pool=[]
for name in names:
key=name.split()[0].lower()
pool.extend(profile_units({'title':name,'path':f'pages/students/2020/{key}/README.md',
'content':'# Diplomová práca 2027\nStav:\n- Ručne označené dáta.'},include_records=True))
return pool,[r for r in pool if 'označené' in r['text']]
SEVEN=['Eva Nová','Adam Nový','Dana Malá','Ivan Biely','Jana Sivá','Karol Hnedý','Lea Zelená']
def _accept(payload):
return {'choices':[{'message':{'content':json.dumps({'scopes':[{'scope_id':s['scope_id'],'matches_activity':True,
'primary_ids':s['seed_ids'],'parts':[]} for s in payload['scopes']]})}}]}
def test_each_scope_gets_an_explicit_decision_in_balanced_batches():
pool,seeds=_students(SEVEN)
batches=[]
def select(messages):
payload=json.loads(messages[-1]['content']);batches.append(payload['scopes'])
return _accept(payload)
selected,_,_=expand_and_select(Q,seeds,pool,select)
# Two balanced calls use both slots of the shared endpoint in one round.
assert sorted(len(b) for b in batches)==[3,4]
assert {r['id'] for r in selected}=={r['id'] for r in seeds}
def _three_students():
# Four students: two balanced calls decide every scope explicitly.
return _students(['Eva Nová','Adam Nový','Dana Malá','Ivan Biely'])
def test_one_malformed_scope_decision_drops_only_that_student():
pool,seeds=_three_students()
def select(messages):
payload=json.loads(messages[-1]['content'])
value={'scopes':[{'scope_id':s['scope_id'],'matches_activity':True,
'primary_ids':['invented'] if s['person']=='Adam Nový' else s['seed_ids'],'parts':[]}
for s in payload['scopes']]}
return {'choices':[{'message':{'content':json.dumps(value)}}]}
selected,_,bounded=expand_and_select(Q,seeds,pool,select)
assert {r['person'] for r in selected}=={'Eva Nová','Dana Malá','Ivan Biely'}
assert bounded # The answer must disclose that the selection is limited.
def test_malformed_batch_is_retried_once_with_a_repair_hint():
pool,seeds=_three_students()
calls=[]
def select(messages):
payload=json.loads(messages[-1]['content']);calls.append(payload)
if len(calls)==1:
return {'choices':[{'message':{'content':'not JSON'}}]}
return _accept(payload)
selected,_,bounded=expand_and_select(Q,seeds,pool,select)
# Four students use both endpoint slots: two batches, one of them retried.
assert len({r['person'] for r in selected})==4 and not bounded and len(calls)==3
# An identical request would make a deterministic model repeat its output.
repaired=[c for c in calls if 'format_repair' in c]
assert len(repaired)==1 and 'format_repair' not in calls[0]
assert [s['scope_id'] for s in repaired[0]['scopes']]==[s['scope_id'] for s in calls[0]['scopes']]
def test_timeout_of_one_concurrent_batch_keeps_the_other_students():
pool,seeds=_students(SEVEN)
failed=[]
def select(messages):
payload=json.loads(messages[-1]['content'])
if any(s['person']=='Ivan Biely' for s in payload['scopes']):
failed.extend(s['person'] for s in payload['scopes'])
raise TimeoutError('slow batch')
return _accept(payload)
selected,_,bounded=expand_and_select(Q,seeds,pool,select)
# Students of the slow call keep their first-selection units (verbatim);
# nothing else of theirs is added and the answer is marked bounded.
assert {r['person'] for r in selected}==set(SEVEN) and failed and bounded
seed_ids={r['id'] for r in seeds}
assert all(r['id'] in seed_ids for r in selected if r['person'] in failed)
def test_rechecked_student_of_a_slow_call_is_not_added():
pool,seeds=_students(['Eva Nová','Adam Nový'])
chosen=[r for r in seeds if r['person']=='Eva Nová']
def select(messages):
payload=json.loads(messages[-1]['content'])
if any(s['person']=='Adam Nový' for s in payload['scopes']):
raise TimeoutError('slow batch')
return _accept(payload)
selected,_,bounded=expand_and_select(Q,chosen,pool,select,candidates=seeds)
assert {r['person'] for r in selected}<={'Eva Nová'} and bounded
def test_invented_question_part_and_its_evidence_are_ignored():
selected,notes,_=run(lambda c:c['parts'][0].update(part_id='P9'))
# Only the supplied follow-up exists; it stays named as unanswered.
assert len(selected)==1 and 'klasifikátora' not in selected[0]['text']
assert len(notes)==1 and 'Na čo mali dáta slúžiť' in notes[0]
def test_unsupplied_free_text_part_is_dropped_without_rejecting_the_scope():
selected,_,_=run(lambda c:c['parts'].append({'question_part':'Vymyslená požiadavka','evidence_ids':['E9']}))
assert len(selected)==2
def test_follow_up_parts_are_exact_substrings_chosen_by_code():
from tools.knowledge.discovery import follow_up_parts
assert follow_up_parts('Kto pracoval na chatbotovi?')==[]
assert follow_up_parts('Ktorí študenti robili s LLM a čo im fungovalo?')==['čo im fungovalo']
q='Ktorí študenti pripravovali alebo ručne označovali dáta? Čo označovali a na čo mali tieto dáta slúžiť?'
assert follow_up_parts(q)==['Čo označovali','na čo mali tieto dáta slúžiť']
# A relative clause after a comma is part of the main request.
assert follow_up_parts('Daj mi mená študentov, čo robili chatbota.')==[]
def test_positive_decision_without_seed_keeps_the_seed_evidence():
pool=rows();seed=next(r for r in pool if 'označené' in r['text'])
def select(messages):
p=json.loads(messages[-1]['content']);sc=p['scopes'][0]
other=next(e['id'] for e in sc['evidence'] if e['id'] not in sc['seed_ids'])
return {'choices':[{'message':{'content':json.dumps({'scopes':[{'scope_id':sc['scope_id'],
'matches_activity':True,'primary_ids':[other],'parts':[]}]})}}]}
selected,_,_=expand_and_select(Q,[seed],pool,select)
assert seed['id'] in {r['id'] for r in selected}
class _Plan(__import__('pydantic').BaseModel):
question: str = Q
intent: str = 'discovery'
presentation: str = 'overview'
meeting: object = None
def _with_summary(sentences, pool=None, seed=None, question=Q):
"""Decisions accept the seed; the wording call answers with `sentences`."""
if pool is None:
pool=rows();seed=next(r for r in pool if 'označené' in r['text'])
seen=[]
def select(messages):
p=json.loads(messages[-1]['content'])
if 'units' in p:
seen.append(p['units'])
return {'choices':[{'message':{'content':json.dumps({'sentences':[
{'id':p['units'][0]['id'] if e=='unit' else e,'text':t} for t,e in sentences]})}}]}
sc=p['scopes'][0]
assert p['write_summary'] is False # decisions never word
return {'choices':[{'message':{'content':json.dumps({'scopes':[{'scope_id':sc['scope_id'],
'matches_activity':True,'primary_ids':sc['seed_ids'],'parts':[]}]})}}]}
result=expand_and_select(question,[seed],pool,select,plan=_Plan(),known_people={'Eva Nová','Adam Nový'},with_prose=True)
return seed,result,seen
def test_checked_summary_sentence_is_returned_for_its_one_unit():
seed,(selected,_,_,prose),seen=_with_summary([('Ručne označila dáta.','unit')])
assert prose=={seed['id']:['Ručne označila dáta.']}
# The wording call sees each unit alone: its own text, nothing else.
assert [u['text'] for u in seen[0]]==[seed['text']]
@pytest.mark.parametrize('text,evidence',[
('Ručne označila dáta v roku 2025.','unit'), # dates are rendered by code
('Adam Nový ručne označil dáta.','unit'), # another person's name
('Ručne označila dáta.','U99'), # not a supplied unit
])
def test_unsafe_summary_sentence_is_dropped_but_selection_stays(text,evidence):
seed,(selected,_,_,prose),_=_with_summary([(text,evidence)])
assert prose=={} and seed['id'] in {r['id'] for r in selected}
def test_planned_unit_worded_as_achievement_is_dropped():
pool=profile_units({'title':'Eva Nová','path':'pages/students/2020/eva/README.md',
'content':'# Bakalárska práca 2025\nÚlohy:\n- Pripravte dáta na učenie klasifikátora.'},include_records=True)
seed=next(r for r in pool if 'Pripravte' in r['text'])
assert seed['phase'] in {'planned','assignment'}
def run_with(text):
return _with_summary([(text,'unit')],pool=pool,seed=seed,question='Kto pripravoval dáta?')[1][3]
assert run_with('Dáta boli pripravené na učenie klasifikátora.')=={}
assert run_with('Má pripraviť dáta na učenie klasifikátora.')=={seed['id']:['Má pripraviť dáta na učenie klasifikátora.']}
def test_wording_is_skipped_while_another_answer_uses_the_endpoint(monkeypatch):
from tools.answers import model_client
monkeypatch.setattr(model_client,'model_busy',lambda:True)
_,(selected,_,_,prose),seen=_with_summary([('Ručne označila dáta.','unit')])
assert selected and prose=={} and seen==[]
def test_student_gender_hint_is_supplied_for_wording():
from tools.knowledge.discovery import grammatical_gender
assert grammatical_gender('Simona Bobrovčanová')=='feminine'
assert grammatical_gender('Tetiana Mohorian')=='feminine'
assert grammatical_gender('Jakub Maruniak')=='masculine'
assert grammatical_gender('Eduard Matovka')=='masculine' # male surname ending in -a
assert grammatical_gender('Nikita Bodnar')=='masculine'
def _profile(name, content):
key=name.split()[0].lower()
return profile_units({'title':name,'path':f'pages/students/2020/{key}/README.md',
'content':content},include_records=True)
def test_recorded_result_is_offered_before_plans_repeating_the_topic():
# Twelve plans repeat the question's words more often than the one result.
plans='\n'.join(f'- Dotrénujte jazykový model variant {i} a vyhodnoťte model.' for i in range(14))
pool=_profile('Eva Nová','# Diplomová práca 2027\nÚlohy:\n'+plans+'\nStav:\n- funguje dotrénovanie Slovak Mistral.\n- po dotrénovaní je model ručne vyskúšaný.')
seed=next(r for r in pool if 'ručne vyskúšaný' in r['text'])
offered=[]
def select(messages):
p=json.loads(messages[-1]['content']);offered.extend(e['text'] for s in p['scopes'] for e in s['evidence'])
return _accept(p)
expand_and_select('Kto dotrénoval jazykový model?',[seed],pool,select)
assert any('funguje dotrénovanie Slovak Mistral' in t for t in offered)
def test_student_missed_by_first_selection_is_rechecked_and_needs_a_positive_decision():
pool,seeds=_students(['Eva Nová','Adam Nový'])
chosen=[r for r in seeds if r['person']=='Eva Nová']
def run_with(accept_adam):
seen=[]
def select(messages):
p=json.loads(messages[-1]['content']);seen.extend(s['person'] for s in p['scopes'])
return {'choices':[{'message':{'content':json.dumps({'scopes':[{'scope_id':s['scope_id'],
'matches_activity':accept_adam or s['person']!='Adam Nový',
'primary_ids':s['seed_ids'] if accept_adam or s['person']!='Adam Nový' else [],'parts':[]}
for s in p['scopes']]})}}]}
selected,_,_=expand_and_select(Q,chosen,pool,select,candidates=seeds)
return seen,{r['person'] for r in selected}
seen,people=run_with(True)
assert 'Adam Nový' in seen and people=={'Eva Nová','Adam Nový'}
seen,people=run_with(False)
assert people=={'Eva Nová'} # Only this step's positive decision adds a student.
def test_planned_candidate_is_not_rechecked():
pool=_profile('Eva Nová','# Diplomová práca 2027\nStav:\n- Ručne označené dáta.')
plan=_profile('Adam Nový','# Diplomová práca 2027\nÚlohy:\n- Označte dáta.')
seed=next(r for r in pool if 'označené' in r['text'])
seen=[]
def select(messages):
p=json.loads(messages[-1]['content']);seen.extend(s['person'] for s in p['scopes'])
return _accept(p)
expand_and_select(Q,[seed],pool+plan,select,candidates=[seed,*plan])
assert seen==['Eva Nová']
def test_scopes_beyond_the_detail_limit_get_a_light_decision_and_a_notice():
from tools.knowledge import discovery
names=[f'Meno{i} Priezvisko' for i in range(discovery.MAX_SCOPES+3)]
pool,seeds=_students(names)
scopes=[]
def select(messages):
p=json.loads(messages[-1]['content']);scopes.extend(p['scopes'])
assert len(p['scopes'])<=10
return _accept(p)
selected,notes,bounded=expand_and_select(Q,seeds,pool,select)
assert len({r['person'] for r in selected})==len(names)
assert sum(s['detail'] for s in scopes)==discovery.MAX_SCOPES
light=[s for s in scopes if not s['detail']]
assert len(light)==3 and all(len(s['evidence'])<=4 for s in light)
# The follow-up was not looked up for them: the answer is marked as a
# limited selection, without a per-person "missing" claim.
assert bounded and not any(s['person'] in n for s in light for n in notes)
def _fixed_calibration(monkeypatch):
from tools.knowledge import discovery
# Fixed calibration; the deployed values are measured and configurable.
monkeypatch.setattr(discovery,'TOKENS',{'light':25,'detail':70,'prose':60})
monkeypatch.setattr(discovery,'STREAM_RATE',60.0);monkeypatch.setattr(discovery,'SHARED_RATE',40.0)
monkeypatch.setattr(discovery,'TARGET_SECONDS',22.0);monkeypatch.setattr(discovery,'MAX_SCOPES',8)
return discovery
def test_work_per_scope_follows_the_time_left_and_the_endpoint_load(monkeypatch):
d=_fixed_calibration(monkeypatch)
# One user, enough time: every scope decided, the first ones in detail
# and the first of those with readable wording.
levels,worded=d.plan_levels(17,prose=True,seconds=35,busy=False)
assert len(levels)==17 and levels[:8]==['detail']*8 and set(levels[8:])=={'light'} and 4<=worded<=17
# Less time: still every student, fewer details, less wording.
short,few=d.plan_levels(17,prose=True,seconds=14,busy=False)
assert len(short)==17 and short.count('detail')<8 and few<worded
# Another answer shares the endpoint: one slot, about 40 tokens/s.
busy,busy_worded=d.plan_levels(17,prose=True,seconds=35,busy=True)
assert busy.count('detail')<=8 and busy_worded<worded
# Not even the decisions fit: fewer students, the answer is bounded.
tiny,_=d.plan_levels(17,prose=True,seconds=6,busy=False)
assert len(tiny)<17 and set(tiny)=={'light'}
assert d.plan_levels(3,prose=False,seconds=None,busy=False)[1]==0
def test_worded_and_plain_scopes_are_separate_calls(monkeypatch):
d=_fixed_calibration(monkeypatch)
costs=[130]*6+[25]*9
chunks,seconds=d._layout(costs,6,busy=False)
assert all(all(i<6 for i in c) or all(i>=6 for i in c) for c in chunks)
assert sorted(i for c in chunks for i in c)==list(range(15)) and seconds<22
# Sharing the endpoint, this answer's calls run one after another.
assert d._layout(costs,6,busy=True)[1]>seconds
def test_calls_never_exceed_the_input_limit(monkeypatch):
d=_fixed_calibration(monkeypatch)
sizes=[3000]*8+[700]*12
chunks,_=d._layout([70]*8+[25]*12,0,busy=False,sizes=sizes)
assert all(sum(sizes[i] for i in c)<=d.BATCH_INPUT_TOKENS and len(c)<=10 for c in chunks)
assert sorted(i for c in chunks for i in c)==list(range(20))
def test_summary_is_requested_per_call_and_ignored_when_not_requested():
pool,seeds=_students(['Eva Nová'])
def select(messages):
p=json.loads(messages[-1]['content'])
assert p['write_summary'] is False
sc=p['scopes'][0]
return {'choices':[{'message':{'content':json.dumps({'scopes':[{'scope_id':sc['scope_id'],
'matches_activity':True,'primary_ids':sc['seed_ids'],'parts':[],
'summary':[{'text':'Ručne označila dáta.','evidence_id':sc['seed_ids'][0]}]}]})}}]}
selected,_,_,prose=expand_and_select(Q,seeds,pool,select,plan=None,with_prose=True)
assert selected and prose=={}
def test_deadline_before_any_decision_falls_back_to_first_selection():
from tools.answers.model_client import DeadlineExceeded
pool,seeds=_students(['Eva Nová','Adam Nový'])
def late(_):raise DeadlineExceeded('budget')
selected,notes,bounded=expand_and_select(Q,seeds,pool,late)
assert {r['id'] for r in selected}=={r['id'] for r in seeds} and bounded and notes==[]
def test_rechecked_student_is_decided_on_the_whole_scope():
from tools.knowledge import discovery
names=[f'Meno{i} Priezvisko' for i in range(discovery.MAX_SCOPES)]
pool,seeds=_students(names)
extra=_profile('Adam Nový','# Diplomová práca 2027\nStav:\n'+'\n'.join(f'- Ručne označené dáta časť {i}.' for i in range(8)))
offered={}
def select(messages):
p=json.loads(messages[-1]['content'])
for sc in p['scopes']:offered[sc['person']]=sc
return _accept(p)
expand_and_select(Q,seeds,pool+extra,select,candidates=[*seeds,next(r for r in extra if 'označené' in r['text'])])
adam=offered['Adam Nový']
assert adam['detail'] is False and len(adam['evidence'])>3
@pytest.mark.parametrize('source,text,kept',[
('- Prezretý video kanál s RASA tutoriálom.','Vytvoril prezretý video kanál s RASA tutoriálom.',False),
('- Vypracované experimenty pre obnovu interpunkcie.','Vytvoril vypracované experimenty pre obnovu interpunkcie.',False),
('- Vypracované experimenty pre obnovu interpunkcie.','Vypracoval experimenty pre obnovu interpunkcie.',True),
])
def test_wording_never_claims_work_the_unit_does_not_state(source,text,kept):
pool=_profile('Eva Nová','# Diplomová práca 2027\nStav:\n'+source)
seed=next(r for r in pool if r['text']==source)
_,(_,_,_,prose),_=_with_summary([(text,'unit')],pool=pool,seed=seed,question='Kto robil experimenty?')
assert (prose=={seed['id']:[text]})==kept