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=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