Oprava porovnávania
This commit is contained in:
parent
e0f5713da4
commit
59f208b571
@ -1500,6 +1500,13 @@ známe rozkazovacie formulácie zadania menia na pomenovania cieľov, napríklad
|
|||||||
pravidlá po kontrole doslovného dôkazu, nie o voľné dopĺňanie faktov modelom.
|
pravidlá po kontrole doslovného dôkazu, nie o voľné dopĺňanie faktov modelom.
|
||||||
Neznámy opis, podmienky, negácie a údaje o dosiahnutom stave zostávajú zachované.
|
Neznámy opis, podmienky, negácie a údaje o dosiahnutom stave zostávajú zachované.
|
||||||
|
|
||||||
|
Pri obsahovom porovnaní viacerých študentov sa pre každého načíta požadovaný typ
|
||||||
|
práce z rovnakého SQLite snapshotu; všetky podklady naďalej podliehajú spoločnému
|
||||||
|
tokenovému rozpočtu. Zadanie a zaznamenaný stav sa kontrolujú a zobrazujú oddelene.
|
||||||
|
Chýbajúce výsledky sa oznámia, neodvodzujú sa z cieľov. Grafová otázka na roky,
|
||||||
|
v ktorých majú práce „obaja“, používa prienik rokov a prikladá doložené typy a názvy
|
||||||
|
prác z týchto rokov; nezobrazuje namiesto odpovede všetky individuálne roky.
|
||||||
|
|
||||||
Otázky na obsah, zadanie, ciele alebo metódy práce používajú textové výňatky
|
Otázky na obsah, zadanie, ciele alebo metódy práce používajú textové výňatky
|
||||||
z príslušnej akademickej sekcie, nie iba metadáta o názve a roku. Výňatok musí
|
z príslušnej akademickej sekcie, nie iba metadáta o názve a roku. Výňatok musí
|
||||||
patriť požadovanej osobe, typu práce a prípadne roku. Názov, rok ani samotný
|
patriť požadovanej osobe, typu práce a prípadne roku. Názov, rok ani samotný
|
||||||
|
|||||||
@ -25,7 +25,7 @@ def test_identity_is_a_sentence_but_year_is_not_a_start_or_completion_date():
|
|||||||
|
|
||||||
@pytest.mark.parametrize('person,question,expected', [
|
@pytest.mark.parametrize('person,question,expected', [
|
||||||
('Ján Holp', 'Čomu sa venuje diplomová práca Jána Holpa?', 'Jána Holpa'),
|
('Ján Holp', 'Čomu sa venuje diplomová práca Jána Holpa?', 'Jána Holpa'),
|
||||||
('Eva Nová', 'Aké je zadanie diplomovej práce Evy Novej?', None),
|
('Eva Nová', 'Aké je zadanie diplomovej práce Evy Novej?', 'Evy Novej'),
|
||||||
('Ján Holp', 'Aké práce má Ján Holp?', None),
|
('Ján Holp', 'Aké práce má Ján Holp?', None),
|
||||||
('Ján Holp', 'Aké práce má Martin Jančura?', None),
|
('Ján Holp', 'Aké práce má Martin Jančura?', None),
|
||||||
('Ján Holp', 'Aké je zadanie práce Jána Holpáka?', None),
|
('Ján Holp', 'Aké je zadanie práce Jána Holpáka?', None),
|
||||||
@ -44,7 +44,7 @@ def test_content_changes_only_grammar_after_literal_evidence_passes(payload):
|
|||||||
before = copy.deepcopy(payload)
|
before = copy.deepcopy(payload)
|
||||||
result = generate_verified_rag(QUESTION, payload, lambda _: completion(description()))
|
result = generate_verified_rag(QUESTION, payload, lambda _: completion(description()))
|
||||||
assert result['accepted_for_delivery']
|
assert result['accepted_for_delivery']
|
||||||
assert 'Diplomová práca Jána Holpa má podľa dostupného zadania tieto ciele:' in result['answer']
|
assert 'Diplomová práca Jána Holpa má podľa dostupných podkladov tieto ciele:' in result['answer']
|
||||||
assert '1. Vypracovanie prehľadu metód ohodnotenia dokumentov' in result['answer']
|
assert '1. Vypracovanie prehľadu metód ohodnotenia dokumentov' in result['answer']
|
||||||
assert '2. Implementácia systému pre získavanie informácií v slovenskom jazyku.' in result['answer']
|
assert '2. Implementácia systému pre získavanie informácií v slovenskom jazyku.' in result['answer']
|
||||||
assert 'Vypracujte' not in result['answer'] and 'Implementujte' not in result['answer']
|
assert 'Vypracujte' not in result['answer'] and 'Implementujte' not in result['answer']
|
||||||
|
|||||||
235
test/test_comparison_answers.py
Normal file
235
test/test_comparison_answers.py
Normal file
@ -0,0 +1,235 @@
|
|||||||
|
"""Real multi-person query regressions, including deliberately wrong evidence."""
|
||||||
|
import copy
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from tools.answer_evaluation.context_policy import answer_context_issues, text_contains_value
|
||||||
|
from tools.answer_evaluation.graph_context import graph_context
|
||||||
|
from tools.answer_evaluation.presentation import content_lines, goal_text
|
||||||
|
from tools.answer_evaluation.rag_evidence import (content_sections, expand_comparison_sources,
|
||||||
|
rag_evidence_context, requested_types)
|
||||||
|
from tools.answer_evaluation.verified_rag import generate_verified_rag, rag_answer_issues
|
||||||
|
from tools.answer_evaluation.render_answer import render_graph_answer
|
||||||
|
from tools.graphrag import graph_search as gs
|
||||||
|
from tools.graphrag.tests.test_graph_search import FakeSession
|
||||||
|
from tools.rag.llm_context import compact_rag_payload
|
||||||
|
from tools.rag.rag_utils import build_context_text
|
||||||
|
|
||||||
|
COMPARE = ('Porovnaj zameranie bakalárskej práce Ondreja Megelu a diplomovej práce Jána Holpa. '
|
||||||
|
'Rozlišuj zadanie od dosiahnutých výsledkov a uveď zdroje.')
|
||||||
|
SHARED = 'V ktorých rokoch majú Ján Holp a Ondrej Megela obaja evidovanú prácu alebo projekt?'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('canonical,wording', [
|
||||||
|
('Ondrej Megela', 'Ondreja Megelu'), ('Martin Šarišský', 'Martina Šarišského'),
|
||||||
|
('Ján Malinovský', 'Jána Malinovského'), ('Eva Nová', 'Evy Novej')])
|
||||||
|
def test_complete_case_endings(canonical, wording):
|
||||||
|
assert text_contains_value(wording, canonical, person=True)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('canonical,other', [
|
||||||
|
('Martin Šarišský', 'Martin Jančura'), ('Ján Holp', 'Ján Holpák'),
|
||||||
|
('Ondrej Megela', 'Ondrej Megelovec'), ('Martin NaN1', 'Martin NaN2')])
|
||||||
|
def test_no_short_prefix_matching(canonical, other):
|
||||||
|
assert not text_contains_value(other, canonical, person=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_each_person_gets_the_requested_work_type():
|
||||||
|
assert requested_types(COMPARE, ['Ján Holp', 'Ondrej Megela']) == {
|
||||||
|
'Ján Holp': 'diploma_thesis', 'Ondrej Megela': 'bachelor_thesis'}
|
||||||
|
|
||||||
|
|
||||||
|
def test_goal_conversion_is_not_all_or_nothing():
|
||||||
|
text = '\n'.join(content_lines(['- Zistite, čo je to RAG.', '- Naučte sa čo je to GraphRAG.',
|
||||||
|
'- Zostavte malý znalostný graf.', '- Využite graf pri vyhľadávaní.',
|
||||||
|
'- Použite databázu Neo4j.', 'Práca neimplementuje vlastný model.'],
|
||||||
|
'Ján Malinovský', 'diploma_project', 'Aké sú ciele diplomového projektu Jána Malinovského?'))
|
||||||
|
assert 'Diplomový projekt Jána Malinovského' in text
|
||||||
|
assert 'Zistiť, čo je to RAG.' in text and 'Použiť databázu Neo4j.' in text
|
||||||
|
assert 'Ďalšie podklady:' in text and 'Práca neimplementuje vlastný model.' in text
|
||||||
|
assert not any(word in text for word in ['Zistite', 'Naučte', 'Zostavte', 'Využite', 'Použite'])
|
||||||
|
|
||||||
|
|
||||||
|
def test_prefixed_goal_keeps_other_converted_items():
|
||||||
|
text = '\n'.join(content_lines(['1. Vypracujte prehľad systémov generovania prirodzeného jazyka.',
|
||||||
|
'2. Na základe tohto prehľadu navrhnite a implementujte agentový systém.'],
|
||||||
|
'Martin Šarišský', 'diploma_thesis', 'Čomu sa venuje diplomová práca Martina Šarišského?'))
|
||||||
|
assert 'Diplomová práca Martina Šarišského' in text
|
||||||
|
assert 'Vypracovanie prehľadu' in text and 'navrhnúť a implementovať' in text
|
||||||
|
assert 'Vypracujte' not in text and 'navrhnite' not in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_coordinated_commands_and_objects_keep_grammatical_infinitives():
|
||||||
|
text='\n'.join(content_lines(['Vypracujte prehľad aplikácií BERT a spôsoby ich vyhodnotenia.',
|
||||||
|
'Vypracujte prehľad metód a implementujte systém.'],'Ondrej Megela','bachelor_thesis'))
|
||||||
|
assert 'Vypracovať prehľad aplikácií BERT a spôsoby ich vyhodnotenia.' in text
|
||||||
|
assert 'Vypracovať prehľad metód a implementovať systém.' in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_goal_headings_are_not_extra_evidence_and_sentences_keep_capitals():
|
||||||
|
text='\n'.join(content_lines(['Ciele na semester.', 'Zlepšite proces. Zlepšite extrakciu.'],
|
||||||
|
'Ján Malinovský','diploma_project'))
|
||||||
|
assert 'Ďalšie podklady' not in text and 'Zlepšiť proces. Zlepšiť extrakciu.' in text
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('literal', ['Použite https://example.test/pouzite/vypracujte.',
|
||||||
|
'Implementujte parser pre `navrhnite a implementujte`.'])
|
||||||
|
def test_grammar_does_not_change_urls_or_code_literals(literal):
|
||||||
|
assert goal_text(literal)==(literal,False)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def comparison_payload(tmp_path):
|
||||||
|
sources = []; rows = []
|
||||||
|
for i, (person,slug,kind,year,goal,progress) in enumerate([
|
||||||
|
('Ján Holp','jan_holp','Diplomová práca',2021,'Implementujte systém vyhľadávania.',
|
||||||
|
'Implementovaný PageRank a indexovanie webových stránok.'),
|
||||||
|
('Ondrej Megela','ondrej_megela','Bakalárska práca',2020,'Natrénujte jazykový model metódou BERT.',
|
||||||
|
'Natrénovaný model wiki103 a vykonané vyhodnotenie.')],1):
|
||||||
|
path=f'pages/students/2018/{slug}/README.md'
|
||||||
|
sources.append({'source_id':f'S{i}','title':person,'document_path':path,
|
||||||
|
'source_url':f'https://example.test/{slug}','text':f'Sekcia: {kind} {year}\nNázov: Ukážkový názov práce.'})
|
||||||
|
rows.append((path,json.dumps([[person,f'{kind} {year}']]),f'Zadanie:\n- {goal}\nStav:\n- {progress}',1,0))
|
||||||
|
rows.append((path,json.dumps([[person,'Diplomový projekt 2022']]),'Stav:\nCudzí projekt využíva Cassandra.',1,1))
|
||||||
|
db=tmp_path/'index.sqlite'
|
||||||
|
with sqlite3.connect(db) as conn:
|
||||||
|
conn.execute('CREATE TABLE chunks(document_path,heading_paths_json,text,published,chunk_index)')
|
||||||
|
conn.executemany('INSERT INTO chunks VALUES(?,?,?,?,?)',rows)
|
||||||
|
raw={'status':'ok','reason':None,'sources':sources,'context':build_context_text(sources),
|
||||||
|
'complete':False,'truncated':True,'truncation_reasons':['result_limit'],'index_version':{'version':'fixture'}}
|
||||||
|
return db,raw
|
||||||
|
|
||||||
|
|
||||||
|
def selected_comparison(context):
|
||||||
|
quotes=[{'source_id':p['source_id'],'quote':s['text']} for p in context['content_passages']
|
||||||
|
for s in content_sections(p['text']) if s['kind'] in {'assignment','progress'}]
|
||||||
|
return {'status':'ok','answer':'','claims':[],'evidence_quotes':quotes,
|
||||||
|
'source_urls':[s['source_url'] for s in context['sources']]}
|
||||||
|
|
||||||
|
|
||||||
|
def test_expansion_preserves_binding_versions_and_budget(comparison_payload):
|
||||||
|
db,raw=comparison_payload; before=copy.deepcopy(raw)
|
||||||
|
payload=compact_rag_payload(expand_comparison_sources(db,COMPARE,raw))
|
||||||
|
c=rag_evidence_context(COMPARE,payload)
|
||||||
|
assert c['requirements']['work_types']=={'Ján Holp':'diploma_thesis','Ondrej Megela':'bachelor_thesis'}
|
||||||
|
assert c['requirements']['content_roles']=={p:['assignment','progress'] for p in c['requirements']['people']}
|
||||||
|
assert 'Cassandra' not in payload['context']
|
||||||
|
assert payload['index_version']==raw['index_version'] and payload['complete'] is False
|
||||||
|
assert payload['context_budget']['used_tokens']<=payload['context_budget']['max_tokens']
|
||||||
|
assert raw==before
|
||||||
|
|
||||||
|
|
||||||
|
def test_hidden_chunks_are_not_added_to_comparison_evidence(comparison_payload):
|
||||||
|
db,raw=comparison_payload; path=raw['sources'][0]['document_path']
|
||||||
|
with sqlite3.connect(db) as conn:
|
||||||
|
conn.execute('INSERT INTO chunks VALUES(?,?,?,?,?)',(path,
|
||||||
|
json.dumps([['Ján Holp','Diplomová práca 2021']]),'Stav:\nNezverejnený tajný výsledok.',0,2))
|
||||||
|
expanded=expand_comparison_sources(db,COMPARE,raw)
|
||||||
|
assert 'tajný výsledok' not in expanded['context']
|
||||||
|
|
||||||
|
|
||||||
|
def test_relevant_child_document_is_preserved(comparison_payload):
|
||||||
|
db,raw=comparison_payload
|
||||||
|
child={**raw['sources'][0],'source_id':'S3','title':'Meranie projektu',
|
||||||
|
'document_path':raw['sources'][0]['document_path'].replace('README.md','meranie/README.md'),
|
||||||
|
'text':'Sekcia: Diplomová práca 2021\nStav:\nVyhodnotená presnosť vyhľadávania.'}
|
||||||
|
raw['sources'].append(child); raw['context']=build_context_text(raw['sources'])
|
||||||
|
expanded=expand_comparison_sources(db,COMPARE,raw)
|
||||||
|
assert 'Vyhodnotená presnosť vyhľadávania.' in expanded['context']
|
||||||
|
|
||||||
|
|
||||||
|
def test_comparison_separates_goals_and_results(comparison_payload):
|
||||||
|
db,raw=comparison_payload; payload=compact_rag_payload(expand_comparison_sources(db,COMPARE,raw))
|
||||||
|
c=rag_evidence_context(COMPARE,payload); selected=selected_comparison(c)
|
||||||
|
assert rag_answer_issues(selected,c)==[]
|
||||||
|
result=generate_verified_rag(COMPARE,payload,lambda _: {'choices':[{'message':{'content':json.dumps(selected)}}]})
|
||||||
|
assert result['accepted_for_delivery']
|
||||||
|
text=result['answer']
|
||||||
|
assert text.count('**Zadanie a ciele:**')==text.count('**Zaznamenaný stav a výsledky:**')==2
|
||||||
|
assert 'Natrénovať jazykový model metódou BERT.' in text
|
||||||
|
assert 'Natrénovaný model wiki103' in text and 'Implementovaný PageRank' in text
|
||||||
|
assert 'Cassandra' not in text and len(result['source_urls'])==2
|
||||||
|
|
||||||
|
|
||||||
|
def test_goals_cannot_replace_available_results(comparison_payload):
|
||||||
|
db,raw=comparison_payload; c=rag_evidence_context(COMPARE,expand_comparison_sources(db,COMPARE,raw))
|
||||||
|
a=selected_comparison(c)
|
||||||
|
a['evidence_quotes']=[q for q in a['evidence_quotes'] if not any(
|
||||||
|
word in q['quote'] for word in ['Implementovaný','Natrénovaný'])]
|
||||||
|
assert 'missing_progress_evidence' in rag_answer_issues(a,c)
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_results_allow_an_explicit_partial_comparison(comparison_payload):
|
||||||
|
db,raw=comparison_payload
|
||||||
|
with sqlite3.connect(db) as conn:
|
||||||
|
conn.execute("UPDATE chunks SET text='Zadanie:\n- Implementujte systém vyhľadávania.' WHERE document_path LIKE '%jan_holp%' AND chunk_index=0")
|
||||||
|
payload=expand_comparison_sources(db,COMPARE,raw); c=rag_evidence_context(COMPARE,payload)
|
||||||
|
selected=selected_comparison(c)
|
||||||
|
result=generate_verified_rag(COMPARE,payload,lambda _: {'choices':[{'message':{'content':json.dumps(selected)}}]})
|
||||||
|
assert result['accepted_for_delivery']
|
||||||
|
assert 'nie sú dosiahnuté výsledky doložené' in result['answer']
|
||||||
|
assert 'Implementácia systému vyhľadávania' in result['answer']
|
||||||
|
|
||||||
|
|
||||||
|
def test_future_tasks_are_not_presented_as_achieved_results(comparison_payload):
|
||||||
|
db,raw=comparison_payload
|
||||||
|
with sqlite3.connect(db) as conn:
|
||||||
|
conn.execute("UPDATE chunks SET text=text || '\nÚlohy:\n- Pripravte experiment s PageRank.' WHERE document_path LIKE '%jan_holp%' AND chunk_index=0")
|
||||||
|
payload=expand_comparison_sources(db,COMPARE,raw); c=rag_evidence_context(COMPARE,payload)
|
||||||
|
selected=selected_comparison(c)
|
||||||
|
p=next(p for p in c['content_passages'] if p['person']=='Ján Holp')
|
||||||
|
selected['evidence_quotes'].append({'source_id':p['source_id'],'quote':'- Pripravte experiment s PageRank.'})
|
||||||
|
result=generate_verified_rag(COMPARE,payload,lambda _: {'choices':[{'message':{'content':json.dumps(selected)}}]})
|
||||||
|
assert result['accepted_for_delivery']
|
||||||
|
assert '**Ďalšie doložené úlohy (plán):**' in result['answer']
|
||||||
|
assert 'Pripraviť experiment s PageRank.' in result['answer']
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def shared_session():
|
||||||
|
catalog=[]; theses=[]; works=[]
|
||||||
|
for pid,person,slug in [('p1','Ján Holp','jan'),('p2','Ondrej Megela','ondrej')]:
|
||||||
|
path=f'pages/students/2018/{slug}/README.md'; url=f'https://example.test/{slug}'
|
||||||
|
catalog.append({'element_id':pid,'kind':'Person','name':person,'source_document':path})
|
||||||
|
base={'person_element_id':pid,'person':person,'document_path':path,'source_url':url}
|
||||||
|
theses.append({**base,'thesis_element_id':pid+'t','thesis_id':pid+'t','title':'Ukážkový názov',
|
||||||
|
'year':2019 if pid=='p1' else 2020,'heading':'Bakalárska práca', 'thesis_type':'bachelor_thesis'})
|
||||||
|
works.append({**base,'work_element_id':pid+'w','work_id':pid+'w','year':2020,
|
||||||
|
'heading':'Diplomový projekt','work_type':'diploma_project'})
|
||||||
|
return FakeSession({gs.CATALOG_QUERY:catalog,gs.THESIS_FACTS_QUERY:theses,gs.WORK_FACTS_QUERY:works})
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('word',['obaja','obidvaja'])
|
||||||
|
def test_both_means_shared_years(word):
|
||||||
|
c=gs.extract_query_constraints(SHARED.replace('obaja',word))
|
||||||
|
assert c['mentions_shared'] and c['mentions_academic_year']
|
||||||
|
|
||||||
|
|
||||||
|
def test_shared_year_has_only_intersection_and_bound_work_details(shared_session):
|
||||||
|
result=gs.search_graph_with_session(shared_session,SHARED)
|
||||||
|
assert result['academic_year_comparison']['shared_years']==[2020]
|
||||||
|
c=graph_context(result); records=c['required_academic_records']
|
||||||
|
assert len(records)==3 and all(r['year']==2020 for r in records)
|
||||||
|
a={'status':'ok','claims':records,'source_urls':[s['source_url'] for s in c['sources']]}
|
||||||
|
rendered=render_graph_answer(a,c)
|
||||||
|
assert answer_context_issues(rendered,c)==[]
|
||||||
|
assert 'Spoločný evidovaný rok je **2020**.' in rendered['answer']
|
||||||
|
assert '2019' not in rendered['answer'] and 'Ukážkový názov' in rendered['answer']
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_common_year_is_not_a_union(shared_session):
|
||||||
|
for row in shared_session.responses[gs.WORK_FACTS_QUERY]:
|
||||||
|
if row['person_element_id']=='p1': row['year']=2021
|
||||||
|
result=gs.search_graph_with_session(shared_session,SHARED)
|
||||||
|
assert result['status']=='no_answer' and result['academic_year_comparison']['shared_years']==[]
|
||||||
|
c=graph_context(result)
|
||||||
|
assert 'spoločný rok nenašiel' in render_graph_answer({'status':'no_answer','claims':[],'source_urls':[]},c)['answer']
|
||||||
|
|
||||||
|
|
||||||
|
def test_partial_records_do_not_claim_an_unwitnessed_common_year(shared_session):
|
||||||
|
result=gs.search_graph_with_session(shared_session,SHARED,limit=1); c=graph_context(result)
|
||||||
|
a={'status':'ok','claims':c['required_academic_records'],'source_urls':[s['source_url'] for s in c['sources']]}
|
||||||
|
text=render_graph_answer(a,c)['answer']
|
||||||
|
assert 'Výpis je neúplný.' in text and 'Spoločný evidovaný rok je' not in text
|
||||||
@ -133,13 +133,26 @@ def prose_issues(answer: dict) -> list[str]:
|
|||||||
return ['unsupported_prose_number'] if unsupported else []
|
return ['unsupported_prose_number'] if unsupported else []
|
||||||
|
|
||||||
|
|
||||||
def text_contains_value(text: str, value: str, *, person: bool = False) -> bool:
|
def person_token_pattern(token: str) -> str:
|
||||||
value = normalized(value); text = normalized(text)
|
"""Bounded case endings of a complete name token, never fuzzy prefixes."""
|
||||||
if not person: return value in text
|
token = normalized(token)
|
||||||
suffix = r'(?:a|u|ovi|om|e|ou|y|ovej|oveho|ym|emu)?'
|
suffix = r'(?:a|u|ovi|om|e|ou|y|ovej|oveho|ym|emu)?'
|
||||||
pattern = r'\b' + r'\s+'.join(re.escape(token) + (suffix if token.isalpha() else '')
|
variants = [re.escape(token) + (suffix if token.isalpha() else '')]
|
||||||
for token in value.split()) + r'\b'
|
if token.isalpha() and len(token) >= 3:
|
||||||
return bool(re.search(pattern, text))
|
if token.endswith('a'):
|
||||||
|
variants.append(re.escape(token[:-1]) + r'(?:u|y|e|ou|ovi|om|ej)')
|
||||||
|
elif token.endswith('y'):
|
||||||
|
variants.append(re.escape(token[:-1]) + r'(?:eho|emu|om|ym)')
|
||||||
|
return '(?:' + '|'.join(variants) + ')'
|
||||||
|
|
||||||
|
|
||||||
|
def person_pattern(person: str) -> str:
|
||||||
|
return r'\b' + r'\s+'.join(person_token_pattern(token) for token in normalized(person).split()) + r'\b'
|
||||||
|
|
||||||
|
|
||||||
|
def text_contains_value(text: str, value: str, *, person: bool = False) -> bool:
|
||||||
|
if not person: return normalized(value) in normalized(text)
|
||||||
|
return bool(re.search(person_pattern(value), normalized(text)))
|
||||||
|
|
||||||
|
|
||||||
def _same_claim_value(key: str, actual, expected) -> bool:
|
def _same_claim_value(key: str, actual, expected) -> bool:
|
||||||
|
|||||||
@ -6,6 +6,7 @@ Title je názov akademickej práce/projektu, nikdy názov profilovej stránky al
|
|||||||
Všetky podmienky jednej claim patria tej istej osobe. Title a year patria rovnakému akademickému záznamu. Category a document_author musia byť doložené tým istým document_path.
|
Všetky podmienky jednej claim patria tej istej osobe. Title a year patria rovnakému akademickému záznamu. Category a document_author musia byť doložené tým istým document_path.
|
||||||
Tag patrí osobe cez jej profil aj dcérske dokumenty. Pre kombináciu názvu/roku a tagu nevyžaduj rovnaký dokument. Answer_candidates sú už doložené spojenia cez tú istú osobu; zachovaj ich person, year, keyword a prípadne title spolu v jednej claim. Neodmietaj toto spojenie len pre odlišné document_path.
|
Tag patrí osobe cez jej profil aj dcérske dokumenty. Pre kombináciu názvu/roku a tagu nevyžaduj rovnaký dokument. Answer_candidates sú už doložené spojenia cez tú istú osobu; zachovaj ich person, year, keyword a prípadne title spolu v jednej claim. Neodmietaj toto spojenie len pre odlišné document_path.
|
||||||
Claims musia pokryť všetky relevantné výsledky dotazu a požadované väzby. Pri porovnaní ľudí uveď každého v samostatnej claim. Pri výbere kandidátov vynechaj nevyhovujúce osoby. Pri názve práce uveď aj rok, ak je vo faktoch známy.
|
Claims musia pokryť všetky relevantné výsledky dotazu a požadované väzby. Pri porovnaní ľudí uveď každého v samostatnej claim. Pri výbere kandidátov vynechaj nevyhovujúce osoby. Pri názve práce uveď aj rok, ak je vo faktoch známy.
|
||||||
|
Academic_year_comparison opisuje spoločné roky všetkých požadovaných osôb, nie zjednotenie ich rokov. Pri dodaných required_academic_records zachovaj aj typy, názvy a identity prác z týchto rokov. Nepridávaj práce z iných rokov.
|
||||||
Source_urls musí obsahovať konkrétne relevantné source_url zo sources pre všetky tvrdenia. Nevymýšľaj ani neupravuj URL. Ak názov/rok dokladá profil a tag dcérsky dokument, cituj oba potrebné zdroje. Nepoužívaj URL iných osôb ako náhradnú citáciu.
|
Source_urls musí obsahovať konkrétne relevantné source_url zo sources pre všetky tvrdenia. Nevymýšľaj ani neupravuj URL. Ak názov/rok dokladá profil a tag dcérsky dokument, cituj oba potrebné zdroje. Nepoužívaj URL iných osôb ako náhradnú citáciu.
|
||||||
Count môže byť číslo iba pri aggregation.complete=true a musí sa rovnať aggregation.count. Inak count=null, aj pri no_answer. Počet nie je počet zobrazených zdrojov. Presný agregovaný počet môže byť úplný aj pri orezanom výpise dôkazov.
|
Count môže byť číslo iba pri aggregation.complete=true a musí sa rovnať aggregation.count. Inak count=null, aj pri no_answer. Počet nie je počet zobrazených zdrojov. Presný agregovaný počet môže byť úplný aj pri orezanom výpise dôkazov.
|
||||||
Pri status kontextu no_answer vráť status=no_answer, claims=[], count=null. Pri ambiguous alebo error zachovaj tento status a prázdne claims. Nevymýšľaj pozitívnu odpoveď. Nulová úplná agregácia so status=ok je platný výsledok s count=0.
|
Pri status kontextu no_answer vráť status=no_answer, claims=[], count=null. Pri ambiguous alebo error zachovaj tento status a prázdne claims. Nevymýšľaj pozitívnu odpoveď. Nulová úplná agregácia so status=ok je platný výsledok s count=0.
|
||||||
|
|||||||
@ -9,7 +9,8 @@ def graph_context(result: dict) -> dict:
|
|||||||
Values/subjects/source paths are copied without inventing or rewriting facts.
|
Values/subjects/source paths are copied without inventing or rewriting facts.
|
||||||
"""
|
"""
|
||||||
facts = []; intent = result['constraints']
|
facts = []; intent = result['constraints']
|
||||||
work_listing = intent.get('mentions_work_listing', False)
|
shared_year_query = intent.get('mentions_shared') and intent.get('mentions_academic_year')
|
||||||
|
work_listing = intent.get('mentions_work_listing', False) or shared_year_query
|
||||||
academic_records = []
|
academic_records = []
|
||||||
for row in result['facts']:
|
for row in result['facts']:
|
||||||
kind = row.get('fact_type', '')
|
kind = row.get('fact_type', '')
|
||||||
@ -55,6 +56,10 @@ def graph_context(result: dict) -> dict:
|
|||||||
context['question'] = result.get('query', '')
|
context['question'] = result.get('query', '')
|
||||||
context['requested_people'] = list(dict.fromkeys(row['name']
|
context['requested_people'] = list(dict.fromkeys(row['name']
|
||||||
for row in result.get('resolved_entities', []) if row.get('kind') == 'Person' and row.get('name')))
|
for row in result.get('resolved_entities', []) if row.get('kind') == 'Person' and row.get('name')))
|
||||||
|
comparison = result.get('academic_year_comparison') or next((row for row in result['facts']
|
||||||
|
if row.get('fact_type') == 'academic_year_comparison'), None)
|
||||||
|
if comparison is not None:
|
||||||
|
context['academic_year_comparison'] = comparison
|
||||||
if work_listing:
|
if work_listing:
|
||||||
context['required_academic_records'] = academic_records
|
context['required_academic_records'] = academic_records
|
||||||
return context
|
return context
|
||||||
|
|||||||
@ -7,7 +7,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import re
|
import re
|
||||||
|
|
||||||
from .context_policy import normalized
|
from .context_policy import normalized, person_token_pattern, text_urls
|
||||||
from .rag_evidence import WORK_LABELS
|
from .rag_evidence import WORK_LABELS
|
||||||
|
|
||||||
|
|
||||||
@ -30,9 +30,7 @@ def owner_in_question(person: str, question: str) -> str | None:
|
|||||||
continue
|
continue
|
||||||
valid = True
|
valid = True
|
||||||
for token, match in zip(tokens, found):
|
for token, match in zip(tokens, found):
|
||||||
base = normalized(token)
|
valid &= bool(re.fullmatch(person_token_pattern(token), normalized(match[0])))
|
||||||
variants = {base, base + 'a', base + 'u'}
|
|
||||||
valid &= normalized(match[0]) in variants
|
|
||||||
if valid:
|
if valid:
|
||||||
return question[found[0].start():found[-1].end()]
|
return question[found[0].start():found[-1].end()]
|
||||||
return None
|
return None
|
||||||
@ -100,24 +98,47 @@ GOAL_VERBS = {
|
|||||||
'vyhodnoťte': 'Vyhodnotiť', 'porovnajte': 'Porovnať',
|
'vyhodnoťte': 'Vyhodnotiť', 'porovnajte': 'Porovnať',
|
||||||
'vytvorte': 'Vytvoriť', 'analyzujte': 'Analyzovať',
|
'vytvorte': 'Vytvoriť', 'analyzujte': 'Analyzovať',
|
||||||
'otestujte': 'Otestovať', 'overte': 'Overiť',
|
'otestujte': 'Otestovať', 'overte': 'Overiť',
|
||||||
|
'zistite': 'Zistiť', 'naučte': 'Naučiť', 'zostavte': 'Zostaviť',
|
||||||
|
'využite': 'Využiť', 'zlepšite': 'Zlepšiť', 'použite': 'Použiť',
|
||||||
|
'natrénujte': 'Natrénovať', 'optimalizujte': 'Optimalizovať',
|
||||||
|
'identifikujte': 'Identifikovať', 'preštudujte': 'Preštudovať',
|
||||||
|
'pripravte': 'Pripraviť',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def goal_text(unit: str) -> tuple[str, bool]:
|
def goal_text(unit: str) -> tuple[str, bool]:
|
||||||
"""Only bounded grammatical substitutions; unknown prose stays verbatim."""
|
"""Only bounded grammatical substitutions; unknown prose stays verbatim."""
|
||||||
|
if text_urls(unit) or '`' in unit:
|
||||||
|
# A word inside a URL/code literal must never be conjugated. Preserve
|
||||||
|
# technical excerpts exactly rather than changing their references.
|
||||||
|
return unit.strip(), False
|
||||||
text = re.sub(r'^\s*(?:\d+[.)]|[-+*])\s+', '', unit.strip())
|
text = re.sub(r'^\s*(?:\d+[.)]|[-+*])\s+', '', unit.strip())
|
||||||
for original, replacement in GOAL_NOUNS:
|
for original, replacement in GOAL_NOUNS:
|
||||||
if re.match(re.escape(original) + r'\b', text, re.I):
|
if re.match(re.escape(original) + r'\b', text, re.I):
|
||||||
|
tail = text[len(original):]
|
||||||
|
# Infinitives retain the original object cases when a second
|
||||||
|
# command or coordinated object makes a noun substitution unsafe.
|
||||||
|
if (re.search(r'\b(?:' + '|'.join(map(re.escape, GOAL_VERBS)) + r')\b', tail, re.I)
|
||||||
|
or re.search(r'\ba\s+(?:spôsoby|metódy|aplikácie|systémy|experimenty|možnosti)\b', tail, re.I)):
|
||||||
|
break
|
||||||
result = replacement + text[len(original):]
|
result = replacement + text[len(original):]
|
||||||
result = result.replace('v ktorom využijete', 'v ktorom sa má využiť')
|
result = result.replace('v ktorom využijete', 'v ktorom sa má využiť')
|
||||||
result = result.replace(', v ktorých vyhodnotíte', ' s cieľom vyhodnotiť')
|
result = result.replace(', v ktorých vyhodnotíte', ' s cieľom vyhodnotiť')
|
||||||
result = result.replace('v ktorých vyhodnotíte', 's cieľom vyhodnotiť')
|
result = result.replace('v ktorých vyhodnotíte', 's cieľom vyhodnotiť')
|
||||||
return result, True
|
return result, True
|
||||||
first = text.split(maxsplit=1)[0].casefold() if text else ''
|
match = re.match(r'[^\W\d_]+', text)
|
||||||
if first in GOAL_VERBS:
|
first = match[0].casefold() if match else ''
|
||||||
|
if first in GOAL_VERBS or (first == 'na' and text.casefold().startswith('na základe ')
|
||||||
|
and re.search(r'\b(?:' + '|'.join(map(re.escape, GOAL_VERBS)) + r')\b', text, re.I)):
|
||||||
|
def infinitive(match):
|
||||||
|
word = GOAL_VERBS[match[0].casefold()]
|
||||||
|
prefix = text[:match.start()].rstrip()
|
||||||
|
return word if not prefix or prefix[-1] in '.!?' else word.lower()
|
||||||
result = re.sub(r'\b(' + '|'.join(map(re.escape, GOAL_VERBS)) + r')\b',
|
result = re.sub(r'\b(' + '|'.join(map(re.escape, GOAL_VERBS)) + r')\b',
|
||||||
lambda match: GOAL_VERBS[match[0].casefold()].lower(), text, flags=re.I)
|
infinitive, text, flags=re.I)
|
||||||
return result[0].upper() + result[1:], True
|
return result[0].upper() + result[1:], True
|
||||||
|
if first in {value.casefold() for value in GOAL_VERBS.values()}:
|
||||||
|
return text, True
|
||||||
return unit.strip(), False
|
return unit.strip(), False
|
||||||
|
|
||||||
|
|
||||||
@ -127,12 +148,18 @@ def content_lines(units: list[str], person: str | None, kind: str | None,
|
|||||||
# A task heading has no factual content of its own and otherwise prevents
|
# A task heading has no factual content of its own and otherwise prevents
|
||||||
# a whole assignment quote from being presented as individual goals.
|
# a whole assignment quote from being presented as individual goals.
|
||||||
units = [unit for unit in units if normalized(unit) not in {
|
units = [unit for unit in units if normalized(unit) not in {
|
||||||
'zadanie', 'zadanie diplomovej prace', 'zadanie bakalarskej prace', 'ciele prace'}]
|
'zadanie', 'zadanie diplomovej prace', 'zadanie bakalarskej prace',
|
||||||
|
'ciele prace', 'ciele na semester', 'navrh na zadanie', 'navrh zadania'}]
|
||||||
converted = [goal_text(unit) for unit in units]
|
converted = [goal_text(unit) for unit in units]
|
||||||
subject = work_subject(person, kind, question) if person else 'Práca'
|
subject = work_subject(person, kind, question) if person else 'Práca'
|
||||||
if converted and all(is_goal for _, is_goal in converted):
|
goals = [text for text, is_goal in converted if is_goal]
|
||||||
return [subject + ' má podľa dostupného zadania tieto ciele:', '',
|
other = [text for text, is_goal in converted if not is_goal]
|
||||||
*[f'{index}. {text}' for index, (text, _) in enumerate(converted, 1)]]
|
if goals:
|
||||||
|
lines = [subject + ' má podľa dostupných podkladov tieto ciele:', '',
|
||||||
|
*[f'{index}. {text}' for index, text in enumerate(goals, 1)]]
|
||||||
|
if other:
|
||||||
|
lines.extend(['', 'Ďalšie podklady:', *other])
|
||||||
|
return lines
|
||||||
return [subject + ' je v dostupných podkladoch opísaná takto:' if not (kind or '').endswith('_project')
|
return [subject + ' je v dostupných podkladoch opísaná takto:' if not (kind or '').endswith('_project')
|
||||||
else subject + ' je v dostupných podkladoch opísaný takto:', '', *units]
|
else subject + ' je v dostupných podkladoch opísaný takto:', '', *units]
|
||||||
|
|
||||||
|
|||||||
@ -6,7 +6,10 @@ Taxonomy years are deliberately not treated as years of academic works.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
import re
|
||||||
from .context_policy import normalized, text_contains_value
|
import json
|
||||||
|
import sqlite3
|
||||||
|
from contextlib import closing
|
||||||
|
from .context_policy import normalized, text_contains_value, person_pattern
|
||||||
|
|
||||||
WORK_TYPES = {
|
WORK_TYPES = {
|
||||||
'bakalarska praca': 'bachelor_thesis', 'diplomova praca': 'diploma_thesis',
|
'bakalarska praca': 'bachelor_thesis', 'diplomova praca': 'diploma_thesis',
|
||||||
@ -102,10 +105,9 @@ def requested_types(question: str, people: list[str]) -> dict[str, str]:
|
|||||||
result[names[0]] = next(iter(kinds))
|
result[names[0]] = next(iter(kinds))
|
||||||
if len(result) == len(people):
|
if len(result) == len(people):
|
||||||
return result
|
return result
|
||||||
suffix = r'(?:a|u|ovi|om|e|ou|y|ovej|oveho|ym|emu)?'
|
|
||||||
positions = []
|
positions = []
|
||||||
for person in people:
|
for person in people:
|
||||||
pattern = r'\b' + r'\s+'.join(re.escape(token) + suffix for token in normalized(person).split()) + r'\b'
|
pattern = person_pattern(person)
|
||||||
positions.extend((m.start(), m.end(), person) for m in re.finditer(pattern, text))
|
positions.extend((m.start(), m.end(), person) for m in re.finditer(pattern, text))
|
||||||
for start, end, kind in types:
|
for start, end, kind in types:
|
||||||
distances = [(max(start - right, left - end, 0), person) for left, right, person in positions]
|
distances = [(max(start - right, left - end, 0), person) for left, right, person in positions]
|
||||||
@ -209,6 +211,105 @@ def academic_content_passages(blocks: list[dict], facts: list[dict], requirement
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def content_sections(text: str) -> list[dict]:
|
||||||
|
"""Separate explicit source goals from recorded progress, without inference."""
|
||||||
|
sections = []; lines = []; kind = 'other'
|
||||||
|
def flush():
|
||||||
|
if lines and '\n'.join(lines).strip():
|
||||||
|
sections.append({'kind': kind, 'text': '\n'.join(lines).strip()})
|
||||||
|
lines.clear()
|
||||||
|
for line in text.splitlines():
|
||||||
|
value = normalized(line)
|
||||||
|
label = line.strip().strip('*_# ').rstrip(':').strip()
|
||||||
|
heading = value == normalized(label) and len(value.split()) <= 8
|
||||||
|
next_kind = None
|
||||||
|
if heading and value in {'ciel', 'ciele', 'ciele na semester', 'ciele prace', 'zadanie',
|
||||||
|
'zadanie diplomovej prace', 'zadanie bakalarskej prace',
|
||||||
|
'zadanie diplomoveho projektu', 'zadanie bakalarskeho projektu',
|
||||||
|
'navrh na zadanie', 'navrh zadania'}:
|
||||||
|
next_kind = 'assignment'
|
||||||
|
elif heading and value in {'stav', 'vysledky', 'dosiahnute vysledky', 'dosiahnuty stav'}:
|
||||||
|
next_kind = 'progress'
|
||||||
|
elif heading and value in {'ulohy', 'zasobnik uloh', 'dalsie ulohy'}:
|
||||||
|
next_kind = 'task'
|
||||||
|
elif value.startswith(('stretnutie ', 'virtualne stretnutie ', 'sekcia ')):
|
||||||
|
next_kind = 'other'
|
||||||
|
if next_kind is not None:
|
||||||
|
flush(); kind = next_kind
|
||||||
|
else:
|
||||||
|
lines.append(line)
|
||||||
|
flush()
|
||||||
|
return sections
|
||||||
|
|
||||||
|
|
||||||
|
def quote_sections(quote: dict, context: dict) -> list[dict]:
|
||||||
|
"""Classify only literal excerpts bound to the delivered academic passage."""
|
||||||
|
full = [section for passage in context.get('content_passages', [])
|
||||||
|
if passage['source_id'] == quote['source_id'] and
|
||||||
|
' '.join(quote['quote'].split()) in ' '.join(passage['text'].split())
|
||||||
|
for section in content_sections(passage['text'])]
|
||||||
|
pieces = content_sections(quote['quote'])
|
||||||
|
for piece in pieces:
|
||||||
|
if piece['kind'] == 'other':
|
||||||
|
kinds = {section['kind'] for section in full
|
||||||
|
if ' '.join(piece['text'].split()) in ' '.join(section['text'].split())}
|
||||||
|
if len(kinds) == 1:
|
||||||
|
piece['kind'] = kinds.pop()
|
||||||
|
return pieces
|
||||||
|
|
||||||
|
|
||||||
|
def expand_comparison_sources(db_file, question: str, payload: dict) -> dict:
|
||||||
|
"""Read requested academic sections from the same SQLite snapshot.
|
||||||
|
|
||||||
|
Multi-person comparisons need each person's requested work, not the single
|
||||||
|
global section intent used by ordinary retrieval expansion. The caller
|
||||||
|
holds index_snapshot; the normal shared LLM budget is applied afterwards.
|
||||||
|
"""
|
||||||
|
context = rag_evidence_context(question, payload)
|
||||||
|
req = context['requirements']
|
||||||
|
if not req.get('content') or len(req['people']) < 2:
|
||||||
|
return payload
|
||||||
|
from tools.rag.rag_utils import build_context_text
|
||||||
|
selected = []; changed = False
|
||||||
|
families = [source['document_path'].removesuffix('README.md') for source in payload.get('sources', [])
|
||||||
|
if source.get('title') in req['people'] and PROFILE.fullmatch(source.get('document_path', ''))]
|
||||||
|
with closing(sqlite3.connect(db_file.resolve().as_uri() + '?mode=ro', uri=True)) as conn:
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
columns = {row[1] for row in conn.execute('PRAGMA table_info(chunks)')}
|
||||||
|
if not {'text', 'heading_paths_json', 'document_path', 'published', 'chunk_index'} <= columns:
|
||||||
|
return payload
|
||||||
|
for source in payload.get('sources', []):
|
||||||
|
person = source.get('title')
|
||||||
|
if person not in req['people'] or not PROFILE.fullmatch(source.get('document_path', '')):
|
||||||
|
if any(source.get('document_path', '').startswith(prefix) for prefix in families):
|
||||||
|
selected.append(source)
|
||||||
|
continue
|
||||||
|
frames = []; seen = set()
|
||||||
|
rows = conn.execute('''SELECT text, heading_paths_json FROM chunks
|
||||||
|
WHERE document_path=? AND COALESCE(published,1)=1 ORDER BY chunk_index''',
|
||||||
|
(source['document_path'],))
|
||||||
|
for row in rows:
|
||||||
|
paths = json.loads(row['heading_paths_json'] or '[]')
|
||||||
|
headings = [' > '.join(path) if isinstance(path, list) else str(path) for path in paths]
|
||||||
|
first = headings[0] if headings else ''
|
||||||
|
for scope, text in academic_windows('Sekcia: ' + first + '\n' + row['text']):
|
||||||
|
if not scope or (req['work_types'].get(person) and scope[0] != req['work_types'][person]):
|
||||||
|
continue
|
||||||
|
if req['unsupported_year'] or (req['years'] and scope[1] not in req['years']):
|
||||||
|
continue
|
||||||
|
if not descriptive_text(text) or (scope, text) in seen:
|
||||||
|
continue
|
||||||
|
seen.add((scope, text))
|
||||||
|
frames.append('Sekcia: ' + WORK_LABELS[scope[0]] +
|
||||||
|
(f' {scope[1]}' if scope[1] is not None else '') + '\n' + text)
|
||||||
|
selected.append({**source, **({'text': '\n\n'.join(frames)} if frames else {})})
|
||||||
|
changed |= bool(frames)
|
||||||
|
if not changed:
|
||||||
|
return payload
|
||||||
|
selected = [{**source, 'source_id': f'S{index}'} for index, source in enumerate(selected, 1)]
|
||||||
|
return {**payload, 'sources': selected, 'source_count': len(selected), 'context': build_context_text(selected)}
|
||||||
|
|
||||||
|
|
||||||
def rag_evidence_context(question: str, payload: dict) -> dict:
|
def rag_evidence_context(question: str, payload: dict) -> dict:
|
||||||
blocks = source_blocks(payload) if payload.get('sources') else []
|
blocks = source_blocks(payload) if payload.get('sources') else []
|
||||||
facts = []
|
facts = []
|
||||||
@ -316,6 +417,8 @@ def rag_evidence_context(question: str, payload: dict) -> dict:
|
|||||||
from .answer_coverage import response_scope
|
from .answer_coverage import response_scope
|
||||||
requirements['content_style'] = response_scope(question, content=True)
|
requirements['content_style'] = response_scope(question, content=True)
|
||||||
requirements['response_mode'] = 'mixed' if any(requirements[key] for key in ['title', 'year', 'start_year']) else 'quotes'
|
requirements['response_mode'] = 'mixed' if any(requirements[key] for key in ['title', 'year', 'start_year']) else 'quotes'
|
||||||
|
requirements['distinguish_progress'] = bool(re.search(r'\b(?:zadani\w*|ciel\w*)\b', query)
|
||||||
|
and re.search(r'\b(?:vysledk\w*|dosiahn\w*|stav\w*)\b', query))
|
||||||
stated_years = re.findall(r'\b(?:v roku|z roku|pre rok|na rok|roku|rok)\s+(\d+)\b', query) if academic else []
|
stated_years = re.findall(r'\b(?:v roku|z roku|pre rok|na rok|roku|rok)\s+(\d+)\b', query) if academic else []
|
||||||
requirements['years'] = ([int(value) for value in stated_years if len(value) == 4]
|
requirements['years'] = ([int(value) for value in stated_years if len(value) == 4]
|
||||||
if not requirements['start_year'] else [])
|
if not requirements['start_year'] else [])
|
||||||
@ -328,6 +431,12 @@ def rag_evidence_context(question: str, payload: dict) -> dict:
|
|||||||
'aggregation': None, 'answer_candidates': []}
|
'aggregation': None, 'answer_candidates': []}
|
||||||
if content:
|
if content:
|
||||||
context['content_passages'] = academic_content_passages(blocks, facts, requirements, question)
|
context['content_passages'] = academic_content_passages(blocks, facts, requirements, question)
|
||||||
|
if requirements['distinguish_progress']:
|
||||||
|
requirements['content_roles'] = {person: sorted({section['kind']
|
||||||
|
for p in context['content_passages'] if p['person'] == person
|
||||||
|
for section in content_sections(p['text'])
|
||||||
|
if section['kind'] in {'assignment', 'progress'} and descriptive_text(section['text'])})
|
||||||
|
for person in requested}
|
||||||
return context
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -10,6 +10,18 @@ def render_graph_answer(answer: dict, context: dict) -> dict:
|
|||||||
out = deepcopy(answer)
|
out = deepcopy(answer)
|
||||||
lines = []
|
lines = []
|
||||||
if out['status'] == 'ok':
|
if out['status'] == 'ok':
|
||||||
|
comparison = context.get('academic_year_comparison')
|
||||||
|
if comparison:
|
||||||
|
people = comparison.get('people', [])
|
||||||
|
witnessed = [{claim['year'] for claim in out['claims']
|
||||||
|
if claim.get('person') == person and claim.get('year') is not None} for person in people]
|
||||||
|
visible = set.intersection(*witnessed) if witnessed else set()
|
||||||
|
years = sorted(visible & set(comparison.get('shared_years', [])))
|
||||||
|
if years:
|
||||||
|
label = ('Spoločný evidovaný rok je' if len(years) == 1 else 'Spoločné evidované roky sú')
|
||||||
|
if context.get('complete') is not True or context.get('truncated'):
|
||||||
|
label = 'Medzi doložené spoločné roky patria'
|
||||||
|
lines.extend([label + ' **' + ', '.join(map(str, years)) + '**.', ''])
|
||||||
records = {}
|
records = {}
|
||||||
for claim in out['claims']:
|
for claim in out['claims']:
|
||||||
if claim.get('record_id'):
|
if claim.get('record_id'):
|
||||||
@ -49,7 +61,10 @@ def render_graph_answer(answer: dict, context: dict) -> dict:
|
|||||||
elif out['status'] == 'error':
|
elif out['status'] == 'error':
|
||||||
lines.append('Vyhľadanie zlyhalo; odpoveď nemožno pripraviť.')
|
lines.append('Vyhľadanie zlyhalo; odpoveď nemožno pripraviť.')
|
||||||
else:
|
else:
|
||||||
lines.append(no_answer_text(context))
|
if context.get('academic_year_comparison') is not None:
|
||||||
|
lines.append('V dostupných podkladoch sa pre zadané podmienky spoločný rok nenašiel.')
|
||||||
|
else:
|
||||||
|
lines.append(no_answer_text(context))
|
||||||
if out['source_urls']:
|
if out['source_urls']:
|
||||||
lines.extend(['', ('Zdroj: ' if len(out['source_urls']) == 1 else 'Zdroje: ') + ', '.join(out['source_urls'])])
|
lines.extend(['', ('Zdroj: ' if len(out['source_urls']) == 1 else 'Zdroje: ') + ', '.join(out['source_urls'])])
|
||||||
out['answer'] = '\n'.join(lines)
|
out['answer'] = '\n'.join(lines)
|
||||||
|
|||||||
@ -9,9 +9,9 @@ from pydantic import BaseModel, ConfigDict, Field
|
|||||||
|
|
||||||
from .answer_format import Claim
|
from .answer_format import Claim
|
||||||
from .context_policy import answer_context_issues, citation_check, allowed_urls, normalized, text_urls
|
from .context_policy import answer_context_issues, citation_check, allowed_urls, normalized, text_urls
|
||||||
from .rag_evidence import descriptive_text, rag_evidence_context
|
from .rag_evidence import descriptive_text, rag_evidence_context, quote_sections
|
||||||
from .answer_coverage import answer_coverage, coverage_notices
|
from .answer_coverage import answer_coverage, coverage_notices
|
||||||
from .presentation import content_lines, fact_sentences, no_answer_text
|
from .presentation import content_lines, fact_sentences, no_answer_text, work_subject
|
||||||
|
|
||||||
|
|
||||||
class RagClaim(Claim):
|
class RagClaim(Claim):
|
||||||
@ -56,6 +56,15 @@ Pri requirements.content_style=overview vyber najviac DVA krátke doslovné vý
|
|||||||
na požadovanú osobu, najviac 600 znakov a dve odrážky/body na osobu. Odpovedaj stručne na obsah;
|
na požadovanú osobu, najviac 600 znakov a dve odrážky/body na osobu. Odpovedaj stručne na obsah;
|
||||||
neprepisuj celé zadanie a históriu stretnutí. Pri content_style=assignment zachovaj
|
neprepisuj celé zadanie a históriu stretnutí. Pri content_style=assignment zachovaj
|
||||||
požadované body zadania; tento stručný limit sa na zadanie nevzťahuje.
|
požadované body zadania; tento stručný limit sa na zadanie nevzťahuje.
|
||||||
|
Pri requirements.distinguish_progress=true porovnaj dostupné podklady každej osoby:
|
||||||
|
vyber samostatne výňatok zadania/cieľov a samostatne výňatok zaznamenaného stavu.
|
||||||
|
requirements.content_roles uvádza, ktoré z týchto podkladov sú naozaj dostupné pre každú osobu.
|
||||||
|
Zadanie nie je dosiahnutý výsledok. Nepíš vlastný opis ani nedoplň chýbajúci výsledok.
|
||||||
|
Aj keď výsledky pre osobu chýbajú, môžeš vrátiť status=ok s doloženým zadaním;
|
||||||
|
aplikácia uvedie, čo chýba. no_answer znamená žiadnu odpoveď a musí mať prázdne evidence_quotes.
|
||||||
|
Pri zaznamenanom stave uprednostni konkrétnu implementáciu, natrénovanie modelu a vykonané
|
||||||
|
vyhodnotenie pred konzultáciami alebo samotným písaním textu. Ani priebežný stav nevydávaj
|
||||||
|
za splnenie všetkých cieľov alebo za konečné výsledky práce.
|
||||||
Každý výňatok musí byť JEDEN súvislý úsek z konkrétneho zdroja vrátane Markdownu.
|
Každý výňatok musí byť JEDEN súvislý úsek z konkrétneho zdroja vrátane Markdownu.
|
||||||
Nespájaj oddelené pasáže cez vynechané riadky; použi viac samostatných kratších quotes.
|
Nespájaj oddelené pasáže cez vynechané riadky; použi viac samostatných kratších quotes.
|
||||||
Vyber iba minimum potrebné na zodpovedanie otázky. Výňatok nesmieš parafrázovať ani meniť čísla.
|
Vyber iba minimum potrebné na zodpovedanie otázky. Výňatok nesmieš parafrázovať ani meniť čísla.
|
||||||
@ -217,6 +226,18 @@ def rag_answer_issues(answer: dict, context: dict) -> list[str]:
|
|||||||
' '.join(quote['quote'].split()) in ' '.join(p['text'].split())
|
' '.join(quote['quote'].split()) in ' '.join(p['text'].split())
|
||||||
for p in passages for quote in quotes):
|
for p in passages for quote in quotes):
|
||||||
issues.append('missing_bound_content_evidence')
|
issues.append('missing_bound_content_evidence')
|
||||||
|
if requirements.get('distinguish_progress'):
|
||||||
|
bound_quotes = [quote for quote in quotes if any(
|
||||||
|
quote['source_id'] == p['source_id'] and
|
||||||
|
' '.join(quote['quote'].split()) in ' '.join(p['text'].split()) for p in passages)]
|
||||||
|
kinds = {section['kind'] for quote in bound_quotes for section in quote_sections(quote, context)
|
||||||
|
if descriptive_text(section['text'])}
|
||||||
|
for kind in requirements.get('content_roles', {}).get(person, []):
|
||||||
|
if kind not in kinds:
|
||||||
|
issues.append('missing_' + kind + '_evidence')
|
||||||
|
if (requirements.get('distinguish_progress') and answer['status'] == 'no_answer' and requested
|
||||||
|
and all(any(p.get('person') == person for p in context.get('content_passages', [])) for person in requested)):
|
||||||
|
issues.append('rejected_supported_content_comparison')
|
||||||
if not citation_check(answer, allowed_urls(context))['citations_ok']:
|
if not citation_check(answer, allowed_urls(context))['citations_ok']:
|
||||||
issues.append('unsupported_citation')
|
issues.append('unsupported_citation')
|
||||||
if (claims or quotes) and not cited:
|
if (claims or quotes) and not cited:
|
||||||
@ -271,6 +292,44 @@ def overview_excerpts(answer: dict, context: dict) -> list[str]:
|
|||||||
return [selection['text'] for selection in _overview_selections(answer, context)]
|
return [selection['text'] for selection in _overview_selections(answer, context)]
|
||||||
|
|
||||||
|
|
||||||
|
def comparison_lines(answer: dict, context: dict) -> list[str]:
|
||||||
|
"""Present each person's goals and recorded state separately, including gaps."""
|
||||||
|
lines = []
|
||||||
|
for person in context['requirements']['people']:
|
||||||
|
kind = context['requirements']['work_types'].get(person)
|
||||||
|
lines.extend([work_subject(person, kind, context['question']), ''])
|
||||||
|
roles = {'assignment': [], 'progress': [], 'task': [], 'other': []}
|
||||||
|
for quote in answer.get('evidence_quotes', []):
|
||||||
|
if not any(p.get('person') == person and p['source_id'] == quote['source_id'] and
|
||||||
|
' '.join(quote['quote'].split()) in ' '.join(p['text'].split())
|
||||||
|
for p in context.get('content_passages', [])):
|
||||||
|
continue
|
||||||
|
for section in quote_sections(quote, context):
|
||||||
|
key = section['kind'] if section['kind'] in roles else 'other'
|
||||||
|
roles[key].append({'source_id': quote['source_id'], 'quote': section['text']})
|
||||||
|
for role, label in [('assignment', 'Zadanie a ciele'), ('progress', 'Zaznamenaný stav a výsledky')]:
|
||||||
|
lines.append('**' + label + ':**')
|
||||||
|
if roles[role]:
|
||||||
|
units = overview_excerpts({'evidence_quotes': roles[role]}, context)
|
||||||
|
if role == 'assignment':
|
||||||
|
lines.extend(content_lines(units, person, kind, context['question'])[1:])
|
||||||
|
else:
|
||||||
|
lines.extend('- ' + re.sub(r'^\s*(?:\d+[.)]|[-+*])\s+', '', unit) for unit in units)
|
||||||
|
else:
|
||||||
|
lines.append('V poskytnutých podkladoch ' +
|
||||||
|
('nie je zadanie doložené.' if role == 'assignment' else 'nie sú dosiahnuté výsledky doložené.'))
|
||||||
|
lines.append('')
|
||||||
|
if roles['task']:
|
||||||
|
lines.append('**Ďalšie doložené úlohy (plán):**')
|
||||||
|
units = overview_excerpts({'evidence_quotes': roles['task']}, context)
|
||||||
|
lines.extend(content_lines(units, person, kind, context['question'])[1:])
|
||||||
|
lines.append('')
|
||||||
|
if roles['other']:
|
||||||
|
lines.extend(['**Ďalšie doložené podklady:**',
|
||||||
|
*overview_excerpts({'evidence_quotes': roles['other']}, context), ''])
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
def render_rag_answer(answer: dict, context: dict) -> str:
|
def render_rag_answer(answer: dict, context: dict) -> str:
|
||||||
lines = []
|
lines = []
|
||||||
if answer['status'] == 'ok':
|
if answer['status'] == 'ok':
|
||||||
@ -278,7 +337,9 @@ def render_rag_answer(answer: dict, context: dict) -> str:
|
|||||||
lines.append(fact_sentences(claim, context.get('question', '')))
|
lines.append(fact_sentences(claim, context.get('question', '')))
|
||||||
quotes = answer.get('evidence_quotes', [])
|
quotes = answer.get('evidence_quotes', [])
|
||||||
style = context.get('requirements', {}).get('content_style')
|
style = context.get('requirements', {}).get('content_style')
|
||||||
if quotes and style in {'overview', 'assignment'}:
|
if quotes and context.get('requirements', {}).get('distinguish_progress'):
|
||||||
|
lines.extend(comparison_lines(answer, context))
|
||||||
|
elif quotes and style in {'overview', 'assignment'}:
|
||||||
# Bind each paragraph to the same source/person/work as the checked
|
# Bind each paragraph to the same source/person/work as the checked
|
||||||
# quotes. Never summarize several people's excerpts under one name.
|
# quotes. Never summarize several people's excerpts under one name.
|
||||||
groups = {}
|
groups = {}
|
||||||
|
|||||||
@ -11,6 +11,8 @@ from tools.graphrag.graph_search import search_graph
|
|||||||
from tools.answer_evaluation.graph_context import graph_context
|
from tools.answer_evaluation.graph_context import graph_context
|
||||||
from tools.answer_evaluation.checked_generation import generate_checked_answer
|
from tools.answer_evaluation.checked_generation import generate_checked_answer
|
||||||
from tools.answer_evaluation.verified_rag import generate_verified_rag
|
from tools.answer_evaluation.verified_rag import generate_verified_rag
|
||||||
|
from tools.answer_evaluation.rag_evidence import expand_comparison_sources
|
||||||
|
from tools.sync.index_state import index_snapshot
|
||||||
from tools.answers.model_client import complete_with_configured_model
|
from tools.answers.model_client import complete_with_configured_model
|
||||||
|
|
||||||
_capacity = threading.BoundedSemaphore(2)
|
_capacity = threading.BoundedSemaphore(2)
|
||||||
@ -24,7 +26,10 @@ def deliver_answer(question: str, *, engine: str = 'rag', limit: int = 5,
|
|||||||
try:
|
try:
|
||||||
model = complete or complete_with_configured_model
|
model = complete or complete_with_configured_model
|
||||||
if engine == 'rag':
|
if engine == 'rag':
|
||||||
retrieved = compact_rag_payload(build_rag_context(db_file, question, limit=limit))
|
with index_snapshot(db_file.parent, db_file=db_file):
|
||||||
|
retrieved = build_rag_context(db_file, question, limit=limit)
|
||||||
|
retrieved = expand_comparison_sources(db_file, question, retrieved)
|
||||||
|
retrieved = compact_rag_payload(retrieved)
|
||||||
result = generate_verified_rag(question, retrieved, model)
|
result = generate_verified_rag(question, retrieved, model)
|
||||||
context = retrieved
|
context = retrieved
|
||||||
elif engine == 'graphrag':
|
elif engine == 'graphrag':
|
||||||
|
|||||||
@ -947,6 +947,7 @@ def extract_query_constraints(
|
|||||||
"rovnak",
|
"rovnak",
|
||||||
)
|
)
|
||||||
or (mentions_academic_year and has_stem(tokens, "spaj"))
|
or (mentions_academic_year and has_stem(tokens, "spaj"))
|
||||||
|
or (mentions_academic_year and bool(token_set & {"obaja", "obidvaja", "obe", "obidve", "vsetci"}))
|
||||||
)
|
)
|
||||||
|
|
||||||
mentions_person_answer = (
|
mentions_person_answer = (
|
||||||
@ -2638,6 +2639,11 @@ def search_graph_with_session(
|
|||||||
"query": clean_query, "strategies": strategies, "constraints": constraints,
|
"query": clean_query, "strategies": strategies, "constraints": constraints,
|
||||||
"resolved_entities": resolved_entities, "paths": paths[:limit], "facts": facts[:limit],
|
"resolved_entities": resolved_entities, "paths": paths[:limit], "facts": facts[:limit],
|
||||||
"documents": documents, "aggregation": aggregation,
|
"documents": documents, "aggregation": aggregation,
|
||||||
|
"academic_year_comparison": ({
|
||||||
|
"shared_years": shared_years,
|
||||||
|
"people": [row["name"] for row in resolved_entities if row.get("kind") == "Person"],
|
||||||
|
"complete": not (entity_limit_reached or fetch_capped),
|
||||||
|
} if shared_years is not None else None),
|
||||||
"checked_entities": resolved_entities,
|
"checked_entities": resolved_entities,
|
||||||
"rejection_evidence": [row for row in facts if row.get("fact_type") in {
|
"rejection_evidence": [row for row in facts if row.get("fact_type") in {
|
||||||
"academic_year_comparison", "start_year_comparison"
|
"academic_year_comparison", "start_year_comparison"
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user