Presnejší výber podkladov
This commit is contained in:
parent
05f96ac9c0
commit
4c8e494560
10
README.md
10
README.md
@ -1517,6 +1517,16 @@ len tieto kandidátske výňatky; ich skutočný JSON podlieha tokenovému rozpo
|
|||||||
Chýbajúce časti sa označia jednotlivo, aj keď sú ostatné časti zodpovedateľné.
|
Chýbajúce časti sa označia jednotlivo, aj keď sú ostatné časti zodpovedateľné.
|
||||||
Pri limite kontextu sa nestratí informácia, ktoré osoby používateľ požadoval.
|
Pri limite kontextu sa nestratí informácia, ktoré osoby používateľ požadoval.
|
||||||
|
|
||||||
|
Výňatky nesú interný `passage_id`, osobu, pôvodné označenie práce a rok. Kontrola
|
||||||
|
odmietne zamenený identifikátor a pri rovnakom texte pod rôznymi prácami vyžaduje
|
||||||
|
jednoznačné priradenie. Číslo projektu odlišuje aj dva projekty rovnakého typu
|
||||||
|
a roku. Zadanie, plánované úlohy, priebežný stav a zaznamenané výsledky sa
|
||||||
|
zobrazujú oddelene; negatívny výsledok zostáva výsledkom. Administratívna poznámka
|
||||||
|
nenahrádza technický výsledok. Porovnávané osoby si delia kontextový rozpočet,
|
||||||
|
aby dlhý prvý profil nevytlačil ostatné. Skrátenie rešpektuje celé odseky a položky
|
||||||
|
zoznamu vrátane ich výhrad; pri stručnom opise má zachovanie podmienky prednosť
|
||||||
|
pred orientačným limitom dĺžky.
|
||||||
|
|
||||||
Rozpoznané, ale nepodporované operátory (napríklad interval rokov, vylučovanie
|
Rozpoznané, ale nepodporované operátory (napríklad interval rokov, vylučovanie
|
||||||
alebo minimálny počet) vedú k `ambiguous` a konkrétnemu spresneniu. Rok práce
|
alebo minimálny počet) vedú k `ambiguous` a konkrétnemu spresneniu. Rok práce
|
||||||
sa nepoužije ako dôkaz dátumu jej začatia či obhajoby. Chybové odpovede odlišujú
|
sa nepoužije ako dôkaz dátumu jej začatia či obhajoby. Chybové odpovede odlišujú
|
||||||
|
|||||||
@ -150,7 +150,7 @@ def test_comparison_separates_goals_and_results(comparison_payload):
|
|||||||
result=generate_verified_rag(COMPARE,payload,lambda _: {'choices':[{'message':{'content':json.dumps(selected)}}]})
|
result=generate_verified_rag(COMPARE,payload,lambda _: {'choices':[{'message':{'content':json.dumps(selected)}}]})
|
||||||
assert result['accepted_for_delivery']
|
assert result['accepted_for_delivery']
|
||||||
text=result['answer']
|
text=result['answer']
|
||||||
assert text.count('**Zadanie a ciele:**')==text.count('**Zaznamenaný stav a výsledky:**')==2
|
assert text.count('**Zadanie a ciele:**')==text.count('**Zaznamenané výsledky:**')==2
|
||||||
assert 'Natrénovať jazykový model metódou BERT.' in text
|
assert 'Natrénovať jazykový model metódou BERT.' in text
|
||||||
assert 'Natrénovaný model wiki103' in text and 'Implementovaný PageRank' 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
|
assert 'Cassandra' not in text and len(result['source_urls'])==2
|
||||||
|
|||||||
238
test/test_evidence_selection.py
Normal file
238
test/test_evidence_selection.py
Normal file
@ -0,0 +1,238 @@
|
|||||||
|
"""Bound excerpts to works, preserve their phase and share comparison space."""
|
||||||
|
import copy
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from test_quality_block import corpus, prepare, complete
|
||||||
|
from tools.answer_evaluation.content_selection import administrative_only, evidence_units
|
||||||
|
from tools.answer_evaluation.rag_evidence import rag_evidence_context, content_sections
|
||||||
|
from tools.answer_evaluation.rag_preparation import prepare_named_content
|
||||||
|
from tools.answer_evaluation.verified_rag import generate_verified_rag, rag_answer_issues
|
||||||
|
from tools.answer_evaluation.verified_rag import overview_excerpts
|
||||||
|
from tools.rag.llm_context import compact_rag_payload
|
||||||
|
from tools.rag.rag_utils import build_context_text
|
||||||
|
|
||||||
|
|
||||||
|
def update_eva(corpus,text,headings=None):
|
||||||
|
db,raw=corpus
|
||||||
|
with sqlite3.connect(db) as conn:
|
||||||
|
conn.execute('UPDATE chunks SET text=?,heading_paths_json=? WHERE document_path=?',
|
||||||
|
(text,json.dumps(headings or [['Eva Nová','Diplomová práca 2024']]),raw['sources'][0]['document_path']))
|
||||||
|
|
||||||
|
|
||||||
|
def selection(context):
|
||||||
|
return {'status':'ok','answer':'','claims':[],
|
||||||
|
'evidence_quotes':[{key:c[key] for key in ['source_id','passage_id'] if c.get(key)}|{'quote':c['text']}
|
||||||
|
for c in context.get('content_candidates',[])],
|
||||||
|
'source_urls':[s['source_url'] for s in context['sources']]}
|
||||||
|
|
||||||
|
|
||||||
|
def test_assignment_plan_progress_and_recorded_result_are_separate(corpus):
|
||||||
|
update_eva(corpus,'Zadanie:\n- Implementujte vyhľadávač.\nPriebežný stav:\n'
|
||||||
|
'- Rozpracovaná integrácia vyhľadávania.\n- Bude implementovaný klasifikátor.\n'
|
||||||
|
'Výsledky:\n- Natrénovaný model BERT.\nPlánované úlohy:\n- Pripravte experiment s presnosťou.')
|
||||||
|
q='Zhrň zadanie, plánované úlohy, priebežný stav a výsledky diplomovej práce Evy Novej.'
|
||||||
|
payload=prepare(corpus,q);context=rag_evidence_context(q,payload)
|
||||||
|
units=evidence_units(context)
|
||||||
|
assert {u['phase'] for u in units}>={'assignment','planned','progress','result'}
|
||||||
|
future=next(u for u in units if 'Bude implementovaný' in u['text'])
|
||||||
|
assert future['phase']=='planned' and 'results' not in future['facets']
|
||||||
|
result=generate_verified_rag(q,payload,complete(selection(context)))
|
||||||
|
assert result['accepted_for_delivery']
|
||||||
|
assert all(label in result['answer'] for label in ['Zadanie a ciele','Plánované úlohy','Priebežný stav','Zaznamenané výsledky'])
|
||||||
|
assert 'Bude implementovaný' not in result['answer'].split('**Zaznamenané výsledky:**')[1]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('heading',['Zadanie','Zadanie diplomovej práce'])
|
||||||
|
def test_phase_from_chunk_heading_ancestry_is_not_lost(corpus,heading):
|
||||||
|
update_eva(corpus,'- Pripravte vyhodnotenie modelu.',
|
||||||
|
[['Eva Nová','Diplomová práca 2024',heading]])
|
||||||
|
q='Aké je zadanie a plánované úlohy diplomovej práce Evy Novej?'
|
||||||
|
c=rag_evidence_context(q,prepare(corpus,q))
|
||||||
|
assert any(u['kind']=='assignment' and u['phase']=='assignment' for u in evidence_units(c))
|
||||||
|
assert not any(u['kind']=='task' for u in evidence_units(c))
|
||||||
|
assert all(u['year']==2024 and u['work_label']=='Diplomová práca 2024' for u in evidence_units(c))
|
||||||
|
|
||||||
|
|
||||||
|
def test_negated_completion_is_progress_not_a_completed_result(corpus):
|
||||||
|
update_eva(corpus,'Stav:\n- Parser zatiaľ nie je implementovaný.')
|
||||||
|
q='Aký je priebežný stav diplomovej práce Evy Novej?'
|
||||||
|
c=rag_evidence_context(q,prepare(corpus,q))
|
||||||
|
row=next(u for u in evidence_units(c) if 'Parser' in u['text'])
|
||||||
|
assert row['phase']=='progress' and 'results' not in row['facets']
|
||||||
|
q='Aké výsledky dosiahla Eva Nová pri diplomovej práci?'
|
||||||
|
r=generate_verified_rag(q,prepare(corpus,q),lambda _: (_ for _ in ()).throw(AssertionError('No results model call')))
|
||||||
|
assert r['status']=='no_answer' and r['accepted_for_delivery']
|
||||||
|
|
||||||
|
|
||||||
|
def test_administration_alone_is_not_a_technical_result(corpus):
|
||||||
|
update_eva(corpus,'Stav:\n- Konzultácia štruktúry práce.\n- Dohodnutý termín odovzdania.')
|
||||||
|
q='Aké výsledky dosiahla Eva Nová pri diplomovej práci?'
|
||||||
|
c=rag_evidence_context(q,prepare(corpus,q))
|
||||||
|
assert not c['content_candidates']
|
||||||
|
assert administrative_only('Konzultácia štruktúry práce.')
|
||||||
|
assert not administrative_only('Vyriešený problém s GPU po konzultácii.')
|
||||||
|
assert not administrative_only('Odovzdaný zdrojový kód indexovania do SQLite.')
|
||||||
|
|
||||||
|
|
||||||
|
def test_recorded_working_system_is_a_result_not_just_administration(corpus):
|
||||||
|
update_eva(corpus,'Stav:\n- Funguje RAG systém, zostáva overiť presnosť.')
|
||||||
|
q='Aké výsledky má diplomová práca Evy Novej?'
|
||||||
|
c=rag_evidence_context(q,prepare(corpus,q))
|
||||||
|
assert any(u['phase']=='result' and 'zostáva' in u['text'] for u in c['content_candidates'])
|
||||||
|
|
||||||
|
|
||||||
|
def test_completed_experiments_are_not_confused_with_planned_tasks(corpus):
|
||||||
|
update_eva(corpus,'Výsledky:\n- Vyhodnotená presnosť modelu v experimente.\n'
|
||||||
|
'Úlohy:\n- Pripravte experiment s novým tokenizérom.')
|
||||||
|
q='Aké vykonané experimenty má Eva Nová a aké plánované úlohy jej zostali?'
|
||||||
|
c=rag_evidence_context(q,prepare(corpus,q))
|
||||||
|
planned=next(u for u in evidence_units(c) if 'Pripravte' in u['text'])
|
||||||
|
assert 'plans' in planned['facets'] and 'experiments' not in planned['facets']
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_planned_method_does_not_prove_an_actually_used_method(corpus):
|
||||||
|
update_eva(corpus,'Úlohy:\n- Použiť metódu BERT.')
|
||||||
|
q='Aké použité metódy má diplomová práca Evy Novej?'
|
||||||
|
c=rag_evidence_context(q,prepare(corpus,q))
|
||||||
|
assert not c['content_candidates']
|
||||||
|
|
||||||
|
|
||||||
|
def test_explicit_negative_finding_remains_a_result(corpus):
|
||||||
|
update_eva(corpus,'Výsledky:\n- Model nebol úspešný pri teste presnosti.')
|
||||||
|
q='Aké výsledky má diplomová práca Evy Novej?'
|
||||||
|
p=prepare(corpus,q);c=rag_evidence_context(q,p)
|
||||||
|
assert c['content_candidates'][0]['phase']=='result'
|
||||||
|
r=generate_verified_rag(q,p,complete(selection(c)))
|
||||||
|
assert r['accepted_for_delivery'] and 'nebol úspešný' in r['answer']
|
||||||
|
|
||||||
|
|
||||||
|
def test_identical_text_in_two_work_years_needs_passage_identity(corpus):
|
||||||
|
_,raw=corpus;source=raw['sources'][0]
|
||||||
|
source['text']='Sekcia: Diplomová práca 2024\nStav:\nImplementovaný parser.\n\n'
|
||||||
|
source['text']+='Sekcia: Diplomová práca 2025\nStav:\nImplementovaný parser.'
|
||||||
|
raw['sources']=[source];raw['context']=build_context_text(raw['sources'])
|
||||||
|
q='Aké výsledky má Eva Nová pri diplomovej práci?';c=rag_evidence_context(q,raw)
|
||||||
|
a={'status':'ok','answer':'','claims':[],'source_urls':[source['source_url']],
|
||||||
|
'evidence_quotes':[{'source_id':'S1','quote':'Implementovaný parser.'}]}
|
||||||
|
assert 'ambiguous_evidence_scope' in rag_answer_issues(a,c)
|
||||||
|
p=c['content_passages'][0];a['evidence_quotes'][0]['passage_id']=p['passage_id']
|
||||||
|
assert 'ambiguous_evidence_scope' not in rag_answer_issues(a,c)
|
||||||
|
assert len({p['passage_id'] for p in c['content_passages']})==2
|
||||||
|
|
||||||
|
|
||||||
|
def test_identical_text_cannot_reuse_another_persons_passage_id(corpus):
|
||||||
|
for person_name in ['eva_nova','boris_novy']:
|
||||||
|
db,_=corpus
|
||||||
|
with sqlite3.connect(db) as conn:
|
||||||
|
conn.execute('UPDATE chunks SET text=? WHERE document_path LIKE ?',
|
||||||
|
('Stav:\nImplementovaný parser.',f'%{person_name}%'))
|
||||||
|
q='Porovnaj výsledky Evy Novej a Borisa Nového.';p=prepare(corpus,q);c=rag_evidence_context(q,p)
|
||||||
|
a=selection(c)
|
||||||
|
assert not rag_answer_issues(a,c)
|
||||||
|
wrong=copy.deepcopy(a)
|
||||||
|
wrong['evidence_quotes'][0]['passage_id']=wrong['evidence_quotes'][1]['passage_id']
|
||||||
|
assert 'unsupported_evidence_passage' in rag_answer_issues(wrong,c)
|
||||||
|
|
||||||
|
|
||||||
|
def test_overlapping_excerpt_preserves_qualification_without_repetition(corpus):
|
||||||
|
update_eva(corpus,'Stav:\n- Natrénovaný model BERT.\n'
|
||||||
|
'- Natrénovaný model BERT. Výsledok platí iba pre malý korpus.')
|
||||||
|
q='Aké výsledky dosiahla Eva Nová?';p=prepare(corpus,q);c=rag_evidence_context(q,p)
|
||||||
|
r=generate_verified_rag(q,p,complete(selection(c)))
|
||||||
|
assert r['accepted_for_delivery']
|
||||||
|
assert r['answer'].count('Natrénovaný model BERT')==1 and 'iba pre malý korpus' in r['answer']
|
||||||
|
|
||||||
|
|
||||||
|
def add_same_year_projects(corpus):
|
||||||
|
db,raw=corpus
|
||||||
|
with sqlite3.connect(db) as conn:
|
||||||
|
for i,method in [(1,'BERT'),(2,'Cassandra')]:
|
||||||
|
conn.execute('INSERT INTO chunks VALUES(?,?,?,?,?)',
|
||||||
|
(raw['sources'][0]['document_path'],f'Stav:\nImplementovaná metóda {method}.',
|
||||||
|
json.dumps([['Eva Nová',f'Diplomový projekt {i} 2022']]),1,i))
|
||||||
|
|
||||||
|
|
||||||
|
def test_same_year_project_records_are_not_merged(corpus):
|
||||||
|
add_same_year_projects(corpus)
|
||||||
|
p=prepare(corpus,'Aké metódy používala Eva Nová pri diplomovom projekte v roku 2022?')
|
||||||
|
assert p['status']=='ambiguous' and 'projekt 1' in p['clarification'] and 'projekt 2' in p['clarification']
|
||||||
|
|
||||||
|
|
||||||
|
def test_explicit_project_number_binds_the_excerpt_to_that_project(corpus):
|
||||||
|
add_same_year_projects(corpus)
|
||||||
|
q='Aké metódy používala Eva Nová pri diplomovom projekte 2 v roku 2022?'
|
||||||
|
p=prepare(corpus,q);c=rag_evidence_context(q,p)
|
||||||
|
assert p['status']=='ok' and 'Cassandra' in p['context'] and 'BERT' not in p['context']
|
||||||
|
assert all('projekt 2' in u['work_label'] for u in evidence_units(c))
|
||||||
|
a=selection(c);a['claims']=[{'person':'Eva Nová','work_type':'diploma_project','year':2022}]
|
||||||
|
result=generate_verified_rag(q,p,complete(a))
|
||||||
|
assert result['accepted_for_delivery'] and 'projekt 2' in result['answer']
|
||||||
|
|
||||||
|
|
||||||
|
def test_large_first_profile_cannot_starve_the_second_person(corpus):
|
||||||
|
db,raw=corpus;q='Porovnaj výsledky Evy Novej a Borisa Nového.'
|
||||||
|
p=prepare_named_content(db,q,raw)
|
||||||
|
p['sources'][0]['text']+='\n'+('Dlhé administratívne poznámky. '*3000)
|
||||||
|
compact=compact_rag_payload(p,max_tokens=2200)
|
||||||
|
assert compact['source_count']==2 and compact['context_budget']['used_tokens']<=2200
|
||||||
|
assert compact['truncated'] and 'llm_context_budget' in compact['truncation_reasons']
|
||||||
|
c=rag_evidence_context(q,compact)
|
||||||
|
assert {row['person'] for row in c['content_candidates']}=={'Eva Nová','Boris Nový'}
|
||||||
|
assert all(row['passage_id'] and row['work_label'] for row in c['content_candidates'])
|
||||||
|
|
||||||
|
|
||||||
|
def test_clipping_cannot_drop_a_wrapped_qualification_from_an_excerpt(corpus):
|
||||||
|
db,raw=corpus;q='Porovnaj výsledky Evy Novej a Borisa Nového.'
|
||||||
|
p=prepare_named_content(db,q,raw)
|
||||||
|
p['sources'][0]['text']='Sekcia: Diplomová práca 2024\nStav:\n- Natrénovaný model BERT.\n '
|
||||||
|
p['sources'][0]['text']+='Výsledok platí iba pre malý korpus. '*2000
|
||||||
|
compact=compact_rag_payload(p,max_tokens=1700)
|
||||||
|
assert compact['truncated'] and 'BERT' not in compact['context']
|
||||||
|
c=rag_evidence_context(q,compact)
|
||||||
|
assert any(row['person']=='Boris Nový' for row in c['content_candidates'])
|
||||||
|
assert not any(row['person']=='Eva Nová' for row in c['content_candidates'])
|
||||||
|
|
||||||
|
|
||||||
|
def test_single_profile_clipping_preserves_the_same_qualification_rule(corpus):
|
||||||
|
_,raw=corpus;raw['sources']=raw['sources'][:1]
|
||||||
|
raw['sources'][0]['text']='Sekcia: Diplomová práca 2024\nStav:\n- Natrénovaný model BERT.\n '
|
||||||
|
raw['sources'][0]['text']+='Výsledok platí iba pre malý testovací korpus. '*2000
|
||||||
|
compact=compact_rag_payload(raw,max_tokens=1700)
|
||||||
|
assert compact['truncated'] and 'BERT' not in compact['context']
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('with_title',[False,True])
|
||||||
|
def test_relevant_results_are_prioritized_before_context_is_clipped(corpus,with_title):
|
||||||
|
update_eva(corpus,('Názov: Skutočný názov práce.\n' if with_title else '')+'Zadanie:\n- Implementujte parser.')
|
||||||
|
db,raw=corpus;path=raw['sources'][0]['document_path']
|
||||||
|
with sqlite3.connect(db) as conn:
|
||||||
|
for i in range(1,12):
|
||||||
|
conn.execute('INSERT INTO chunks VALUES(?,?,?,?,?)',(path,
|
||||||
|
'Stav:\n- Konzultácia štruktúry práce. '+('Administratívne poznámky. '*100),
|
||||||
|
json.dumps([['Eva Nová','Diplomová práca 2024']]),1,i))
|
||||||
|
conn.execute('INSERT INTO chunks VALUES(?,?,?,?,?)',(path,
|
||||||
|
'Výsledky:\n- Vyhodnotená presnosť parsera.',
|
||||||
|
json.dumps([['Eva Nová','Diplomová práca 2024']]),1,12))
|
||||||
|
q='Porovnaj '+('názvy a ' if with_title else '')+'výsledky Evy Novej a Borisa Nového.'
|
||||||
|
compact=compact_rag_payload(prepare_named_content(db,q,raw),max_tokens=1700)
|
||||||
|
assert compact['truncated'] and 'Vyhodnotená presnosť parsera.' in compact['context']
|
||||||
|
c=rag_evidence_context(q,compact)
|
||||||
|
assert {u['person'] for u in c['content_candidates']}=={'Eva Nová','Boris Nový'}
|
||||||
|
if with_title:
|
||||||
|
assert 'Skutočný názov práce' in compact['context'] and 'title' in c['requirements']['available_fields']['Eva Nová']
|
||||||
|
|
||||||
|
|
||||||
|
def test_overview_cannot_cut_off_a_qualification_for_brevity():
|
||||||
|
text='Systém vyhľadáva dokumenty. '+('Opis implementácie a dát. '*30)+'Platí iba pre malý testovací korpus.'
|
||||||
|
quote={'source_id':'S1','quote':text}
|
||||||
|
c={'content_passages':[{'source_id':'S1','person':'Eva Nová','text':text}],'facts':[]}
|
||||||
|
assert overview_excerpts({'evidence_quotes':[quote]},c)==[text]
|
||||||
|
|
||||||
|
|
||||||
|
def test_markdown_role_headings_preserve_explicit_source_phase():
|
||||||
|
sections=content_sections('## Zadanie\nImplementujte systém.\n## Plánované úlohy\nPripravte dáta.\n'
|
||||||
|
'## Priebežný stav\nRozpracovaná integrácia.\n## Výsledky\nVyhodnotená presnosť.')
|
||||||
|
assert [(s['kind'],s['phase']) for s in sections]==[
|
||||||
|
('assignment','assignment'),('task','planned'),('progress','progress'),('progress','result')]
|
||||||
@ -3,11 +3,23 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import re
|
import re
|
||||||
from .context_policy import normalized
|
from .context_policy import normalized
|
||||||
from .question_intent import FACET_LABELS, planned_experiments
|
from .question_intent import FACET_LABELS, planned_experiments, requested_facets
|
||||||
|
|
||||||
COMPLETED = r'\b(?:implementovan\w*|natrenovan\w*|vytvoren\w*|extrahovan\w*|vykonan\w*|urobene vyhodnotenie|vyhodnoteny\w*|nameran\w*)\b'
|
COMPLETED = (r'\b(?:(?:implementovan|natrenovan|vytvoren|extrahovan|vykonan|vyhodnoten|nameran|'
|
||||||
PLANNED = r'\b(?:bude|budu|planuj\w*|cielom|mal by|ma sa|treba|pripravte|navrhnite|otestujte|vyhodnotte)\b'
|
r'vyriesen|odstranen|otestovan|porovnan|dosiahnut|dokoncen)(?:y|a|e|u|ou|ych|ym|ymi|om)|'
|
||||||
ADMIN = r'\b(?:konzultac\w*|struktura prace|pisomn\w*|pisat|odovzda\w*|wip|termin\w*)\b'
|
r'urobene vyhodnotenie|implementoval\w*|natrenoval\w*|vyhodnotil\w*|funguje|funguju)\b')
|
||||||
|
PLANNED = r'\b(?:bude|budu|planuj\w*|planovan\w*|cielom|mal by|ma sa|treba|pripravte|navrhnite|otestujte|vyhodnotte)\b'
|
||||||
|
ADMIN = r'\b(?:konzultac\w*|struktura prace|pisomn\w*|pisat|odovzda\w*\s+(?:text\w*|pisomn\w*|prac\w*)|wip|termin\w*)\b'
|
||||||
|
NOT_COMPLETED = r'\b(?:nie je|nie su|nebol\w*|este nie|zatial nie|nebude|nebudu)\b'
|
||||||
|
|
||||||
|
|
||||||
|
def has_qualification(text: str) -> bool:
|
||||||
|
return bool(re.search(r'\b(?:iba|len|avsak|ale|zatial|podmien\w*|obmedzen\w*|nebol\w*|nie|nepotvrd\w*)\b',normalized(text)))
|
||||||
|
|
||||||
|
|
||||||
|
def administrative_only(text: str) -> bool:
|
||||||
|
n=normalized(text)
|
||||||
|
return bool(re.search(ADMIN,n) and not re.search(COMPLETED,n))
|
||||||
|
|
||||||
|
|
||||||
def split_units(text: str) -> list[str]:
|
def split_units(text: str) -> list[str]:
|
||||||
@ -19,7 +31,7 @@ def split_units(text: str) -> list[str]:
|
|||||||
if not descriptive_text(part):
|
if not descriptive_text(part):
|
||||||
continue
|
continue
|
||||||
# A source paragraph is kept literal, including its negations and qualifications.
|
# A source paragraph is kept literal, including its negations and qualifications.
|
||||||
for unit in ([part] if len(part) <= 900 else re.split(r'(?<=[.!?])\s+(?=[A-ZÁČĎÉÍĽŇÓŠŤÚÝŽ])', part)):
|
for unit in ([part] if len(part) <= 900 or has_qualification(part) else re.split(r'(?<=[.!?])\s+(?=[A-ZÁČĎÉÍĽŇÓŠŤÚÝŽ])', part)):
|
||||||
if descriptive_text(unit):
|
if descriptive_text(unit):
|
||||||
units.append(unit.strip())
|
units.append(unit.strip())
|
||||||
return units
|
return units
|
||||||
@ -27,28 +39,45 @@ def split_units(text: str) -> list[str]:
|
|||||||
|
|
||||||
def effective_role(text: str, role: str) -> str:
|
def effective_role(text: str, role: str) -> str:
|
||||||
n = normalized(text)
|
n = normalized(text)
|
||||||
if role=='other':
|
if role in {'other','progress'}:
|
||||||
from .presentation import GOAL_VERBS
|
from .presentation import GOAL_VERBS
|
||||||
first=n.split()[0] if n else ''
|
first=n.split()[0] if n else ''
|
||||||
if first in {normalized(word) for word in GOAL_VERBS} or n.startswith('cielom je'):
|
if (first in {normalized(word) for word in [*GOAL_VERBS,*GOAL_VERBS.values()]} or n.startswith('cielom je') or
|
||||||
|
re.search(PLANNED,n)):
|
||||||
return 'task'
|
return 'task'
|
||||||
if role == 'other' and re.search(COMPLETED, n) and not re.search(PLANNED, n):
|
if role == 'other' and re.search(COMPLETED, n) and not re.search(PLANNED, n):
|
||||||
return 'progress'
|
return 'progress'
|
||||||
return role
|
return role
|
||||||
|
|
||||||
|
|
||||||
def facets_for(text: str, role: str, question: str) -> list[str]:
|
def evidence_phase(text: str, role: str, source_phase: str = 'unknown') -> str:
|
||||||
|
if role=='assignment':return 'assignment'
|
||||||
|
if role=='task':return 'planned'
|
||||||
|
if administrative_only(text):return 'administrative'
|
||||||
|
if role=='progress':
|
||||||
|
if source_phase=='result' or (not re.search(NOT_COMPLETED,normalized(text)) and
|
||||||
|
re.search(COMPLETED,normalized(text))):
|
||||||
|
return 'result'
|
||||||
|
return 'progress'
|
||||||
|
return 'unknown'
|
||||||
|
|
||||||
|
|
||||||
|
def facets_for(text: str, role: str, question: str, phase: str | None = None) -> list[str]:
|
||||||
n = normalized(text); facets = []
|
n = normalized(text); facets = []
|
||||||
if role == 'assignment': facets.append('assignment')
|
if role == 'assignment': facets.append('assignment')
|
||||||
if role == 'progress' and (not re.search(PLANNED,n) or re.search(COMPLETED,n)):
|
if role == 'task':facets.append('plans')
|
||||||
facets.append('results')
|
if role == 'progress' and not re.search(PLANNED,n) and not administrative_only(text):
|
||||||
|
facets.append('progress')
|
||||||
|
if (phase or evidence_phase(text,role))=='result':facets.append('results')
|
||||||
if re.search(r'\b(?:problem\w*|tazkost\w*|prekazk\w*|chyb\w*|zlyha\w*|nepodar\w*|nevyries\w*)\b', n) and role not in {'assignment','task'}:
|
if re.search(r'\b(?:problem\w*|tazkost\w*|prekazk\w*|chyb\w*|zlyha\w*|nepodar\w*|nevyries\w*)\b', n) and role not in {'assignment','task'}:
|
||||||
facets.append('problems')
|
facets.append('problems')
|
||||||
if re.search(r'\b(?:metod\w*|technolog\w*|nastroj\w*|pouzi\w*|vyuziv\w*|pomocou|implementovan\w*|natrenovan\w* model\w*)\b', n):
|
if re.search(r'\b(?:metod\w*|technolog\w*|nastroj\w*|pouzi\w*|vyuziv\w*|pomocou|implementovan\w*|natrenovan\w* model\w*)\b', n):
|
||||||
if role not in {'assignment','task'} or not re.search(r'\b(?:skutocne|pouziva\w*|pouzil\w*|vyuzival\w*|implementoval\w*)\b',normalized(question)):
|
actual_use=(re.search(r'\b(?:skutocne|pouziva\w*|pouzil\w*|vyuzival\w*|implementoval\w*)\b',normalized(question)) or
|
||||||
|
re.search(r'\b(?:pouzit\w*|aplikovan\w*|vyuzit\w*)\s+(?:metod\w*|technolog\w*|nastroj\w*)\b',normalized(question)))
|
||||||
|
if role not in {'assignment','task'} or not actual_use:
|
||||||
facets.append('methods')
|
facets.append('methods')
|
||||||
if re.search(r'\b(?:experiment\w*|meran\w*|test\w*|otest\w*|vyhodnot\w*|benchmark\w*|f1|presnost\w*)\b', n):
|
if re.search(r'\b(?:experiment\w*|meran\w*|test\w*|otest\w*|vyhodnot\w*|benchmark\w*|f1|presnost\w*)\b', n):
|
||||||
wants_done=re.search(r'\b(?:vykonal\w*|otestoval\w*|uskutocnil\w*|vyhodnotil\w*)\b',normalized(question))
|
wants_done=re.search(r'\b(?:vykonal\w*|vykonan\w*|otestoval\w*|uskutocnil\w*|uskutocnen\w*|realizovan\w*|vyhodnotil\w*)\b',normalized(question))
|
||||||
if wants_done and role in {'assignment','task'}:
|
if wants_done and role in {'assignment','task'}:
|
||||||
pass
|
pass
|
||||||
elif not planned_experiments(question) or role in {'assignment','task'} or re.search(PLANNED, n):
|
elif not planned_experiments(question) or role in {'assignment','task'} or re.search(PLANNED, n):
|
||||||
@ -61,35 +90,73 @@ def unit_score(text: str, role: str, facet: str = '') -> int:
|
|||||||
score = 25 if role == 'progress' else 10
|
score = 25 if role == 'progress' else 10
|
||||||
if re.search(COMPLETED, n): score += 45
|
if re.search(COMPLETED, n): score += 45
|
||||||
if re.search(r'\b(?:model|system|algoritm\w*|vyhodnot\w*|experiment\w*|presnost\w*|pagerank|bert)\b', n): score += 15
|
if re.search(r'\b(?:model|system|algoritm\w*|vyhodnot\w*|experiment\w*|presnost\w*|pagerank|bert)\b', n): score += 15
|
||||||
if re.search(ADMIN, n): score -= 70
|
if administrative_only(text): score -= 70
|
||||||
if facet == 'problems' and re.search(r'\b(?:problem\w*|chyb\w*|zlyha\w*)\b', n): score += 35
|
if facet == 'problems' and re.search(r'\b(?:problem\w*|chyb\w*|zlyha\w*)\b', n): score += 35
|
||||||
if role in {'assignment','task'} and facet == 'results': score -= 100
|
if role in {'assignment','task'} and facet == 'results': score -= 100
|
||||||
return score
|
return score
|
||||||
|
|
||||||
|
|
||||||
|
def prioritize_frames(frames: list[str], question: str) -> list[str]:
|
||||||
|
"""Order whole source windows before budgeting; do not rewrite their text."""
|
||||||
|
from .rag_evidence import content_sections, TITLE_LABELS
|
||||||
|
rankings=[]
|
||||||
|
for facet in requested_facets(question):
|
||||||
|
scores=[]
|
||||||
|
for index,frame in enumerate(frames):
|
||||||
|
relevant=[]
|
||||||
|
for section in content_sections(frame):
|
||||||
|
for text in split_units(section['text']):
|
||||||
|
role=effective_role(text,section['kind'])
|
||||||
|
phase=evidence_phase(text,role,section.get('phase','unknown'))
|
||||||
|
if len(text)<=2500 and facet in facets_for(text,role,question,phase):
|
||||||
|
relevant.append(unit_score(text,role,facet))
|
||||||
|
if relevant:scores.append((index,max(relevant)))
|
||||||
|
rankings.append([i for i,_ in sorted(scores,key=lambda item:-item[1])])
|
||||||
|
order=[]
|
||||||
|
if re.search(r'\bnaz(?:ov|v)\w*\b',normalized(question)) and frames:
|
||||||
|
order=[i for i,frame in enumerate(frames) if any(':' in line and
|
||||||
|
normalized(line.split(':',1)[0]) in TITLE_LABELS for line in frame.splitlines())] or [0]
|
||||||
|
seen=set(order)
|
||||||
|
for pos in range(max(map(len,rankings),default=0)):
|
||||||
|
for ranking in rankings:
|
||||||
|
if pos<len(ranking) and ranking[pos] not in seen:
|
||||||
|
seen.add(ranking[pos]);order.append(ranking[pos])
|
||||||
|
return [frames[i] for i in [*order,*(i for i in range(len(frames)) if i not in seen)]]
|
||||||
|
|
||||||
|
|
||||||
def evidence_units(context: dict, quotes: list[dict] | None = None) -> list[dict]:
|
def evidence_units(context: dict, quotes: list[dict] | None = None) -> list[dict]:
|
||||||
from .rag_evidence import content_sections, quote_sections
|
from .rag_evidence import content_sections, quote_sections, quote_matches_passage
|
||||||
rows = []
|
rows = []
|
||||||
for passage in context.get('content_passages', []):
|
for passage in context.get('content_passages', []):
|
||||||
pieces = content_sections(passage['text']) if quotes is None else [
|
pieces = content_sections(passage['text']) if quotes is None else [
|
||||||
piece for quote in quotes if quote['source_id'] == passage['source_id'] and
|
piece for quote in quotes if quote_matches_passage(quote,passage)
|
||||||
' '.join(quote['quote'].split()) in ' '.join(passage['text'].split())
|
for piece in quote_sections(quote, {'content_passages':[passage]})]
|
||||||
for piece in quote_sections(quote, context)]
|
|
||||||
for piece in pieces:
|
for piece in pieces:
|
||||||
for unit in split_units(piece['text']):
|
for unit in split_units(piece['text']):
|
||||||
role = effective_role(unit, piece['kind'])
|
role = effective_role(unit, piece['kind'])
|
||||||
rows.append({k: passage.get(k) for k in ['person','work_type','year','source_id']} |
|
phase=evidence_phase(unit,role,piece.get('phase','unknown'))
|
||||||
{'text':unit,'kind':role,'facets':facets_for(unit,role,context.get('question',''))})
|
rows.append({k: passage.get(k) for k in ['person','work_type','year','work_label','source_id','passage_id']} |
|
||||||
|
{'text':unit,'kind':role,'phase':phase,
|
||||||
|
'facets':facets_for(unit,role,context.get('question',''),phase)})
|
||||||
# Overlapping chunks/quotes can repeat the same fact with different roles.
|
# Overlapping chunks/quotes can repeat the same fact with different roles.
|
||||||
# An explicit role wins over an unlabelled duplicate, within the same work.
|
# An explicit role wins over an unlabelled duplicate, within the same work.
|
||||||
unique = {}
|
unique = {}
|
||||||
for row in rows:
|
for row in rows:
|
||||||
clean=re.sub(r'^\s*(?:\d+[.)]|[-+*])\s+','',row['text'])
|
clean=re.sub(r'^\s*(?:\d+[.)]|[-+*])\s+','',row['text'])
|
||||||
key = tuple(row.get(k) for k in ['person','work_type','year']) + (' '.join(clean.split()),)
|
key = tuple(row.get(k) for k in ['person','work_type','year']) + (normalized(row.get('work_label')),
|
||||||
|
row['phase'],' '.join(clean.split()))
|
||||||
old = unique.get(key)
|
old = unique.get(key)
|
||||||
if old is None or (old['kind']=='other' and row['kind']!='other'):
|
if old is None or (old['kind']=='other' and row['kind']!='other'):
|
||||||
unique[key]=row
|
unique[key]=row
|
||||||
return list(unique.values())
|
units=list(unique.values())
|
||||||
|
# Prefer a whole longer excerpt over its overlapping prefix, within one
|
||||||
|
# work and phase. Never merge people, years or plans with recorded results.
|
||||||
|
def plain(row):return ' '.join(re.sub(r'^\s*(?:\d+[.)]|[-+*])\s+','',row['text']).split())
|
||||||
|
return [row for row in units if not any(
|
||||||
|
all(row.get(k)==other.get(k) for k in ['person','work_type','year','phase']) and
|
||||||
|
normalized(row.get('work_label'))==normalized(other.get('work_label')) and
|
||||||
|
len(plain(row))<len(plain(other)) and ' '+plain(row)+' ' in ' '+plain(other)+' '
|
||||||
|
for other in units)]
|
||||||
|
|
||||||
|
|
||||||
def candidates(context: dict) -> list[dict]:
|
def candidates(context: dict) -> list[dict]:
|
||||||
@ -98,6 +165,8 @@ def candidates(context: dict) -> list[dict]:
|
|||||||
for person in context['requirements']['people']:
|
for person in context['requirements']['people']:
|
||||||
for facet in facets:
|
for facet in facets:
|
||||||
relevant = [u for u in units if u['person']==person and facet in u['facets']]
|
relevant = [u for u in units if u['person']==person and facet in u['facets']]
|
||||||
|
if facet=='progress' and 'results' in facets and any(u['phase']=='progress' for u in relevant):
|
||||||
|
relevant=[u for u in relevant if u['phase']=='progress']
|
||||||
relevant.sort(key=lambda u: -unit_score(u['text'],u['kind'],facet))
|
relevant.sort(key=lambda u: -unit_score(u['text'],u['kind'],facet))
|
||||||
accepted=0
|
accepted=0
|
||||||
for unit in relevant:
|
for unit in relevant:
|
||||||
@ -161,6 +230,10 @@ def facet_lines(answer: dict, context: dict) -> list[str]:
|
|||||||
subject=person+(' — '+WORK_LABELS[kind] if kind in WORK_LABELS else '')
|
subject=person+(' — '+WORK_LABELS[kind] if kind in WORK_LABELS else '')
|
||||||
if len(scopes)==1 and next(iter(scopes))[1] is not None:
|
if len(scopes)==1 and next(iter(scopes))[1] is not None:
|
||||||
subject+=' ('+str(next(iter(scopes))[1])+')'
|
subject+=' ('+str(next(iter(scopes))[1])+')'
|
||||||
|
labels={p.get('work_label') for p in context.get('content_passages',[])
|
||||||
|
if p.get('person')==person and p.get('work_label')}
|
||||||
|
if req.get('project_number') is not None and len(labels)==1:
|
||||||
|
subject=person+' — '+' '.join(next(iter(labels)).split())
|
||||||
lines.extend(['**'+subject+'**', ''])
|
lines.extend(['**'+subject+'**', ''])
|
||||||
seen=set()
|
seen=set()
|
||||||
for facet in req.get('facets', []):
|
for facet in req.get('facets', []):
|
||||||
@ -179,8 +252,10 @@ def facet_lines(answer: dict, context: dict) -> list[str]:
|
|||||||
if row['kind'] in {'assignment','task'}:
|
if row['kind'] in {'assignment','task'}:
|
||||||
text,_=goal_text(text)
|
text,_=goal_text(text)
|
||||||
text=re.sub(r'^\s*(?:\d+[.)]|[-+*])\s+','',text)
|
text=re.sub(r'^\s*(?:\d+[.)]|[-+*])\s+','',text)
|
||||||
phase=' (plán)' if row['kind'] in {'assignment','task'} and facet not in {'assignment'} else ''
|
phase=' (plán)' if row['kind'] in {'assignment','task'} and facet not in {'assignment','plans'} else ''
|
||||||
lines.append('- '+text+phase); seen.add(key); shown+=1
|
state=({'result':'Zaznamenaný výsledok: ','progress':'Priebežný stav: '}.get(row['phase'],'')
|
||||||
|
if facet=='results' else '')
|
||||||
|
lines.append('- '+state+text+phase); seen.add(key); shown+=1
|
||||||
if shown==2: break
|
if shown==2: break
|
||||||
if not shown:
|
if not shown:
|
||||||
if rows:
|
if rows:
|
||||||
|
|||||||
@ -4,14 +4,16 @@ from .context_policy import normalized
|
|||||||
|
|
||||||
FACET_PATTERNS = {
|
FACET_PATTERNS = {
|
||||||
'assignment': r'\b(?:zadani\w*|ciel\w*)\b',
|
'assignment': r'\b(?:zadani\w*|ciel\w*)\b',
|
||||||
|
'plans': r'\b(?:planovan\w*|dalsi\w*\s+uloh\w*|uloh\w*)\b',
|
||||||
'problems': r'\b(?:problem\w*|tazkost\w*|prekazk\w*|chyb\w*)\b',
|
'problems': r'\b(?:problem\w*|tazkost\w*|prekazk\w*|chyb\w*)\b',
|
||||||
'methods': r'\b(?:metod\w*|technolog\w*|nastroj\w*|pouziv\w*|pouzil\w*)\b',
|
'methods': r'\b(?:metod\w*|technolog\w*|nastroj\w*|pouziv\w*|pouzil\w*)\b',
|
||||||
'experiments': r'\b(?:experiment\w*|merani\w*|testovani\w*|vyhodnocov\w*)\b',
|
'experiments': r'\b(?:experiment\w*|merani\w*|testovani\w*|vyhodnocov\w*)\b',
|
||||||
'results': r'\b(?:vysledk\w*|dosiahn\w*|stav\w*)\b',
|
'progress':r'\b(?:priebezn\w*\s+)?stav\w*\b',
|
||||||
|
'results': r'\b(?:vysledk\w*|dosiahn\w*)\b',
|
||||||
}
|
}
|
||||||
FACET_LABELS = {'assignment': 'Zadanie a ciele', 'problems': 'Riešené problémy',
|
FACET_LABELS = {'assignment': 'Zadanie a ciele', 'plans':'Plánované úlohy', 'problems': 'Riešené problémy',
|
||||||
'methods': 'Metódy a technológie', 'experiments': 'Experimenty a vyhodnotenie',
|
'methods': 'Metódy a technológie', 'experiments': 'Experimenty a vyhodnotenie',
|
||||||
'results': 'Zaznamenaný stav a výsledky'}
|
'progress':'Priebežný stav','results': 'Zaznamenané výsledky'}
|
||||||
|
|
||||||
|
|
||||||
def requested_facets(question: str) -> list[str]:
|
def requested_facets(question: str) -> list[str]:
|
||||||
@ -29,10 +31,12 @@ def content_question(question: str) -> bool:
|
|||||||
|
|
||||||
def facet_mode(question: str) -> bool:
|
def facet_mode(question: str) -> bool:
|
||||||
facets = requested_facets(question)
|
facets = requested_facets(question)
|
||||||
return any(facet in facets for facet in ['problems','methods','experiments','results'])
|
return any(facet in facets for facet in ['plans','progress','problems','methods','experiments','results'])
|
||||||
|
|
||||||
|
|
||||||
def planned_experiments(question: str) -> bool:
|
def planned_experiments(question: str) -> bool:
|
||||||
text=normalized(question)
|
text=normalized(question)
|
||||||
|
if re.search(r'\b(?:vykonal\w*|vykonan\w*|uskutocnil\w*|uskutocnen\w*|realizoval\w*|realizovan\w*|otestoval\w*|vyhodnotil\w*)\b',text):
|
||||||
|
return False
|
||||||
return bool(re.search(r'\b(?:plan\w*|pripravit|navrhnut)|'
|
return bool(re.search(r'\b(?:plan\w*|pripravit|navrhnut)|'
|
||||||
r'\b(?:mal|mali|mala|ma|maju)\s+(?:priprav\w*|navrhn\w*|otest\w*|vyhodnot\w*)',text))
|
r'\b(?:mal|mali|mala|ma|maju)\s+(?:priprav\w*|navrhn\w*|otest\w*|vyhodnot\w*)',text))
|
||||||
|
|||||||
@ -8,6 +8,7 @@ from __future__ import annotations
|
|||||||
import re
|
import re
|
||||||
import json
|
import json
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
import hashlib
|
||||||
from contextlib import closing
|
from contextlib import closing
|
||||||
from .context_policy import normalized, text_contains_value, person_pattern
|
from .context_policy import normalized, text_contains_value, person_pattern
|
||||||
from .question_intent import requested_facets, content_question, facet_mode
|
from .question_intent import requested_facets, content_question, facet_mode
|
||||||
@ -50,6 +51,8 @@ def academic_scope(path: str) -> tuple[str, int | None] | None:
|
|||||||
for heading in reversed(path.split('>')):
|
for heading in reversed(path.split('>')):
|
||||||
if normalized(heading).startswith(('priprava na ', 'priprava k ')):
|
if normalized(heading).startswith(('priprava na ', 'priprava k ')):
|
||||||
return None
|
return None
|
||||||
|
if not TYPE_PATTERN.match(normalized(heading)):
|
||||||
|
continue
|
||||||
kind = work_type(heading)
|
kind = work_type(heading)
|
||||||
if kind:
|
if kind:
|
||||||
years = [int(year) for year in re.findall(r'(?<!\d)([12]\d{3})(?!\d)', heading)]
|
years = [int(year) for year in re.findall(r'(?<!\d)([12]\d{3})(?!\d)', heading)]
|
||||||
@ -124,24 +127,36 @@ def requested_types(question: str, people: list[str]) -> dict[str, str]:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
def academic_windows(body: str) -> list[tuple[tuple[str, int | None] | None, str]]:
|
def project_number(text: str) -> int | None:
|
||||||
|
match=re.search(r'\bprojekt\w*\s+([1-9]\d?)(?!\d)\b',normalized(text))
|
||||||
|
return int(match[1]) if match else None
|
||||||
|
|
||||||
|
|
||||||
|
def academic_windows_metadata(body: str) -> list[tuple]:
|
||||||
"""Keep literal passages bound to the academic section actually delivered."""
|
"""Keep literal passages bound to the academic section actually delivered."""
|
||||||
windows = []; scope = None; heading_stack = []; lines = []
|
windows = []; scope = None; label = None; heading_stack = []; lines = []
|
||||||
def flush():
|
def flush():
|
||||||
if lines:
|
if lines:
|
||||||
windows.append((scope, '\n'.join(lines)))
|
windows.append((scope, label, '\n'.join(lines)))
|
||||||
lines.clear()
|
lines.clear()
|
||||||
for line in body.splitlines():
|
for line in body.splitlines():
|
||||||
heading = re.match(r'^(?:Sekcia(?: dôkazu)?:|#{1,6}\s)(.*)', line)
|
heading = re.match(r'^(?:Sekcia(?: dôkazu)?:|#{1,6}\s)(.*)', line)
|
||||||
if line.startswith(('RELEVANTNÁ SEKCIA ', 'DÔKAZ Z ', 'ĎALŠÍ DÔKAZ ',
|
if line.startswith(('RELEVANTNÁ SEKCIA ', 'DÔKAZ Z ', 'ĎALŠÍ DÔKAZ ',
|
||||||
'ĎALŠÍ RELEVANTNÝ ', 'NAJRELEVANTNEJŠÍ ', 'ZAČIATOK RELEVANTNEJ ')):
|
'ĎALŠÍ RELEVANTNÝ ', 'NAJRELEVANTNEJŠÍ ', 'ZAČIATOK RELEVANTNEJ ')):
|
||||||
flush(); scope = None; heading_stack.clear()
|
flush(); scope = None; label = None; heading_stack.clear()
|
||||||
elif line.startswith('Dokument:'):
|
elif line.startswith('Dokument:'):
|
||||||
flush(); scope = None; heading_stack.clear()
|
flush(); scope = None; label = None; heading_stack.clear()
|
||||||
elif heading:
|
elif heading:
|
||||||
flush()
|
flush()
|
||||||
if line.startswith('Sekcia'):
|
if line.startswith('Sekcia'):
|
||||||
scope = academic_scope(heading[1]); heading_stack = [(0, scope)]
|
scope = academic_scope(heading[1]); label = None
|
||||||
|
parts=[p.strip() for p in heading[1].split('>')]
|
||||||
|
academic=[i for i,p in enumerate(parts) if TYPE_PATTERN.match(normalized(p))]
|
||||||
|
if scope and academic:
|
||||||
|
pos=academic[-1];label=parts[pos]
|
||||||
|
# Chunk ancestry can carry the only explicit role heading.
|
||||||
|
lines.extend('# '+part for part in parts[pos+1:] if part)
|
||||||
|
heading_stack = [(0, scope, label)]
|
||||||
else:
|
else:
|
||||||
level = len(line) - len(line.lstrip('#'))
|
level = len(line) - len(line.lstrip('#'))
|
||||||
while heading_stack and heading_stack[-1][0] >= level:
|
while heading_stack and heading_stack[-1][0] >= level:
|
||||||
@ -149,7 +164,8 @@ def academic_windows(body: str) -> list[tuple[tuple[str, int | None] | None, str
|
|||||||
detected = academic_scope(heading[1])
|
detected = academic_scope(heading[1])
|
||||||
scope = (None if normalized(heading[1]).startswith(('priprava na ', 'priprava k ')) else
|
scope = (None if normalized(heading[1]).startswith(('priprava na ', 'priprava k ')) else
|
||||||
detected or (heading_stack[-1][1] if heading_stack else None))
|
detected or (heading_stack[-1][1] if heading_stack else None))
|
||||||
heading_stack.append((level, scope))
|
label = heading[1].strip() if detected else (heading_stack[-1][2] if heading_stack else None)
|
||||||
|
heading_stack.append((level, scope, label))
|
||||||
if scope is not None and detected is None:
|
if scope is not None and detected is None:
|
||||||
# Keep semantic child headings (Stav, Úlohy, Metódy) as
|
# Keep semantic child headings (Stav, Úlohy, Metódy) as
|
||||||
# literal source text instead of losing their meaning.
|
# literal source text instead of losing their meaning.
|
||||||
@ -160,6 +176,10 @@ def academic_windows(body: str) -> list[tuple[tuple[str, int | None] | None, str
|
|||||||
return windows
|
return windows
|
||||||
|
|
||||||
|
|
||||||
|
def academic_windows(body: str) -> list[tuple[tuple[str, int | None] | None, str]]:
|
||||||
|
return [(scope,text) for scope,_label,text in academic_windows_metadata(body)]
|
||||||
|
|
||||||
|
|
||||||
def descriptive_text(text: str, titles=()) -> str:
|
def descriptive_text(text: str, titles=()) -> str:
|
||||||
"""Exclude labels/identity lines; a title or year alone is not a description."""
|
"""Exclude labels/identity lines; a title or year alone is not a description."""
|
||||||
metadata = TITLE_LABELS | {'meno veduceho', 'veduci', 'veduci prace', 'autor', 'autor dokumentu',
|
metadata = TITLE_LABELS | {'meno veduceho', 'veduci', 'veduci prace', 'autor', 'autor dokumentu',
|
||||||
@ -202,16 +222,19 @@ def academic_content_passages(blocks: list[dict], facts: list[dict], requirement
|
|||||||
types = {requirements['work_types'][person] for person in people if person in requirements['work_types']} or query_types
|
types = {requirements['work_types'][person] for person in people if person in requirements['work_types']} or query_types
|
||||||
titles = [row.get(key) for row in facts if row.get('source_id') == source['source_id']
|
titles = [row.get(key) for row in facts if row.get('source_id') == source['source_id']
|
||||||
for key in ('title', 'person', 'document_author')]
|
for key in ('title', 'person', 'document_author')]
|
||||||
for scope, text in academic_windows(source['body']):
|
for scope, label, text in academic_windows_metadata(source['body']):
|
||||||
if not scope or (types and scope[0] not in types):
|
if not scope or (types and scope[0] not in types):
|
||||||
continue
|
continue
|
||||||
if requirements['unsupported_year'] or (requirements['years'] and scope[1] not in requirements['years']):
|
if requirements['unsupported_year'] or (requirements['years'] and scope[1] not in requirements['years']):
|
||||||
continue
|
continue
|
||||||
|
if requirements.get('project_number') is not None and project_number(label or '')!=requirements['project_number']:
|
||||||
|
continue
|
||||||
if not descriptive_text(text, titles):
|
if not descriptive_text(text, titles):
|
||||||
continue
|
continue
|
||||||
owner = (source.get('title') if PROFILE.fullmatch(path) else people[0] if len(people) == 1 else None)
|
owner = (source.get('title') if PROFILE.fullmatch(path) else people[0] if len(people) == 1 else None)
|
||||||
passage = {'source_id': source['source_id'], 'person': owner,
|
passage = {'source_id': source['source_id'], 'person': owner,
|
||||||
'work_type': scope[0], 'year': scope[1], 'text': text}
|
'document_path':path, 'work_type': scope[0], 'year': scope[1], 'work_label':label, 'text': text}
|
||||||
|
passage['passage_id']='P'+hashlib.sha256(json.dumps(passage,ensure_ascii=False,sort_keys=True).encode()).hexdigest()[:20]
|
||||||
if passage not in result:
|
if passage not in result:
|
||||||
result.append(passage)
|
result.append(passage)
|
||||||
return result
|
return result
|
||||||
@ -219,48 +242,56 @@ def academic_content_passages(blocks: list[dict], facts: list[dict], requirement
|
|||||||
|
|
||||||
def content_sections(text: str) -> list[dict]:
|
def content_sections(text: str) -> list[dict]:
|
||||||
"""Separate explicit source goals from recorded progress, without inference."""
|
"""Separate explicit source goals from recorded progress, without inference."""
|
||||||
sections = []; lines = []; kind = 'other'
|
sections = []; lines = []; kind = 'other'; phase = 'unknown'
|
||||||
def flush():
|
def flush():
|
||||||
if lines and '\n'.join(lines).strip():
|
if lines and '\n'.join(lines).strip():
|
||||||
sections.append({'kind': kind, 'text': '\n'.join(lines).strip()})
|
sections.append({'kind': kind, 'phase':phase, 'text': '\n'.join(lines).strip()})
|
||||||
lines.clear()
|
lines.clear()
|
||||||
for line in text.splitlines():
|
for line in text.splitlines():
|
||||||
value = normalized(line)
|
value = normalized(line)
|
||||||
label = line.strip().strip('*_# ').rstrip(':').strip()
|
label = line.strip().strip('*_# ').rstrip(':').strip()
|
||||||
heading = value == normalized(label) and len(value.split()) <= 8
|
heading = value == normalized(label) and len(value.split()) <= 8
|
||||||
|
role_value=re.sub(r'\s+(?:diplomov\w*|bakalarsk\w*|zaverecn\w*)\s+(?:prac\w*|projekt\w*)(?:\s+\d{4})?$','',value)
|
||||||
next_kind = None
|
next_kind = None
|
||||||
if heading and value in {'ciel', 'ciele', 'ciele na semester', 'ciele prace', 'zadanie',
|
if heading and role_value in {'ciel', 'ciele', 'ciele na semester', 'ciele prace', 'zadanie',
|
||||||
'zadanie diplomovej prace', 'zadanie bakalarskej prace',
|
'zadanie diplomovej prace', 'zadanie bakalarskej prace',
|
||||||
'zadanie diplomoveho projektu', 'zadanie bakalarskeho projektu',
|
'zadanie diplomoveho projektu', 'zadanie bakalarskeho projektu',
|
||||||
'navrh na zadanie', 'navrh zadania'}:
|
'navrh na zadanie', 'navrh zadania'}:
|
||||||
next_kind = 'assignment'
|
next_kind = 'assignment'
|
||||||
elif heading and value in {'stav', 'vysledky', 'dosiahnute vysledky', 'dosiahnuty stav'}:
|
elif heading and role_value in {'stav', 'priebezny stav', 'vysledky', 'dosiahnute vysledky', 'dosiahnuty stav'}:
|
||||||
next_kind = 'progress'
|
next_kind = 'progress'
|
||||||
elif heading and value in {'ulohy', 'zasobnik uloh', 'dalsie ulohy'}:
|
elif heading and role_value in {'ulohy', 'zasobnik uloh', 'dalsie ulohy', 'plan', 'planovane ulohy'}:
|
||||||
next_kind = 'task'
|
next_kind = 'task'
|
||||||
elif value.startswith(('stretnutie ', 'virtualne stretnutie ', 'sekcia ')):
|
elif value.startswith(('stretnutie ', 'virtualne stretnutie ', 'sekcia ')):
|
||||||
next_kind = 'other'
|
next_kind = 'other'
|
||||||
if next_kind is not None:
|
if next_kind is not None:
|
||||||
flush(); kind = next_kind
|
flush(); kind = next_kind
|
||||||
|
phase=({'assignment':'assignment','task':'planned','other':'unknown'}.get(kind) or
|
||||||
|
('result' if role_value in {'vysledky','dosiahnute vysledky'} else 'progress'))
|
||||||
else:
|
else:
|
||||||
lines.append(line)
|
lines.append(line)
|
||||||
flush()
|
flush()
|
||||||
return sections
|
return sections
|
||||||
|
|
||||||
|
|
||||||
|
def quote_matches_passage(quote: dict, passage: dict) -> bool:
|
||||||
|
return (quote['source_id']==passage['source_id'] and
|
||||||
|
(not quote.get('passage_id') or quote['passage_id']==passage.get('passage_id')) and
|
||||||
|
' '.join(quote['quote'].split()) in ' '.join(passage['text'].split()))
|
||||||
|
|
||||||
|
|
||||||
def quote_sections(quote: dict, context: dict) -> list[dict]:
|
def quote_sections(quote: dict, context: dict) -> list[dict]:
|
||||||
"""Classify only literal excerpts bound to the delivered academic passage."""
|
"""Classify only literal excerpts bound to the delivered academic passage."""
|
||||||
full = [section for passage in context.get('content_passages', [])
|
full = [section for passage in context.get('content_passages', [])
|
||||||
if passage['source_id'] == quote['source_id'] and
|
if quote_matches_passage(quote,passage)
|
||||||
' '.join(quote['quote'].split()) in ' '.join(passage['text'].split())
|
|
||||||
for section in content_sections(passage['text'])]
|
for section in content_sections(passage['text'])]
|
||||||
pieces = content_sections(quote['quote'])
|
pieces = content_sections(quote['quote'])
|
||||||
for piece in pieces:
|
for piece in pieces:
|
||||||
if piece['kind'] == 'other':
|
if piece['kind'] == 'other':
|
||||||
kinds = {section['kind'] for section in full
|
roles = {(section['kind'],section['phase']) for section in full
|
||||||
if ' '.join(piece['text'].split()) in ' '.join(section['text'].split())}
|
if ' '.join(piece['text'].split()) in ' '.join(section['text'].split())}
|
||||||
if len(kinds) == 1:
|
if len(roles) == 1:
|
||||||
piece['kind'] = kinds.pop()
|
piece['kind'],piece['phase'] = roles.pop()
|
||||||
return pieces
|
return pieces
|
||||||
|
|
||||||
|
|
||||||
@ -298,7 +329,7 @@ def expand_comparison_sources(db_file, question: str, payload: dict) -> dict:
|
|||||||
paths = json.loads(row['heading_paths_json'] or '[]')
|
paths = json.loads(row['heading_paths_json'] or '[]')
|
||||||
headings = [' > '.join(path) if isinstance(path, list) else str(path) for path in paths]
|
headings = [' > '.join(path) if isinstance(path, list) else str(path) for path in paths]
|
||||||
first = headings[0] if headings else ''
|
first = headings[0] if headings else ''
|
||||||
for scope, text in academic_windows('Sekcia: ' + first + '\n' + row['text']):
|
for scope, label, text in academic_windows_metadata('Sekcia: ' + first + '\n' + row['text']):
|
||||||
if not scope or (req['work_types'].get(person) and scope[0] != req['work_types'][person]):
|
if not scope or (req['work_types'].get(person) and scope[0] != req['work_types'][person]):
|
||||||
continue
|
continue
|
||||||
if req['unsupported_year'] or (req['years'] and scope[1] not in req['years']):
|
if req['unsupported_year'] or (req['years'] and scope[1] not in req['years']):
|
||||||
@ -306,8 +337,7 @@ def expand_comparison_sources(db_file, question: str, payload: dict) -> dict:
|
|||||||
if not descriptive_text(text) or (scope, text) in seen:
|
if not descriptive_text(text) or (scope, text) in seen:
|
||||||
continue
|
continue
|
||||||
seen.add((scope, text))
|
seen.add((scope, text))
|
||||||
frames.append('Sekcia: ' + WORK_LABELS[scope[0]] +
|
frames.append('Sekcia: ' + (label or WORK_LABELS[scope[0]]) + '\n' + text)
|
||||||
(f' {scope[1]}' if scope[1] is not None else '') + '\n' + text)
|
|
||||||
selected.append({**source, **({'text': '\n\n'.join(frames)} if frames else {})})
|
selected.append({**source, **({'text': '\n\n'.join(frames)} if frames else {})})
|
||||||
changed |= bool(frames)
|
changed |= bool(frames)
|
||||||
if not changed:
|
if not changed:
|
||||||
@ -328,7 +358,9 @@ def rag_evidence_context(question: str, payload: dict) -> dict:
|
|||||||
people.append(person)
|
people.append(person)
|
||||||
base = {'person': person, 'source_id': source['source_id'], 'source_url': source.get('source_url'),
|
base = {'person': person, 'source_id': source['source_id'], 'source_url': source.get('source_url'),
|
||||||
'document_path': source['document_path']}
|
'document_path': source['document_path']}
|
||||||
windows = academic_windows(source['body'])
|
windows = [(scope,text) for scope,label,text in academic_windows_metadata(source['body'])
|
||||||
|
if project_number(question) is None or
|
||||||
|
(scope is not None and project_number(label or '')==project_number(question))]
|
||||||
unscoped = []
|
unscoped = []
|
||||||
for section, text in windows:
|
for section, text in windows:
|
||||||
titles = []
|
titles = []
|
||||||
@ -432,6 +464,7 @@ def rag_evidence_context(question: str, payload: dict) -> dict:
|
|||||||
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'] = requested_years(question) if academic and not requirements['start_year'] else []
|
requirements['years'] = requested_years(question) if academic and not requirements['start_year'] else []
|
||||||
requirements['unsupported_year'] = any(len(value) != 4 for value in stated_years)
|
requirements['unsupported_year'] = any(len(value) != 4 for value in stated_years)
|
||||||
|
requirements['project_number'] = project_number(question)
|
||||||
if requested:
|
if requested:
|
||||||
facts = [row for row in facts if row['person'] in requested]
|
facts = [row for row in facts if row['person'] in requested]
|
||||||
requirements['available_fields'] = {person:[key for key in ['title','year','start_year']
|
requirements['available_fields'] = {person:[key for key in ['title','year','start_year']
|
||||||
|
|||||||
@ -8,7 +8,7 @@ from contextlib import closing
|
|||||||
|
|
||||||
from .context_policy import normalized, person_pattern, text_contains_value
|
from .context_policy import normalized, person_pattern, text_contains_value
|
||||||
from .question_intent import content_question
|
from .question_intent import content_question
|
||||||
from .rag_evidence import PROFILE, academic_scope, academic_windows, requested_types, WORK_LABELS
|
from .rag_evidence import PROFILE, academic_windows_metadata, requested_types, WORK_LABELS, project_number
|
||||||
from tools.retrieval.question_constraints import requested_years
|
from tools.retrieval.question_constraints import requested_years
|
||||||
|
|
||||||
|
|
||||||
@ -64,17 +64,21 @@ def prepare_named_content(db_file, question: str, payload: dict) -> dict:
|
|||||||
paths=json.loads(row['heading_paths_json'] or '[]')
|
paths=json.loads(row['heading_paths_json'] or '[]')
|
||||||
headings=[' > '.join(p) if isinstance(p,list) else str(p) for p in paths]
|
headings=[' > '.join(p) if isinstance(p,list) else str(p) for p in paths]
|
||||||
first=headings[0] if headings else ''
|
first=headings[0] if headings else ''
|
||||||
for scope,text in academic_windows('Sekcia: '+first+'\n'+str(row['text'] or '')):
|
for scope,label,text in academic_windows_metadata('Sekcia: '+first+'\n'+str(row['text'] or '')):
|
||||||
if not scope or (types.get(person['title']) and scope[0]!=types[person['title']]) or (years and scope[1] not in years):
|
if not scope or (types.get(person['title']) and scope[0]!=types[person['title']]) or (years and scope[1] not in years):
|
||||||
continue
|
continue
|
||||||
label=WORK_LABELS[scope[0]]+(f' {scope[1]}' if scope[1] is not None else '')
|
label=label or WORK_LABELS[scope[0]]+(f' {scope[1]}' if scope[1] is not None else '')
|
||||||
records[scope]=label
|
if project_number(question) is not None and project_number(label)!=project_number(question):
|
||||||
if (scope,text) not in seen:
|
continue
|
||||||
seen.add((scope,text));frames.append('Sekcia: '+label+'\n'+text)
|
records[(scope,normalized(label))]=label
|
||||||
|
if (scope,normalized(label),text) not in seen:
|
||||||
|
seen.add((scope,normalized(label),text));frames.append('Sekcia: '+label+'\n'+text)
|
||||||
if len(records)>1:
|
if len(records)>1:
|
||||||
options='; '.join(dict.fromkeys(records.values()))
|
options='; '.join(dict.fromkeys(records.values()))
|
||||||
return clarify(payload,'ambiguous_work',f'Ktorú prácu osoby {person["title"]} máš na mysli? '
|
return clarify(payload,'ambiguous_work',f'Ktorú prácu osoby {person["title"]} máš na mysli? '
|
||||||
f'V indexe sú tieto možnosti: {options}. Uveď typ práce a rok.')
|
f'V indexe sú tieto možnosti: {options}. Uveď typ práce a rok alebo presné označenie projektu.')
|
||||||
|
from .content_selection import prioritize_frames
|
||||||
|
frames=prioritize_frames(frames,question)
|
||||||
source=next((s for s in payload.get('sources',[]) if s.get('document_path')==person['path']),None)
|
source=next((s for s in payload.get('sources',[]) if s.get('document_path')==person['path']),None)
|
||||||
if source is None:
|
if source is None:
|
||||||
from tools.rag.search_core import make_source_url
|
from tools.rag.search_core import make_source_url
|
||||||
|
|||||||
@ -10,10 +10,10 @@ 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, quote_sections
|
from .rag_evidence import descriptive_text, rag_evidence_context, quote_sections, quote_matches_passage
|
||||||
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, work_subject
|
from .presentation import content_lines, fact_sentences, no_answer_text, work_subject
|
||||||
from .content_selection import facet_lines, evidence_units, missing_facets, fit_candidates
|
from .content_selection import facet_lines, evidence_units, missing_facets, fit_candidates, has_qualification
|
||||||
|
|
||||||
|
|
||||||
class RagClaim(Claim):
|
class RagClaim(Claim):
|
||||||
@ -25,6 +25,7 @@ class EvidenceQuote(BaseModel):
|
|||||||
model_config = ConfigDict(extra='forbid', strict=True)
|
model_config = ConfigDict(extra='forbid', strict=True)
|
||||||
source_id: str
|
source_id: str
|
||||||
quote: str = Field(min_length=1, max_length=2500)
|
quote: str = Field(min_length=1, max_length=2500)
|
||||||
|
passage_id: str | None = Field(default=None,min_length=1,max_length=64)
|
||||||
|
|
||||||
|
|
||||||
class RagAnswer(BaseModel):
|
class RagAnswer(BaseModel):
|
||||||
@ -70,7 +71,11 @@ Pri zaznamenanom stave uprednostni konkrétnu implementáciu, natrénovanie mode
|
|||||||
vyhodnotenie pred konzultáciami alebo samotným písaním textu. Ani priebežný stav nevydávaj
|
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.
|
za splnenie všetkých cieľov alebo za konečné výsledky práce.
|
||||||
Ak sú dodané content_candidates, vyber quotes iba z ich textu. Každý kandidát uvádza
|
Ak sú dodané content_candidates, vyber quotes iba z ich textu. Každý kandidát uvádza
|
||||||
osobu, typ práce, rok, kind (assignment/task/progress/other) a facets, ktoré dokladá.
|
osobu, typ práce, rok, work_label, passage_id, kind, phase a facets, ktoré dokladá.
|
||||||
|
Pri evidence_quotes zachovaj passage_id kandidáta alebo dodanej content_passage.
|
||||||
|
Rovnaký text pod inou prácou, rokom alebo sekciou nie je zameniteľný dôkaz.
|
||||||
|
Phase rozlišuje assignment (zadanie), planned (plán), progress (priebežný stav)
|
||||||
|
a result (zaznamenaný výsledok). Zaznamenaný výsledok nie je dôkaz dokončenia celej práce.
|
||||||
Pokry každú dostupnú požadovanú časť requirements.facets za každú osobu. Pre chýbajúcu časť
|
Pokry každú dostupnú požadovanú časť requirements.facets za každú osobu. Pre chýbajúcu časť
|
||||||
nevymýšľaj dôkaz: aplikácia ju výslovne označí. Čiastočná doložená odpoveď má status=ok.
|
nevymýšľaj dôkaz: aplikácia ju výslovne označí. Čiastočná doložená odpoveď má status=ok.
|
||||||
Pri výsledkoch použi skutočný zaznamenaný stav; úlohy a plánované experimenty nie sú výsledky.
|
Pri výsledkoch použi skutočný zaznamenaný stav; úlohy a plánované experimenty nie sú výsledky.
|
||||||
@ -212,7 +217,18 @@ def rag_answer_issues(answer: dict, context: dict) -> list[str]:
|
|||||||
elif source.get('source_url') not in cited:
|
elif source.get('source_url') not in cited:
|
||||||
issues.append('missing_quote_source')
|
issues.append('missing_quote_source')
|
||||||
if source:
|
if source:
|
||||||
|
matches=[p for p in context.get('content_passages',[]) if quote_matches_passage(quote,p)]
|
||||||
|
if requirements.get('content'):
|
||||||
|
if quote.get('passage_id') and not matches:
|
||||||
|
issues.append('unsupported_evidence_passage')
|
||||||
|
elif not quote.get('passage_id') and len(matches)>1:
|
||||||
|
identities={(p.get('person'),p.get('work_type'),p.get('year'),normalized(p.get('work_label')),
|
||||||
|
tuple(sorted({(u['kind'],u['phase']) for u in evidence_units(
|
||||||
|
{'content_passages':[p],'question':context.get('question','')},[quote])}))) for p in matches}
|
||||||
|
if len(identities)>1:
|
||||||
|
issues.append('ambiguous_evidence_scope')
|
||||||
if context.get('candidate_only') and not any(c['source_id']==quote['source_id'] and
|
if context.get('candidate_only') and not any(c['source_id']==quote['source_id'] and
|
||||||
|
(not quote.get('passage_id') or quote['passage_id']==c.get('passage_id')) and
|
||||||
' '.join(quote['quote'].split()) in ' '.join(c['text'].split())
|
' '.join(quote['quote'].split()) in ' '.join(c['text'].split())
|
||||||
for c in context.get('content_candidates', [])):
|
for c in context.get('content_candidates', [])):
|
||||||
issues.append('quote_outside_delivered_candidates')
|
issues.append('quote_outside_delivered_candidates')
|
||||||
@ -221,9 +237,7 @@ def rag_answer_issues(answer: dict, context: dict) -> list[str]:
|
|||||||
for key in ('title', 'person', 'document_author')]
|
for key in ('title', 'person', 'document_author')]
|
||||||
if not descriptive_text(quote['quote'], titles):
|
if not descriptive_text(quote['quote'], titles):
|
||||||
issues.append('metadata_quote_does_not_describe_work')
|
issues.append('metadata_quote_does_not_describe_work')
|
||||||
if not any(passage['source_id'] == quote['source_id'] and
|
if not matches:
|
||||||
' '.join(quote['quote'].split()) in ' '.join(passage['text'].split())
|
|
||||||
for passage in context.get('content_passages', [])):
|
|
||||||
issues.append('quote_outside_requested_work')
|
issues.append('quote_outside_requested_work')
|
||||||
if (requested and source.get('document_path', '').startswith(('pages/students/', 'pages/interns/')) and
|
if (requested and source.get('document_path', '').startswith(('pages/students/', 'pages/interns/')) and
|
||||||
not any(source.get('document_path', '').startswith(row['document_path'].removesuffix('README.md'))
|
not any(source.get('document_path', '').startswith(row['document_path'].removesuffix('README.md'))
|
||||||
@ -246,14 +260,12 @@ def rag_answer_issues(answer: dict, context: dict) -> list[str]:
|
|||||||
all(claim.get(key) is None or claim[key] == p.get(key) for key in ['work_type', 'year'])
|
all(claim.get(key) is None or claim[key] == p.get(key) for key in ['work_type', 'year'])
|
||||||
for claim in claims)]
|
for claim in claims)]
|
||||||
eligible = any(c.get('person')==person for c in context.get('content_candidates', []))
|
eligible = any(c.get('person')==person for c in context.get('content_candidates', []))
|
||||||
if (not requirements.get('facets') or eligible) and not any(quote['source_id'] == p['source_id'] and
|
if (not requirements.get('facets') or eligible) and not any(quote_matches_passage(quote,p)
|
||||||
' '.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') and not requirements.get('facets'):
|
if requirements.get('distinguish_progress') and not requirements.get('facets'):
|
||||||
bound_quotes = [quote for quote in quotes if any(
|
bound_quotes = [quote for quote in quotes if any(
|
||||||
quote['source_id'] == p['source_id'] and
|
quote_matches_passage(quote,p) for p in passages)]
|
||||||
' '.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)
|
kinds = {section['kind'] for quote in bound_quotes for section in quote_sections(quote, context)
|
||||||
if descriptive_text(section['text'])}
|
if descriptive_text(section['text'])}
|
||||||
for kind in requirements.get('content_roles', {}).get(person, []):
|
for kind in requirements.get('content_roles', {}).get(person, []):
|
||||||
@ -287,7 +299,7 @@ def _overview_selections(answer: dict, context: dict) -> list[dict]:
|
|||||||
for quote in answer.get('evidence_quotes', []):
|
for quote in answer.get('evidence_quotes', []):
|
||||||
sid = quote['source_id']
|
sid = quote['source_id']
|
||||||
owner = next((p.get('person') for p in context.get('content_passages', [])
|
owner = next((p.get('person') for p in context.get('content_passages', [])
|
||||||
if p['source_id'] == sid and p.get('person')), sid)
|
if quote_matches_passage(quote,p) and p.get('person')), sid)
|
||||||
identities = [row.get(key) for row in context.get('facts', []) if row.get('source_id') == sid
|
identities = [row.get(key) for row in context.get('facts', []) if row.get('source_id') == sid
|
||||||
for key in ('person', 'title', 'document_author')]
|
for key in ('person', 'title', 'document_author')]
|
||||||
blocks = re.split(r'\n\s*\n|(?=^[ \t]*(?:\d+[.)]|[-+*])\s+)', quote['quote'], flags=re.M)
|
blocks = re.split(r'\n\s*\n|(?=^[ \t]*(?:\d+[.)]|[-+*])\s+)', quote['quote'], flags=re.M)
|
||||||
@ -298,21 +310,17 @@ def _overview_selections(answer: dict, context: dict) -> list[dict]:
|
|||||||
if not descriptive_text(block, identities):
|
if not descriptive_text(block, identities):
|
||||||
continue
|
continue
|
||||||
# Prefer complete sentences when a prose paragraph is very long.
|
# Prefer complete sentences when a prose paragraph is very long.
|
||||||
units = re.split(r'(?<=[.!?])\s+(?=[A-ZÁČĎÉÍĽŇÓŠŤÚÝŽ])', block) if len(block) > 600 else [block]
|
qualified=has_qualification(block)
|
||||||
|
units = re.split(r'(?<=[.!?])\s+(?=[A-ZÁČĎÉÍĽŇÓŠŤÚÝŽ])', block) if len(block) > 600 and not qualified else [block]
|
||||||
for unit in units:
|
for unit in units:
|
||||||
remaining = 600 - sizes.get(owner, 0)
|
remaining = 600 - sizes.get(owner, 0)
|
||||||
if counts.get(owner, 0) >= 2 or remaining < 30:
|
if counts.get(owner, 0) >= 2 or (remaining < 30 and not qualified):
|
||||||
break
|
break
|
||||||
unit = unit.strip()
|
unit = unit.strip()
|
||||||
if not descriptive_text(unit, identities) or (owner, unit) in seen:
|
if not descriptive_text(unit, identities) or (owner, unit) in seen:
|
||||||
continue
|
continue
|
||||||
if len(unit) > remaining:
|
if len(unit) > remaining and not qualified and counts.get(owner,0):
|
||||||
# An explicit ellipsis makes a shortened literal prefix
|
|
||||||
# visible; never cut a word/URL and pretend it is complete.
|
|
||||||
prefix = unit[:remaining - 2].rsplit(' ', 1)[0].rstrip()
|
|
||||||
if not prefix or ' ' not in prefix or not descriptive_text(prefix, identities):
|
|
||||||
continue
|
continue
|
||||||
unit = prefix + ' …'
|
|
||||||
selected.append({'evidence': quote, 'text': unit}); seen.add((owner, unit))
|
selected.append({'evidence': quote, 'text': unit}); seen.add((owner, unit))
|
||||||
counts[owner] = counts.get(owner, 0) + 1
|
counts[owner] = counts.get(owner, 0) + 1
|
||||||
sizes[owner] = sizes.get(owner, 0) + len(unit)
|
sizes[owner] = sizes.get(owner, 0) + len(unit)
|
||||||
@ -332,8 +340,7 @@ def comparison_lines(answer: dict, context: dict) -> list[str]:
|
|||||||
lines.extend([work_subject(person, kind, context['question']), ''])
|
lines.extend([work_subject(person, kind, context['question']), ''])
|
||||||
roles = {'assignment': [], 'progress': [], 'task': [], 'other': []}
|
roles = {'assignment': [], 'progress': [], 'task': [], 'other': []}
|
||||||
for quote in answer.get('evidence_quotes', []):
|
for quote in answer.get('evidence_quotes', []):
|
||||||
if not any(p.get('person') == person and p['source_id'] == quote['source_id'] and
|
if not any(p.get('person') == person and quote_matches_passage(quote,p)
|
||||||
' '.join(quote['quote'].split()) in ' '.join(p['text'].split())
|
|
||||||
for p in context.get('content_passages', [])):
|
for p in context.get('content_passages', [])):
|
||||||
continue
|
continue
|
||||||
for section in quote_sections(quote, context):
|
for section in quote_sections(quote, context):
|
||||||
@ -398,8 +405,7 @@ def render_rag_answer(answer: dict, context: dict) -> str:
|
|||||||
for selection in display:
|
for selection in display:
|
||||||
quote = selection['evidence']
|
quote = selection['evidence']
|
||||||
passage = next((p for p in context.get('content_passages', [])
|
passage = next((p for p in context.get('content_passages', [])
|
||||||
if p['source_id'] == quote['source_id'] and
|
if quote_matches_passage(quote,p)), {})
|
||||||
' '.join(quote['quote'].split()) in ' '.join(p['text'].split())), {})
|
|
||||||
identity = (passage.get('person'), passage.get('work_type'), passage.get('year'))
|
identity = (passage.get('person'), passage.get('work_type'), passage.get('year'))
|
||||||
groups.setdefault(identity, []).append(selection['text'])
|
groups.setdefault(identity, []).append(selection['text'])
|
||||||
for (person, kind, _year), selections in groups.items():
|
for (person, kind, _year), selections in groups.items():
|
||||||
|
|||||||
@ -45,7 +45,7 @@ def content_fingerprints(questions: Path, root: Path = ROOT) -> dict[str, Any]:
|
|||||||
'tools/answer_evaluation/checked_generation.py', 'tools/answer_evaluation/answer_format.py',
|
'tools/answer_evaluation/checked_generation.py', 'tools/answer_evaluation/answer_format.py',
|
||||||
'tools/answer_evaluation/render_answer.py', 'tools/answer_evaluation/graph_context.py',
|
'tools/answer_evaluation/render_answer.py', 'tools/answer_evaluation/graph_context.py',
|
||||||
'tools/answer_evaluation/presentation.py',
|
'tools/answer_evaluation/presentation.py',
|
||||||
'tools/answer_evaluation/rag_evidence.py',
|
'tools/answer_evaluation/rag_evidence.py', 'tools/answer_evaluation/verified_rag.py', 'tools/rag/llm_context.py',
|
||||||
'tools/answer_evaluation/question_intent.py', 'tools/answer_evaluation/content_selection.py',
|
'tools/answer_evaluation/question_intent.py', 'tools/answer_evaluation/content_selection.py',
|
||||||
'tools/answer_evaluation/rag_preparation.py', 'tools/answer_evaluation/answer_coverage.py',
|
'tools/answer_evaluation/rag_preparation.py', 'tools/answer_evaluation/answer_coverage.py',
|
||||||
'tools/answer_evaluation/graph_answer_prompt.txt',
|
'tools/answer_evaluation/graph_answer_prompt.txt',
|
||||||
|
|||||||
@ -4,6 +4,7 @@ from __future__ import annotations
|
|||||||
import copy
|
import copy
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
|
|
||||||
from tools.rag.build_chunks import TokenCounter
|
from tools.rag.build_chunks import TokenCounter
|
||||||
from tools.retrieval.text_budget import split_to_fit
|
from tools.retrieval.text_budget import split_to_fit
|
||||||
@ -62,6 +63,60 @@ def compact_rag_payload(data: dict, *, max_tokens: int | None = None,
|
|||||||
def fits(payload):
|
def fits(payload):
|
||||||
return payload['context_budget']['used_tokens'] <= limit
|
return payload['context_budget']['used_tokens'] <= limit
|
||||||
|
|
||||||
|
def whole_prefix(body,end):
|
||||||
|
boundaries=[m.end() for m in re.finditer(
|
||||||
|
r'\n\s*\n|(?=^[ \t]*(?:\d+[.)]|[-+*])\s+|^#{1,6}\s|^Sekcia:)',body,re.M)
|
||||||
|
if m.end()<=end]
|
||||||
|
return body[:max(boundaries,default=0)]
|
||||||
|
|
||||||
|
def balanced_sources(sources):
|
||||||
|
"""Reserve evidence space for every requested person before expanding."""
|
||||||
|
suffix='\n[OBSAH ZDROJA OREZANÝ]'
|
||||||
|
selected=[{**s,'text':suffix if s.get('text') else ''} for s in sources]
|
||||||
|
if not fits(render(selected,truncated=True)):
|
||||||
|
raise ValueError('Rozpočet nestačí na metadáta všetkých porovnávaných osôb')
|
||||||
|
families={s.get('document_path','').removesuffix('README.md'):s['title'] for s in sources
|
||||||
|
if s.get('title') in base['named_subjects'] and s.get('document_path')}
|
||||||
|
groups={}
|
||||||
|
for i,s in enumerate(sources):
|
||||||
|
owner=next((p for path,p in families.items() if s.get('document_path','').startswith(path)),f'other:{i}')
|
||||||
|
groups.setdefault(owner,[]).append(i)
|
||||||
|
|
||||||
|
def used():return render(selected,truncated=True)['context_budget']['used_tokens']
|
||||||
|
|
||||||
|
def expand(index,budget):
|
||||||
|
body=sources[index].get('text') or ''
|
||||||
|
if not body:return
|
||||||
|
ceiling=min(limit,used()+max(0,budget))
|
||||||
|
def candidate(text):
|
||||||
|
replacement={**selected[index],'text':text+(suffix if len(text)<len(body) else '')}
|
||||||
|
return render([*selected[:index],replacement,*selected[index+1:]],truncated=True)
|
||||||
|
if candidate(body)['context_budget']['used_tokens']<=ceiling:
|
||||||
|
text=body
|
||||||
|
else:
|
||||||
|
low,high=0,len(body)-1
|
||||||
|
while low<high:
|
||||||
|
mid=(low+high+1)//2
|
||||||
|
if candidate(body[:mid])['context_budget']['used_tokens']<=ceiling:low=mid
|
||||||
|
else:high=mid-1
|
||||||
|
text=body[:low]
|
||||||
|
# Keep a whole paragraph/list item, including wrapped caveats.
|
||||||
|
text=whole_prefix(body,low)
|
||||||
|
selected[index]={**selected[index],'text':text+(suffix if len(text)<len(body) else '')}
|
||||||
|
|
||||||
|
remaining=limit-used();quota=remaining//max(1,len(groups))
|
||||||
|
for indexes in groups.values():
|
||||||
|
start=used()
|
||||||
|
for pos,index in enumerate(indexes):
|
||||||
|
expand(index,max(0,quota-(used()-start))//(len(indexes)-pos))
|
||||||
|
# Reuse the space left by short profiles, still sharing it by person.
|
||||||
|
remaining=limit-used();quota=remaining//max(1,len(groups))
|
||||||
|
for indexes in groups.values():
|
||||||
|
start=used()
|
||||||
|
for pos,index in enumerate(indexes):
|
||||||
|
expand(index,max(0,quota-(used()-start))//(len(indexes)-pos))
|
||||||
|
return render(selected,truncated=True)
|
||||||
|
|
||||||
# An already projected server response needs no reconstruction by its client.
|
# An already projected server response needs no reconstruction by its client.
|
||||||
if data.get('context_budget'):
|
if data.get('context_budget'):
|
||||||
result = stamp({**base, 'sources': references, 'context': data.get('context', '')})
|
result = stamp({**base, 'sources': references, 'context': data.get('context', '')})
|
||||||
@ -72,7 +127,9 @@ def compact_rag_payload(data: dict, *, max_tokens: int | None = None,
|
|||||||
elif any('text' in source for source in input_sources):
|
elif any('text' in source for source in input_sources):
|
||||||
sources = input_sources
|
sources = input_sources
|
||||||
result = render(sources)
|
result = render(sources)
|
||||||
if not fits(result):
|
if not fits(result) and len(base.get('named_subjects',[]))>1:
|
||||||
|
result=balanced_sources(sources)
|
||||||
|
elif not fits(result):
|
||||||
selected = []
|
selected = []
|
||||||
if not fits(render([], truncated=True)):
|
if not fits(render([], truncated=True)):
|
||||||
raise ValueError('Tokenový rozpočet je menší ako povinné pokyny a metadáta')
|
raise ValueError('Tokenový rozpočet je menší ako povinné pokyny a metadáta')
|
||||||
@ -93,7 +150,7 @@ def compact_rag_payload(data: dict, *, max_tokens: int | None = None,
|
|||||||
middle = (low + high + 1) // 2
|
middle = (low + high + 1) // 2
|
||||||
if body_fits(body[:middle]): low = middle
|
if body_fits(body[:middle]): low = middle
|
||||||
else: high = middle - 1
|
else: high = middle - 1
|
||||||
selected.append({**source, 'text': body[:low] + suffix})
|
selected.append({**source, 'text': whole_prefix(body,low) + suffix})
|
||||||
break
|
break
|
||||||
result = render(selected, truncated=True)
|
result = render(selected, truncated=True)
|
||||||
if not selected:
|
if not selected:
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user