Presnejší výber projektov

This commit is contained in:
Ján Pták 2026-11-04 03:18:03 +01:00
parent dd487d0c05
commit 8335c69a4a
21 changed files with 385 additions and 61 deletions

View File

@ -1160,7 +1160,7 @@ upozornenie na neúplnosť, nezamieňať autora dokumentu s autorom práce a pri
sa nevysvetľujú odhadom; verzia dát neoznačuje verziu programu. sa nevysvetľujú odhadom; verzia dát neoznačuje verziu programu.
Tieto pokyny ovplyvňujú správanie Claude, jeho finálny text však náš backend Tieto pokyny ovplyvňujú správanie Claude, jeho finálny text však náš backend
nemôže programovo vynútiť. Po aktualizácii už nahratých Skills nahraď ich nemôže programovo vynútiť. Po aktualizácii už nahratých Skills nahraď ich
novými ZIPmi z `packages/claude/dist/` (verzia 1.4.0) a over správanie v novom chate. novými ZIPmi z `packages/claude/dist/` (verzia 1.5.0) a over správanie v novom chate.
### Nahratie Skills do webového Claude ### Nahratie Skills do webového Claude
@ -1513,6 +1513,14 @@ Pri názve a roku práce model dostáva typované fakty a stručné zdroje. RAG
nepoužívajú grafové `record_id` ani `work_label`; každé tvrdenie musí mať doloženú nepoužívajú grafové `record_id` ani `work_label`; každé tvrdenie musí mať doloženú
wiki URL. Označenie zdroja ako `S1` nenahrádza citáciu. wiki URL. Označenie zdroja ako `S1` nenahrádza citáciu.
Obsahové otázky podporujú presný nadpis práce v úvodzovkách, číslo projektu,
nečíslovaný projekt, vylúčenie konkrétneho čísla aj otázku na oba projekty.
Každá práca si zachová vlastné dôkazy a pokrytie požadovaných aspektov.
Odpoveď „obe“ vyžaduje doplnenie pôvodnej otázky z predchádzajúceho spresnenia
v Claude; stateless backend si konverzáciu nedomýšľa. Nadpisy úloh na ďalšie
stretnutie označujú plán. Otázka na skutočne používané metódy vyberá záznamy
o používaní alebo implementácii, nie samotné pokyny na štúdium či návrh.
Pri obsahovom porovnaní viacerých študentov sa pre každého načíta požadovaný typ 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 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. tokenovému rozpočtu. Zadanie a zaznamenaný stav sa kontrolujú a zobrazujú oddelene.

View File

@ -3,12 +3,12 @@
{ {
"archive": "zpwiki-rag.zip", "archive": "zpwiki-rag.zip",
"name": "rag", "name": "rag",
"sha256": "0c4b1a65d7ec97e89505000c7f60239ae220098ba11dc8a6ae9fca8be396f151" "sha256": "8e5ab92284ee6f53d10e468cb4edee6cfd354ae869583673cbb6aea8ea9ca437"
}, },
{ {
"archive": "zpwiki-graphrag.zip", "archive": "zpwiki-graphrag.zip",
"name": "graphrag", "name": "graphrag",
"sha256": "cbfd609ec66c24a03e4c75b33e5a92438a1131c25019df8f2ea522bc917ba5e3" "sha256": "99a9ea36b439d6b14edc35c2d4d0c1c2cf97139f78e8fd73b8ce2347d82b1092"
} }
], ],
"schema_version": 1 "schema_version": 1

Binary file not shown.

Binary file not shown.

View File

@ -10,12 +10,16 @@ For `/graphrag <question>`, send the text after the leading `/graphrag` as the q
to `zpwiki_graphrag`. Preserve every condition. If no question follows, ask for one. to `zpwiki_graphrag`. Preserve every condition. If no question follows, ask for one.
Use this graph tool rather than RAG when the user explicitly selects `/graphrag`. Use this graph tool rather than RAG when the user explicitly selects `/graphrag`.
For every call, copy the latest user question verbatim after removing only its leading For a complete question, copy the latest user question verbatim after removing only its leading
command. Do not reuse an earlier question or an example, paraphrase, silently correct a command. Do not reuse an earlier question or an example, paraphrase, silently correct a
name, add a year or drop a condition. Before calling, check names, work types, years, name, add a year or drop a condition. Before calling, check names, work types, years,
negations and comparisons against the current message. Before presenting the result, negations and comparisons against the current message. Before presenting the result,
check that its `query` matches that question. A mismatch is not a usable answer. check that its `query` matches that question. A mismatch is not a usable answer.
If clarification is needed, ask specifically for the missing or ambiguous condition. If clarification is needed, ask specifically for the missing or ambiguous condition.
For a short reply selecting the immediately preceding options, reconstruct that original
full question and apply only the selection. Preserve every condition; if context is missing,
ask for it. Compare the returned `query` against that resolved full question. A text question
about methods or progress belongs to RAG; preserve any backend guidance instead of inventing graph evidence.
For multiple explicit commands in one message, handle each question separately in order; For multiple explicit commands in one message, handle each question separately in order;
keep all conditions belonging to each question together. keep all conditions belonging to each question together.

View File

@ -20,11 +20,16 @@ from starlette.responses import JSONResponse
# Host-model rules belong to MCP initialization and Skills, not tool-result data. # Host-model rules belong to MCP initialization and Skills, not tool-result data.
QUESTION_POLICY = ( QUESTION_POLICY = (
'For each tool call, copy the latest user question verbatim after removing only its leading ' 'For a complete question, copy the latest user question verbatim after removing only its leading '
'/rag or /graphrag command. Keep names, work types, years, negations, comparisons and every condition. ' '/rag or /graphrag command. Keep names, work types, years, negations, comparisons and every condition. '
'Never reuse a previous question or an example, paraphrase it or split its conditions across searches. ' 'Never reuse a previous question or an example, paraphrase it or split its conditions across searches. '
'If the user supplies multiple explicit commands, handle each exact question separately in order. ' 'If the user supplies multiple explicit commands, handle each exact question separately in order. '
'Before calling, compare the query with the current user message. Respect the explicitly selected engine. ' 'For a short reply selecting options from the immediately preceding clarification, reconstruct the original '
'full question and apply only that selection. Preserve its person, year, work type and requested aspects. '
'Use exact quoted work headings for a selected work; if the user chooses both, ask about both works in one full query. '
'If that context is missing, ask for it instead of guessing. '
'Before calling, compare the query with the current complete question or its explicitly resolved clarification. '
'Respect the explicitly selected engine. '
'The returned query is audit data: compare it with the current question before presenting its answer. ' 'The returned query is audit data: compare it with the current question before presenting its answer. '
'If they differ, do not present the mismatched answer. Ask for a concrete clarification when needed. ' 'If they differ, do not present the mismatched answer. Ask for a concrete clarification when needed. '
) )

View File

@ -10,12 +10,18 @@ For `/rag <question>`, send the text after the leading `/rag` as the question to
`zpwiki_rag`. Preserve every condition. If no question follows, ask for one. `zpwiki_rag`. Preserve every condition. If no question follows, ask for one.
Use this text-search tool rather than GraphRAG when the user explicitly selects `/rag`. Use this text-search tool rather than GraphRAG when the user explicitly selects `/rag`.
For every call, copy the latest user question verbatim after removing only its leading For a complete question, copy the latest user question verbatim after removing only its leading
command. Do not reuse an earlier question or an example, paraphrase, silently correct a command. Do not reuse an earlier question or an example, paraphrase, silently correct a
name, add a year or drop a condition. Before calling, check names, work types, years, name, add a year or drop a condition. Before calling, check names, work types, years,
negations and comparisons against the current message. Before presenting the result, negations and comparisons against the current message. Before presenting the result,
check that its `query` matches that question. A mismatch is not a usable answer. check that its `query` matches that question. A mismatch is not a usable answer.
If clarification is needed, ask specifically for the missing or ambiguous condition. If clarification is needed, ask specifically for the missing or ambiguous condition.
For a short reply selecting the immediately preceding options, reconstruct that original
full question and apply only the selection. Preserve the person, year, work type and
requested aspects. For one work, use its exact quoted heading; for "both", request both
works in one query and keep their evidence separate. Do not search the words "both" alone.
If the preceding question/options are unavailable, ask for them rather than guessing.
Compare the returned `query` against this resolved full question in that case.
For multiple explicit commands in one message, handle each question separately in order; For multiple explicit commands in one message, handle each question separately in order;
keep all conditions belonging to each question together. keep all conditions belonging to each question together.

View File

@ -11,7 +11,7 @@ import zipfile
ROOT = Path(__file__).resolve().parents[1] ROOT = Path(__file__).resolve().parents[1]
BASE = ROOT / 'packages/claude' BASE = ROOT / 'packages/claude'
ENGINES = ('rag', 'graphrag') ENGINES = ('rag', 'graphrag')
PACKAGE_VERSION = '1.4.0' PACKAGE_VERSION = '1.5.0'
def json_bytes(value): def json_bytes(value):

View File

@ -0,0 +1,152 @@
"""Exact/both/negated work selection and proof of actual use, not future tasks."""
import json
import sqlite3
import pytest
from tools.answer_evaluation.rag_preparation import prepare_named_content
from tools.answer_evaluation.rag_evidence import rag_evidence_context,project_number,content_sections
from tools.answer_evaluation.verified_rag import generate_verified_rag
from tools.answer_evaluation.content_selection import facets_for
from tools.retrieval.work_selector import parse_work_selector
from tools.retrieval.question_constraints import requested_years,unsupported_constraint
from tools.answers import answer_delivery
from tools.rag.llm_context import compact_rag_payload
@pytest.fixture
def projects(tmp_path):
db=tmp_path/'index.sqlite';path='pages/students/2016/eva_nova/README.md'
with sqlite3.connect(db) as c:
c.execute('CREATE TABLE documents(path,title,author,published)')
c.execute('CREATE TABLE chunks(document_path,text,heading_paths_json,published,chunk_index)')
c.execute('INSERT INTO documents VALUES(?,?,?,?)',(path,'Eva Nová','Autor dokumentu',1))
for i,(label,text) in enumerate([
('Diplomový projekt 2020','Hotové:\n- Funguje databáza SQLite.\nÚlohy na ďalšie stretnutie:\n- Naštudujte si ako sa používa Redis.'),
('Diplomový projekt 2 2020','Stav:\n- Implementovaný parser pomocou Python.\nÚlohy na ďalšie stretnutie:\n- urobte návrh metódy PageRank.\nDo ďalšieho stretnutia:\n- Naštudujte si ako sa používa Promise.')]):
c.execute('INSERT INTO chunks VALUES(?,?,?,?,?)',(path,text,json.dumps([['Eva Nová',label]]),1,i))
return db
def prepare(db,q):
raw={'status':'ok','reason':None,'sources':[],'context':'','complete':False,'truncated':True,'truncation_reasons':['result_limit']}
return compact_rag_payload(prepare_named_content(db,q,raw))
def select(messages):
context=json.loads(messages[1]['content'])['context']
a={'status':'ok','claims':[],'source_urls':[],
'evidence_ids':[u['evidence_id'] for u in context.get('content_candidates',[])]}
return {'choices':[{'message':{'content':json.dumps(a)}}]}
@pytest.mark.parametrize('phrase',[
'pri práci "Diplomový projekt 2020" (nie Diplomový projekt 2)',
'pri nečíslovanom diplomovom projekte v roku 2020',
'pri diplomovom projekte bez čísla v roku 2020',
'pri diplomovom projekte v roku 2020 okrem projektu 2',
])
def test_exact_unnumbered_and_excluded_number_select_the_actual_work(projects,phrase):
q='Aké metódy používala Eva Nová '+phrase+'?'
assert not unsupported_constraint(q) and requested_years(q)==[2020]
p=prepare(projects,q);assert p['status']=='ok'
assert [s['work_label'] for s in p['work_scopes']]==['Diplomový projekt 2020']
r=generate_verified_rag(q,p,select)
assert r['accepted_for_delivery'] and 'SQLite' in r['answer']
assert all(x not in r['answer'] for x in ['Python','PageRank','Promise','Redis'])
def test_numbered_work_keeps_actual_methods_and_rejects_future_task_evidence(projects):
q='Aké metódy používala Eva Nová pri diplomovom projekte 2 v roku 2020?'
p=prepare(projects,q);c=rag_evidence_context(q,p)
assert all(u['phase'] not in {'planned','assignment'} for u in c['content_candidates'])
r=generate_verified_rag(q,p,select)
assert r['accepted_for_delivery'] and 'Python' in r['answer']
assert 'PageRank' not in r['answer'] and 'Promise' not in r['answer'] and 'SQLite' not in r['answer']
bad={'status':'ok','claims':[],'source_urls':[s['source_url'] for s in c['sources']],
'evidence_quotes':[{'source_id':c['sources'][0]['source_id'],'quote':'- urobte návrh metódy PageRank.'}]}
r=generate_verified_rag(q,p,lambda _:{'choices':[{'message':{'content':json.dumps(bad)}}]})
assert not r['accepted_for_delivery'] and 'quote_outside_delivered_candidates' in r['attempts'][-1]['issues']
@pytest.mark.parametrize('word',['oboch','obidvoch','všetkých'])
def test_each_requested_work_has_its_own_methods_and_heading(projects,word):
q=f'Aké metódy používala Eva Nová pri {word} diplomových projektoch v roku 2020?'
p=prepare(projects,q);c=rag_evidence_context(q,p)
assert c['requirements']['separate_works'] and len(p['work_scopes'])==2
r=generate_verified_rag(q,p,select)
assert r['accepted_for_delivery'] and r['answer'].count('**Eva Nová — Diplomový projekt')==2
first,second=r['answer'].split('**Eva Nová — Diplomový projekt 2 2020')
assert 'SQLite' in first and 'Python' not in first and 'Python' in second
assert 'PageRank' not in r['answer'] and 'Promise' not in r['answer']
def test_omitting_a_second_work_with_evidence_is_rejected_even_for_the_same_person_and_year(projects):
q='Aké metódy používala Eva Nová pri oboch diplomových projektoch v roku 2020?'
p=prepare(projects,q)
def incomplete(messages):
c=json.loads(messages[1]['content'])['context']
a={'status':'ok','claims':[],'source_urls':[],'evidence_ids':[u['evidence_id'] for u in c['content_candidates'] if u['work_label']=='Diplomový projekt 2020']}
return {'choices':[{'message':{'content':json.dumps(a)}}]}
r=generate_verified_rag(q,p,incomplete)
assert not r['accepted_for_delivery'] and 'missing_methods_evidence' in r['attempts'][-1]['issues']
def test_missing_methods_are_reported_for_the_right_work_without_stealing_another_works_evidence(projects):
with sqlite3.connect(projects) as c:
c.execute("UPDATE chunks SET text='Úlohy na ďalšie stretnutie:\n- Použite metódu BERT.' WHERE chunk_index=1")
q='Aké metódy používala Eva Nová pri oboch diplomových projektoch v roku 2020?'
r=generate_verified_rag(q,prepare(projects,q),select)
assert r['accepted_for_delivery'] and r['answer_coverage']['status']=='partial'
assert 'SQLite' in r['answer'] and 'BERT' not in r['answer']
assert 'Diplomový projekt 2 2020' in r['answer'] and 'skutočne použitých metódach' in r['answer']
def test_plans_remain_available_as_plans_with_natural_verbs(projects):
q='Aké plánované úlohy mala Eva Nová pri oboch diplomových projektoch v roku 2020?'
r=generate_verified_rag(q,prepare(projects,q),select)
assert r['accepted_for_delivery']
assert 'Urobiť návrh metódy PageRank.' in r['answer'] and 'Naštudovať si ako sa používa Promise.' in r['answer']
assert 'urobte' not in r['answer'] and 'Naštudujte' not in r['answer']
def test_unknown_requested_work_is_not_silently_dropped(projects):
q='Porovnaj metódy Evy Novej pri "Diplomový projekt 2020" a "Diplomový projekt 3 2020".'
p=prepare(projects,q)
assert p['status']=='ambiguous' and p['reason']=='unresolved_work_selection'
def test_short_followup_needs_context_instead_of_global_retrieval(monkeypatch):
def forbidden(*a,**kw):pytest.fail('A bare follow-up must not search or call an LLM')
monkeypatch.setattr(answer_delivery,'build_rag_context',forbidden)
r=answer_delivery.deliver_answer('daj obe',engine='rag',complete=forbidden)
assert r['status']=='ambiguous' and r['reason']=='missing_followup_context'
def test_negative_selector_is_not_a_positive_number_and_literal_title_years_are_not_filters():
q='Aké metódy používal Ján Holp pri "Diplomový projekt 2020" (nie Diplomový projekt 2)?'
selector=parse_work_selector(q)
assert project_number(q) is None and selector.excluded_numbers==(2,) and requested_years(q)==[2020]
assert selector.matches('Diplomový projekt 2020') and not selector.matches('Diplomový projekt 2 2020')
assert requested_years('Kto má názov "Projekt 2 o svete 2099"?')==[]
assert not parse_work_selector('Kto má názov "Projekt 2 o svete 2099"?').specific
assert unsupported_constraint('Aké metódy používal Ján Holp, nie v roku 2020?')=='exclusion'
def test_unimplemented_method_is_not_proof_of_actual_use():
assert 'methods' not in facets_for('Metóda BERT ešte nie je implementovaná.','progress',
'Aké metódy skutočne použila Eva Nová?')
def test_scalar_question_also_preserves_exact_work_and_exclusion(projects):
q='Aký názov má "Diplomový projekt 2020" Evy Novej (nie Diplomový projekt 2)?'
p=prepare(projects,q)
assert len(p['work_scopes'])==1 and p['work_scopes'][0]['work_label']=='Diplomový projekt 2020'
assert 'Python' not in p['context'] and 'Promise' not in p['context']
@pytest.mark.parametrize('heading',['Úlohy na ďalšie stretnutie:','Do ďalšieho stretnutia:','Do ďalšiehio stretnutia:'])
def test_source_task_headings_cannot_inherit_a_completed_state(heading):
sections=content_sections('Stav:\n- Implementovaný parser.\n'+heading+'\n- urobte návrh metódy PageRank.')
assert [s['kind'] for s in sections]==['progress','task']
assert sections[-1]['phase']=='planned'

View File

@ -3,7 +3,7 @@ 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, requested_facets from .question_intent import FACET_LABELS, planned_experiments, requested_facets, actual_methods_requested
COMPLETED = (r'\b(?:(?:implementovan|natrenovan|vytvoren|extrahovan|vykonan|vyhodnoten|nameran|' COMPLETED = (r'\b(?:(?:implementovan|natrenovan|vytvoren|extrahovan|vykonan|vyhodnoten|nameran|'
r'vyriesen|odstranen|otestovan|porovnan|dosiahnut|dokoncen)(?:y|a|e|u|ou|ych|ym|ymi|om)|' r'vyriesen|odstranen|otestovan|porovnan|dosiahnut|dokoncen)(?:y|a|e|u|ou|ych|ym|ymi|om)|'
@ -38,7 +38,7 @@ 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(re.sub(r'^\s*(?:\d+[.)]|[-+*])\s+','',text))
if role in {'other','progress'}: 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 ''
@ -71,10 +71,11 @@ def facets_for(text: str, role: str, question: str, phase: str | None = None) ->
if (phase or evidence_phase(text,role))=='result':facets.append('results') 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*|indexov\w*|databaz\w*|webov\w* rozhrani\w*)\b', n):
actual_use=(re.search(r'\b(?:skutocne|pouziva\w*|pouzil\w*|vyuzival\w*|implementoval\w*)\b',normalized(question)) or actual_use=actual_methods_requested(question)
re.search(r'\b(?:pouzit\w*|aplikovan\w*|vyuzit\w*)\s+(?:metod\w*|technolog\w*|nastroj\w*)\b',normalized(question))) actual_statement=not re.search(NOT_COMPLETED,n) and (re.search(COMPLETED,n) or
if role not in {'assignment','task'} or not actual_use: re.search(r'\b(?:pouziva\w*|pouzil\w*|vyuziva\w*|vyuzil\w*|vieme indexov\w*)\b',n))
if not actual_use or (role not in {'assignment','task'} and actual_statement):
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*|vykonan\w*|otestoval\w*|uskutocnil\w*|uskutocnen\w*|realizovan\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))
@ -159,13 +160,29 @@ def evidence_units(context: dict, quotes: list[dict] | None = None) -> list[dict
for other in units)] for other in units)]
def scope_groups(context: dict) -> list[tuple]:
req=context.get('requirements',{});groups=[]
for person in req.get('people',[]):
labels=list(dict.fromkeys(s.get('work_label') for s in context.get('work_scopes',[]) if s.get('person')==person))
groups.extend((person,label) for label in labels) if req.get('separate_works') and labels else groups.append((person,None))
return groups
def matches_scope(row: dict, person: str, label: str | None) -> bool:
return row.get('person')==person and (label is None or normalized(row.get('work_label'))==normalized(label))
def scope_context(context: dict, person: str, label: str | None) -> dict:
return {**context,'content_passages':[p for p in context.get('content_passages',[]) if matches_scope(p,person,label)]}
def candidates(context: dict) -> list[dict]: def candidates(context: dict) -> list[dict]:
from .evidence_selection import identify from .evidence_selection import identify
facets = context['requirements'].get('facets', []) facets = context['requirements'].get('facets', [])
selected = []; seen = set(); units = evidence_units(context) selected = []; seen = set(); units = evidence_units(context)
for person in context['requirements']['people']: for person,label in scope_groups(context):
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 matches_scope(u,person,label) and facet in u['facets']]
if facet=='progress' and 'results' in facets and any(u['phase']=='progress' for u in relevant): 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=[u for u in relevant if u['phase']=='progress']
if facet!='assignment':relevant.sort(key=lambda u: -unit_score(u['text'],u['kind'],facet)) if facet!='assignment':relevant.sort(key=lambda u: -unit_score(u['text'],u['kind'],facet))
@ -186,10 +203,14 @@ def candidates(context: dict) -> list[dict]:
def missing_facets(answer: dict, context: dict) -> list[tuple[str,str]]: def missing_facets(answer: dict, context: dict) -> list[tuple[str,str]]:
return list(dict.fromkeys((person,facet) for person,_label,facet in missing_work_facets(answer,context)))
def missing_work_facets(answer: dict, context: dict) -> list[tuple]:
found = evidence_units(context, answer.get('evidence_quotes', [])) found = evidence_units(context, answer.get('evidence_quotes', []))
return [(person,facet) for person in context['requirements']['people'] return [(person,label,facet) for person,label in scope_groups(context)
for facet in context['requirements'].get('facets', []) for facet in context['requirements'].get('facets', [])
if not any(row['person']==person and facet in row['facets'] for row in found)] if not any(matches_scope(row,person,label) and facet in row['facets'] for row in found)]
def fit_candidates(sent: dict, context: dict, limit: int, encoding: str) -> None: def fit_candidates(sent: dict, context: dict, limit: int, encoding: str) -> None:
@ -200,8 +221,7 @@ def fit_candidates(sent: dict, context: dict, limit: int, encoding: str) -> None
if counter.encoding is None: if counter.encoding is None:
raise RuntimeError('Missing context tokenizer') raise RuntimeError('Missing context tokenizer')
original=list(context.get('content_candidates',[])) original=list(context.get('content_candidates',[]))
groups=[[row for row in original if row.get('person')==person] groups=[[row for row in original if matches_scope(row,person,label)] for person,label in scope_groups(context)]
for person in context['requirements']['people']]
ordered=[group[i] for i in range(max(map(len,groups),default=0)) for group in groups if i<len(group)] ordered=[group[i] for i in range(max(map(len,groups),default=0)) for group in groups if i<len(group)]
selected=[] selected=[]
for row in ordered: for row in ordered:
@ -225,10 +245,11 @@ def facet_lines(answer: dict, context: dict) -> list[str]:
facets=req.get('facets',[]);groups=[];labels={f:FACET_LABELS[f] for f in facets} facets=req.get('facets',[]);groups=[];labels={f:FACET_LABELS[f] for f in facets}
if 'experiments' in facets and planned_experiments(context.get('question','')): if 'experiments' in facets and planned_experiments(context.get('question','')):
labels['experiments']='Plánované experimenty a vyhodnotenie' labels['experiments']='Plánované experimenty a vyhodnotenie'
for person in req['people']: for person,work_label in scope_groups(context):
group={'subject':evidence_subject(person,context),'items':{},'missing':[]};seen=set() group={'subject':evidence_subject(person,scope_context(context,person,work_label)) if work_label is None else
person+' — '+' '.join(work_label.split()),'items':{},'missing':[]};seen=set()
for facet in facets: for facet in facets:
rows=[u for u in units if u['person']==person and facet in u['facets']] rows=[u for u in units if matches_scope(u,person,work_label) and facet in u['facets']]
if facet=='progress' and 'results' in facets and any(u['phase']=='progress' for u in rows): if facet=='progress' and 'results' in facets and any(u['phase']=='progress' for u in rows):
rows=[u for u in rows if u['phase']=='progress'] rows=[u for u in rows if u['phase']=='progress']
if facet!='assignment':rows.sort(key=lambda u:-unit_score(u['text'],u['kind'],facet)) if facet!='assignment':rows.sort(key=lambda u:-unit_score(u['text'],u['kind'],facet))

View File

@ -20,10 +20,10 @@ def identify(row: dict, *, required: bool = False) -> dict:
def overview_candidates(context: dict) -> list[dict]: def overview_candidates(context: dict) -> list[dict]:
"""Keep every main assignment item; do not confuse a backlog with goals.""" """Keep every main assignment item; do not confuse a backlog with goals."""
from .content_selection import evidence_units, administrative_only, unit_score from .content_selection import evidence_units, administrative_only, unit_score, scope_groups, matches_scope
rows=evidence_units(context); selected=[] rows=evidence_units(context); selected=[]
for person in context['requirements']['people']: for person,label in scope_groups(context):
own=[row for row in rows if row['person']==person] own=[row for row in rows if matches_scope(row,person,label)]
main=[row for row in own if row['kind']=='assignment'] main=[row for row in own if row['kind']=='assignment']
# Without an explicit assignment, retain a bounded descriptive sample. # Without an explicit assignment, retain a bounded descriptive sample.
# A plan or meeting history cannot become the main assignment. # A plan or meeting history cannot become the main assignment.
@ -71,11 +71,11 @@ def missing_required_evidence(answer: dict, context: dict) -> list[str]:
def overview_units(answer: dict, context: dict) -> list[dict]: def overview_units(answer: dict, context: dict) -> list[dict]:
"""Render all checked assignment items and at most two fallback units per person.""" """Render all checked assignment items and at most two fallback units per person."""
from .content_selection import evidence_units from .content_selection import evidence_units,scope_groups,matches_scope
rows=evidence_units(context,answer.get('evidence_quotes',[])) rows=evidence_units(context,answer.get('evidence_quotes',[]))
selected=[] selected=[]
for person in context.get('requirements',{}).get('people',[]): for person,label in scope_groups(context):
own=[row for row in rows if row['person']==person] own=[row for row in rows if matches_scope(row,person,label)]
main=[row for row in own if row['kind']=='assignment'] main=[row for row in own if row['kind']=='assignment']
for row in main or own[:2]: for row in main or own[:2]:
quote=next(q for q in answer['evidence_quotes'] if q['source_id']==row['source_id'] and quote=next(q for q in answer['evidence_quotes'] if q['source_id']==row['source_id'] and

View File

@ -108,6 +108,8 @@ GOAL_VERBS = {
'identifikujte': 'Identifikovať', 'preštudujte': 'Preštudovať', 'identifikujte': 'Identifikovať', 'preštudujte': 'Preštudovať',
'pripravte': 'Pripraviť', 'pripravte': 'Pripraviť',
'vyhodnotte': 'Vyhodnotiť', 'vyhodnnotte': 'Vyhodnotiť', 'vyhodnotte': 'Vyhodnotiť', 'vyhodnnotte': 'Vyhodnotiť',
'urobte':'Urobiť','naštudujte':'Naštudovať','pozrite':'Pozrieť',
'prečítajte':'Prečítať','pokračujte':'Pokračovať',
} }
@ -203,9 +205,10 @@ def no_answer_text(context: dict) -> str:
def missing_aspects(facets: list[str], question: str = '') -> str: def missing_aspects(facets: list[str], question: str = '') -> str:
"""Name only the missing requested fields, without empty helper sections.""" """Name only the missing requested fields, without empty helper sections."""
from .question_intent import planned_experiments from .question_intent import planned_experiments,actual_methods_requested
labels={'assignment':'zadaní a cieľoch','plans':'plánovaných úlohách','progress':'priebežnom stave', labels={'assignment':'zadaní a cieľoch','plans':'plánovaných úlohách','progress':'priebežnom stave',
'results':'výsledkoch','problems':'riešených problémoch','methods':'metódach a technológiách', 'results':'výsledkoch','problems':'riešených problémoch','methods':('skutočne použitých metódach a technológiách'
if actual_methods_requested(question) else 'metódach a technológiách'),
'experiments':('plánovaných experimentoch' if planned_experiments(question) else 'experimentoch a ich vyhodnotení')} 'experiments':('plánovaných experimentoch' if planned_experiments(question) else 'experimentoch a ich vyhodnotení')}
values=list(dict.fromkeys(labels.get(f,f) for f in facets)) values=list(dict.fromkeys(labels.get(f,f) for f in facets))
if not values:return '' if not values:return ''
@ -231,7 +234,7 @@ def evidence_subject(person: str, context: dict) -> str:
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 passages if p.get('work_label')} labels={p.get('work_label') for p in passages if p.get('work_label')}
if req.get('project_number') is not None and len(labels)==1: if (req.get('project_number') is not None or req.get('exact_work_label')) and len(labels)==1:
subject=person+' — '+' '.join(next(iter(labels)).split()) subject=person+' — '+' '.join(next(iter(labels)).split())
return subject return subject

View File

@ -34,6 +34,16 @@ def facet_mode(question: str) -> bool:
return any(facet in facets for facet in ['plans','progress','problems','methods','experiments','results']) return any(facet in facets for facet in ['plans','progress','problems','methods','experiments','results'])
def actual_methods_requested(question: str) -> bool:
return bool(re.search(r'\b(?:skutocne|pouziva\w*|pouzil\w*|vyuzival\w*|implementoval\w*)\b|'
r'\b(?:pouzit\w*|aplikovan\w*|vyuzit\w*)\s+(?:metod\w*|technolog\w*|nastroj\w*)\b',normalized(question)))
def unresolved_followup(question: str) -> bool:
return bool(re.fullmatch(r'(?:(?:daj|uved|ukaz|vypis|porovnaj|chcem|prosim)\s+)?'
r'(?:obe|oba|obidv\w*|vsetky|prvy|druhy)(?:\s+(?:prace|projekty))?[.!?]?',normalized(question)))
def output_year_requested(question: str) -> bool: def output_year_requested(question: str) -> bool:
# A supplied year scopes the evidence; it does not itself ask for a year claim. # A supplied year scopes the evidence; it does not itself ask for a year claim.
text=normalized(question) text=normalized(question)
@ -46,7 +56,7 @@ def graph_needs_text(question: str) -> bool:
# Explicit graph properties stay on their existing path. # Explicit graph properties stay on their existing path.
if re.search(r'\b(?:tag\w*|klucov\w*|kategor\w*|tem\w*|nazov\w*|autor\w*)\b',text): if re.search(r'\b(?:tag\w*|klucov\w*|kategor\w*|tem\w*|nazov\w*|autor\w*)\b',text):
return False return False
return bool(set(requested_facets(question)) & {'assignment','plans','problems','experiments','progress','results'}) return bool(set(requested_facets(question)) & {'assignment','plans','problems','methods','experiments','progress','results'})
def planned_experiments(question: str) -> bool: def planned_experiments(question: str) -> bool:

View File

@ -128,8 +128,9 @@ def requested_types(question: str, people: list[str]) -> dict[str, str]:
def project_number(text: str) -> int | None: def project_number(text: str) -> int | None:
match=re.search(r'\bprojekt\w*\s+([1-9]\d?)(?!\d)\b',normalized(text)) from tools.retrieval.work_selector import parse_work_selector
return int(match[1]) if match else None numbers=parse_work_selector(text).numbers
return numbers[0] if len(numbers)==1 else None
def academic_windows_metadata(body: str) -> list[tuple]: def academic_windows_metadata(body: str) -> list[tuple]:
@ -209,6 +210,8 @@ def descriptive_text(text: str, titles=()) -> str:
def academic_content_passages(blocks: list[dict], facts: list[dict], requirements: dict, question: str) -> list[dict]: def academic_content_passages(blocks: list[dict], facts: list[dict], requirements: dict, question: str) -> list[dict]:
"""Project only retrieved text, respecting person, work type and year together.""" """Project only retrieved text, respecting person, work type and year together."""
from tools.retrieval.work_selector import parse_work_selector
selector=parse_work_selector(question)
result = []; requested = requirements['people'] result = []; requested = requirements['people']
profiles = [row['document_path'].removesuffix('README.md') for row in blocks profiles = [row['document_path'].removesuffix('README.md') for row in blocks
if row.get('title') in requested and PROFILE.fullmatch(row.get('document_path', ''))] if row.get('title') in requested and PROFILE.fullmatch(row.get('document_path', ''))]
@ -227,7 +230,7 @@ def academic_content_passages(blocks: list[dict], facts: list[dict], requirement
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']: if not selector.matches(label or ''):
continue continue
if not descriptive_text(text, titles): if not descriptive_text(text, titles):
continue continue
@ -258,9 +261,10 @@ def content_sections(text: str) -> list[dict]:
'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 role_value in {'stav', 'priebezny stav', 'vysledky', 'dosiahnute vysledky', 'dosiahnuty stav'}: elif heading and role_value in {'stav', 'priebezny stav', 'vysledky', 'dosiahnute vysledky', 'dosiahnuty stav','hotove','urobene'}:
next_kind = 'progress' next_kind = 'progress'
elif heading and role_value in {'ulohy', 'zasobnik uloh', 'dalsie ulohy', 'plan', 'planovane ulohy'}: elif heading and (role_value in {'ulohy', 'zasobnik uloh', 'dalsie ulohy', 'plan', 'planovane ulohy'} or
re.match(r'^(?:ulohy\s+(?:na|do|pre)\b|do dalsie\w* stretnutia\b)',role_value)):
next_kind = 'task' next_kind = 'task'
elif heading and role_value in {'napr','napriklad','priklady','priklad'} and not line[:1].isspace(): elif heading and role_value in {'napr','napriklad','priklady','priklad'} and not line[:1].isspace():
next_kind = 'other' next_kind = 'other'
@ -269,7 +273,7 @@ def content_sections(text: str) -> list[dict]:
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 phase=({'assignment':'assignment','task':'planned','other':'unknown'}.get(kind) or
('result' if role_value in {'vysledky','dosiahnute vysledky'} else 'progress')) ('result' if role_value in {'vysledky','dosiahnute vysledky','hotove','urobene'} else 'progress'))
else: else:
lines.append(line) lines.append(line)
flush() flush()
@ -441,7 +445,9 @@ def rag_evidence_context(question: str, payload: dict) -> dict:
facts = list({tuple(sorted(row.items())): row for row in facts}.values()) facts = list({tuple(sorted(row.items())): row for row in facts}.values())
requested = [person for person in people if text_contains_value(question, person, person=True)] requested = [person for person in people if text_contains_value(question, person, person=True)]
requested=list(dict.fromkeys([*payload.get('named_subjects',[]),*requested])) requested=list(dict.fromkeys([*payload.get('named_subjects',[]),*requested]))
expected_types = requested_types(question, requested) from tools.retrieval.work_selector import parse_work_selector
selector=parse_work_selector(question)
expected_types = requested_types(selector.positive_text, requested)
query = normalized(question) query = normalized(question)
intent_query = normalized(re.sub(r'[„“"«][^„“”"»]+[“”"»]', ' ', question)) intent_query = normalized(re.sub(r'[„“"«][^„“”"»]+[“”"»]', ' ', question))
academic = bool(TYPE_PATTERN.search(query) or re.search(r'\b(?:rok\w* prac\w*|nazov\w* prac\w*)\b', query)) academic = bool(TYPE_PATTERN.search(query) or re.search(r'\b(?:rok\w* prac\w*|nazov\w* prac\w*)\b', query))
@ -476,6 +482,7 @@ def rag_evidence_context(question: str, payload: dict) -> dict:
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) requirements['project_number'] = project_number(question)
requirements['exact_work_label']=selector.labels[0] if len(selector.labels)==1 else None
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']
@ -489,6 +496,11 @@ 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)
context['work_scopes']=payload.get('work_scopes') or list({
(p.get('person'),p.get('work_label')):{k:p.get(k) for k in ['person','work_type','year','work_label']}
for p in context['content_passages']}.values())
requirements['separate_works']=any(sum(s['person']==person for s in context['work_scopes'])>1 for person in requested)
if requirements['separate_works']:requirements['work_scopes']=context['work_scopes']
if requirements.get('facets'): if requirements.get('facets'):
from .content_selection import candidates from .content_selection import candidates
context['content_candidates'] = candidates(context) context['content_candidates'] = candidates(context)

View File

@ -18,7 +18,9 @@ def clarify(payload: dict, reason: str, message: str) -> dict:
def prepare_named_content(db_file, question: str, payload: dict) -> dict: def prepare_named_content(db_file, question: str, payload: dict) -> dict:
if not content_question(question): from tools.retrieval.work_selector import parse_work_selector, label_key
selector=parse_work_selector(question)
if not content_question(question) and not selector.specific:
return payload return payload
if re.search(r'\b(?:s tagom|s klucovym slovom|z kategorie|s autorom dokumentu)\b',normalized(question)): if re.search(r'\b(?:s tagom|s klucovym slovom|z kategorie|s autorom dokumentu)\b',normalized(question)):
return clarify(payload,'content_filter_requires_graph', return clarify(payload,'content_filter_requires_graph',
@ -53,9 +55,9 @@ def prepare_named_content(db_file, question: str, payload: dict) -> dict:
'Uveď jeho presné celé meno; ostatné osoby nebudem používať ako náhradu.') 'Uveď jeho presné celé meno; ostatné osoby nebudem používať ako náhradu.')
if not named: if not named:
return payload return payload
types=requested_types(question,[r['title'] for r in named]) types=requested_types(selector.positive_text,[r['title'] for r in named])
years=requested_years(question) years=requested_years(question)
selected=[] selected=[];scopes=[]
for person in named: for person in named:
rows=list(conn.execute('SELECT text,heading_paths_json FROM chunks WHERE document_path=? ' rows=list(conn.execute('SELECT text,heading_paths_json FROM chunks WHERE document_path=? '
'AND COALESCE(published,1)=1 ORDER BY chunk_index',(person['path'],))) 'AND COALESCE(published,1)=1 ORDER BY chunk_index',(person['path'],)))
@ -68,15 +70,22 @@ def prepare_named_content(db_file, question: str, payload: dict) -> dict:
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=label or 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 '')
if project_number(question) is not None and project_number(label)!=project_number(question): if not selector.matches(label):
continue continue
records[(scope,normalized(label))]=label records[(scope,normalized(label))]=label
if (scope,normalized(label),text) not in seen: if (scope,normalized(label),text) not in seen:
seen.add((scope,normalized(label),text));frames.append('Sekcia: '+label+'\n'+text) seen.add((scope,normalized(label),text));frames.append('Sekcia: '+label+'\n'+text)
if len(records)>1: if selector.multiple and (any(label not in {label_key(v) for v in records.values()} for label in selector.labels) or
any(not any(project_number(v)==number for v in records.values()) for number in selector.numbers)):
return clarify(payload,'unresolved_work_selection',
'Niektoré z požadovaných označení prác osoby '+person['title']+' sa v indexe nenašli. '
'Uveď presné nadpisy zo zdroja; chýbajúcu prácu nevynechám z porovnania potichu.')
if len(records)>1 and not selector.multiple:
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 alebo presné označenie projektu.') f'V indexe sú tieto možnosti: {options}. Uveď typ práce a rok alebo presné označenie projektu.')
scopes.extend({'person':person['title'],'work_type':scope[0],'year':scope[1],'work_label':label}
for (scope,_key),label in records.items())
from .content_selection import prioritize_frames from .content_selection import prioritize_frames
frames=prioritize_frames(frames,question) 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)
@ -92,4 +101,4 @@ def prepare_named_content(db_file, question: str, payload: dict) -> dict:
selected.append(source) selected.append(source)
selected=[{**source,'source_id':f'S{i}'} for i,source in enumerate(selected,1)] selected=[{**source,'source_id':f'S{i}'} for i,source in enumerate(selected,1)]
return {**payload,'sources':selected,'source_count':len(selected),'context':build_context_text(selected), return {**payload,'sources':selected,'source_count':len(selected),'context':build_context_text(selected),
'named_subjects':[p['title'] for p in named]} 'named_subjects':[p['title'] for p in named],'work_scopes':scopes}

View File

@ -13,7 +13,8 @@ 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, work_subject, from .presentation import (content_lines, fact_sentences, no_answer_text, work_subject,
clean_answer, evidence_subject, comparison_table) clean_answer, evidence_subject, comparison_table)
from .content_selection import facet_lines, evidence_units, missing_facets, fit_candidates, has_qualification from .content_selection import (facet_lines, evidence_units, missing_facets, missing_work_facets,
matches_scope, fit_candidates, has_qualification)
from .evidence_selection import (resolve_selection, EvidenceSelectionError, from .evidence_selection import (resolve_selection, EvidenceSelectionError,
missing_required_evidence, overview_units) missing_required_evidence, overview_units)
@ -294,10 +295,10 @@ def rag_answer_issues(answer: dict, context: dict) -> list[str]:
if kind not in kinds: if kind not in kinds:
issues.append('missing_' + kind + '_evidence') issues.append('missing_' + kind + '_evidence')
if requirements.get('facets'): if requirements.get('facets'):
available={(c['person'],facet) for c in context.get('content_candidates',[]) for facet in c['facets']} available=context.get('content_candidates',[])
if answer['status']=='ok': if answer['status']=='ok':
for person,facet in missing_facets(answer,context): for person,label,facet in missing_work_facets(answer,context):
if (person,facet) in available: if any(matches_scope(c,person,label) and facet in c['facets'] for c in available):
issues.append('missing_'+('progress' if facet=='results' else facet)+'_evidence') issues.append('missing_'+('progress' if facet=='results' else facet)+'_evidence')
elif answer['status']=='no_answer' and available: elif answer['status']=='no_answer' and available:
issues.append('rejected_supported_content_comparison') issues.append('rejected_supported_content_comparison')
@ -430,17 +431,18 @@ def render_rag_answer(answer: dict, context: dict) -> str:
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 quote_matches_passage(quote,p)), {}) if quote_matches_passage(quote,p)), {})
identity = (passage.get('person'), passage.get('work_type'), passage.get('year')) identity = (passage.get('person'), passage.get('work_type'), passage.get('year'),passage.get('work_label'))
groups.setdefault(identity, []).append(selection['text']) groups.setdefault(identity, []).append(selection['text'])
table = None table = None
if style == 'overview' and len(groups)>1 and re.search(r'\bporovnaj\w*\b',normalized(context.get('question',''))): if style == 'overview' and len(groups)>1 and re.search(r'\bporovnaj\w*\b',normalized(context.get('question',''))):
from .presentation import goal_text from .presentation import goal_text
table=comparison_table(['Oblasť',*(evidence_subject(person,context) for person,_,_ in groups)], table=comparison_table(['Oblasť',*(person+' — '+label if req.get('separate_works') and label else
evidence_subject(person,context) for person,_,_,label in groups)],
[('Zameranie podľa podkladov',['\n'.join(goal_text(text)[0] for text in units) [('Zameranie podľa podkladov',['\n'.join(goal_text(text)[0] for text in units)
for units in groups.values()])]) for units in groups.values()])])
if table is not None: if table is not None:
lines.extend(table) lines.extend(table)
for (person, kind, _year), selections in ([] if table is not None else groups.items()): for (person, kind, _year,label), selections in ([] if table is not None else groups.items()):
if style == 'overview': if style == 'overview':
units = selections units = selections
else: else:
@ -451,7 +453,9 @@ def render_rag_answer(answer: dict, context: dict) -> str:
units.extend(block.strip() for block in re.split( units.extend(block.strip() for block in re.split(
r'(?=^[ \t]*(?:\d+[.)]|[-+*])\s+)', text, flags=re.M) r'(?=^[ \t]*(?:\d+[.)]|[-+*])\s+)', text, flags=re.M)
if block.strip()) if block.strip())
lines.extend(content_lines(units, person, kind, context.get('question', ''))) rendered=content_lines(units, person, kind, context.get('question', ''))
if req.get('separate_works') and label:rendered=['**'+person+' — '+' '.join(label.split())+'**','',*rendered[1:]]
lines.extend(rendered)
else: else:
sources = {row['source_id']: row for row in context['source_blocks']} sources = {row['source_id']: row for row in context['source_blocks']}
lines += ['Zo zdroja ' + str(sources[quote['source_id']].get('title') or quote['source_id']) + lines += ['Zo zdroja ' + str(sources[quote['source_id']].get('title') or quote['source_id']) +
@ -527,6 +531,9 @@ def generate_verified_rag(question: str, payload: dict, complete) -> dict:
+ ('claims=[] a source_urls=[]; citácie dosadí aplikácia podľa IDs.' + ('claims=[] a source_urls=[]; citácie dosadí aplikácia podľa IDs.'
if context['requirements']['response_mode']=='quotes' else if context['requirements']['response_mode']=='quotes' else
'Pri mixed vyplň iba výslovne požadované claims a cituj aj ich zdroje.')) 'Pri mixed vyplň iba výslovne požadované claims a cituj aj ich zdroje.'))
if context['requirements'].get('separate_works'):
selection_policy+=(' Rovnaká osoba, typ a rok nezlučujú rôzne work_label. '
'Vyber dôkazy pre každú dostupnú požadovanú časť každej práce zvlášť; chýbajúce časti označí aplikácia.')
facets=context['requirements'].get('facets',[]) facets=context['requirements'].get('facets',[])
if facets: if facets:
support={f'osoba_{i}':[f for f in facets if any(row.get('person')==person and f in row.get('facets',[]) support={f'osoba_{i}':[f for f in facets if any(row.get('person')==person and f in row.get('facets',[])

View File

@ -33,6 +33,11 @@ def deliver_answer(question: str, *, engine: str = 'rag', limit: int = 5,
'accepted_for_delivery': False, 'answer': 'Agent práve spracúva iné odpovede. Skúste to o chvíľu.'} 'accepted_for_delivery': False, 'answer': 'Agent práve spracúva iné odpovede. Skúste to o chvíľu.'}
try: try:
model = complete or complete_with_configured_model model = complete or complete_with_configured_model
from tools.answer_evaluation.question_intent import unresolved_followup
if unresolved_followup(question):
return {'status':'ambiguous','reason':'missing_followup_context','accepted_for_delivery':True,
'answer':'Uveď osobu a presné označenia prác alebo úplnú otázku. Samotné „obe“ bez predchádzajúcich možností neviem spoľahlivo vyhľadať.',
'claims':[],'source_urls':[],'engine':engine,'index_version':None,'complete':False,'truncated':False,'truncation_reasons':[]}
unsupported=unsupported_constraint(question) unsupported=unsupported_constraint(question)
if unsupported: if unsupported:
return {'status':'ambiguous','reason':'unsupported_constraint','accepted_for_delivery':True, return {'status':'ambiguous','reason':'unsupported_constraint','accepted_for_delivery':True,
@ -51,7 +56,8 @@ def deliver_answer(question: str, *, engine: str = 'rag', limit: int = 5,
context = retrieved context = retrieved
elif engine == 'graphrag': elif engine == 'graphrag':
from tools.answer_evaluation.question_intent import graph_needs_text from tools.answer_evaluation.question_intent import graph_needs_text
if graph_needs_text(question): from tools.retrieval.work_selector import parse_work_selector
if graph_needs_text(question) or parse_work_selector(question).specific:
return {'status':'ambiguous','reason':'text_evidence_required','accepted_for_delivery':True, return {'status':'ambiguous','reason':'text_evidence_required','accepted_for_delivery':True,
'answer':'Táto otázka vyžaduje textové podklady o zadaní, priebehu alebo výsledkoch práce. ' 'answer':'Táto otázka vyžaduje textové podklady o zadaní, priebehu alebo výsledkoch práce. '
'Použi /rag s rovnakou otázkou; graf samotný tieto výňatky neposkytuje.', 'Použi /rag s rovnakou otázkou; graf samotný tieto výňatky neposkytuje.',

View File

@ -48,6 +48,7 @@ def content_fingerprints(questions: Path, root: Path = ROOT) -> dict[str, Any]:
'tools/answer_evaluation/rag_evidence.py', 'tools/answer_evaluation/verified_rag.py', 'tools/rag/llm_context.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/evidence_selection.py', 'tools/answer_evaluation/evidence_selection.py',
'tools/retrieval/work_selector.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',
'tools/rag/evaluation/rag_metrics.py', 'tools/rag/evaluation/rag_runner.py']) 'tools/rag/evaluation/rag_metrics.py', 'tools/rag/evaluation/rag_runner.py'])

View File

@ -12,7 +12,7 @@ from tools.retrieval.text_budget import split_to_fit
SOURCE_FIELDS = ('source_id', 'title', 'author', 'document_path', 'source_url', 'published', SOURCE_FIELDS = ('source_id', 'title', 'author', 'document_path', 'source_url', 'published',
'categories', 'tags', 'section') 'categories', 'tags', 'section')
FIELDS = ('status', 'reason', 'query', 'engine', 'strategies', 'instructions', 'answer_format', FIELDS = ('status', 'reason', 'query', 'engine', 'strategies', 'instructions', 'answer_format',
'complete', 'truncated', 'truncation_reasons', 'index_version', 'source_count', 'clarification', 'named_subjects') 'complete', 'truncated', 'truncation_reasons', 'index_version', 'source_count', 'clarification', 'named_subjects', 'work_scopes')
def compact_rag_payload(data: dict, *, max_tokens: int | None = None, def compact_rag_payload(data: dict, *, max_tokens: int | None = None,

View File

@ -1,17 +1,18 @@
"""Fail closed for recognized query operators without an implemented meaning.""" """Fail closed for recognized query operators without an implemented meaning."""
import re import re
import unicodedata
def requested_years(question: str) -> list[int]: def requested_years(question: str) -> list[int]:
"""An unquoted four-digit year remains a condition without 'v roku'.""" """An unquoted four-digit year remains a condition without 'v roku'."""
text=re.sub(r'[„“"«][^„“”"»]+[“”"»]', ' ', question) from .work_selector import selector_years
return list(dict.fromkeys(int(value) for value in re.findall(r'\b\d{4}\b',text))) return selector_years(question)
def unsupported_constraint(question: str) -> str | None: def unsupported_constraint(question: str) -> str | None:
text = ''.join(c for c in unicodedata.normalize('NFKD', question).casefold() from .work_selector import parse_work_selector
if not unicodedata.combining(c)) selection=parse_work_selector(question)
if selection.unsupported_negation:return 'exclusion'
text=selection.positive_text
# Do not treat words inside quoted academic titles as query operators. # Do not treat words inside quoted academic titles as query operators.
text = re.sub(r'[„“"«][^„“”"»]+[“”"»]', ' ', text) text = re.sub(r'[„“"«][^„“”"»]+[“”"»]', ' ', text)
academic = re.search(r'\b(?:prac\w*|projekt\w*)\b',text) academic = re.search(r'\b(?:prac\w*|projekt\w*)\b',text)

View File

@ -0,0 +1,79 @@
"""Source-heading selectors; negated project numbers never become positive filters."""
from dataclasses import dataclass
import re
import unicodedata
def normalize(text):
return ''.join(c for c in unicodedata.normalize('NFKD',text).casefold() if not unicodedata.combining(c))
def label_key(text):
return ' '.join(re.sub(r'[^\w]+',' ',normalize(text)).split())
QUOTED=re.compile(r'[„“"«]([^„“”"»]+)[“”"»]')
HEADING=re.compile(r'(?:diplomovy|bakalarsky|timovy|vyskumny|vedecky) projekt(?: [1-9]\d?)? [12]\d{3}|'
r'(?:diplomova|bakalarska|dizertacna) praca [12]\d{3}')
NUMBER=re.compile(r'\bprojekt\w*\s+([1-9]\d?)(?!\d)\b')
NEGATIVE=re.compile(r'\b(?:nie|okrem|bez|s vynimkou)\s+(?:(?:diplomov|bakalarsk|timov|vyskumn|vedeck)\w*\s+)?$')
@dataclass(frozen=True)
class WorkSelector:
labels: tuple=()
excluded_labels: tuple=()
numbers: tuple=()
excluded_numbers: tuple=()
unnumbered: bool=False
multiple: bool=False
positive_text: str=''
unsupported_negation: bool=False
@property
def specific(self):
return bool(self.labels or self.excluded_labels or self.numbers or self.excluded_numbers or self.unnumbered)
def matches(self,label):
key=label_key(label);match=NUMBER.search(key);number=int(match[1]) if match else None
return ((not self.labels or key in self.labels) and key not in self.excluded_labels and
(not self.numbers or number in self.numbers) and number not in self.excluded_numbers and
(not self.unnumbered or number is None))
def parse_work_selector(question):
text=normalize(question);positive=[];negative=[];excluded_spans=[]
for match in QUOTED.finditer(text):
key=label_key(match[1])
if not HEADING.fullmatch(key):continue
neg=NEGATIVE.search(text[:match.start()])
if neg:
negative.append(key);excluded_spans.append((neg.start(),match.end()))
else:positive.append(key)
numbers=[];excluded=[];unsupported=False
for match in NUMBER.finditer(text):
if any(quote.start()<=match.start()<quote.end() for quote in QUOTED.finditer(text)):continue
neg=NEGATIVE.search(text[:match.start()])
if neg:
excluded.append(int(match[1]));excluded_spans.append((neg.start(),match.end()))
# Year-qualified exclusions require the exact quoted heading.
if re.match(r'\s+(?:(?:v|z) roku\s+)?\d{4}\b',text[match.end():]):unsupported=True
else:numbers.append(int(match[1]))
cleaned=text
for start,end in sorted(excluded_spans,reverse=True):cleaned=cleaned[:start]+' '*(end-start)+cleaned[end:]
if re.search(r'\bnie\s+(?:(?:diplomov|bakalarsk|timov|vyskumn|vedeck)\w*\s+)?(?:projekt\w*|prac\w*|(?:(?:v|z)\s+)?(?:rok\w*\s+)?\d{4})\b',cleaned):
unsupported=True
multiple=bool(re.search(r'\b(?:oba|obe|oboch|obidv\w*|vsetk\w*)\s+'
r'(?:(?:jeho|tieto|uveden\w*|diplomov\w*|bakalarsk\w*|timov\w*|vyskumn\w*|vedeck\w*)\s+)*'
r'(?:projekt\w*|prac\w*)\b',text)) or len(set(positive))>1 or len(set(numbers))>1
return WorkSelector(tuple(dict.fromkeys(positive)),tuple(dict.fromkeys(negative)),
tuple(dict.fromkeys(numbers)),tuple(dict.fromkeys(excluded)),
bool(re.search(r'\bnecislo\w*\b|\bprojekt\w*\s+bez cisla\b',text)),multiple,cleaned,unsupported)
def selector_years(question):
selector=parse_work_selector(question)
text=QUOTED.sub(' ',selector.positive_text)
years=[int(v) for v in re.findall(r'\b\d{4}\b',text)]
years.extend(int(label.split()[-1]) for label in selector.labels)
return list(dict.fromkeys(years))