Presnejší výber projektov
This commit is contained in:
parent
dd487d0c05
commit
8335c69a4a
10
README.md
10
README.md
@ -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.
|
||||
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
|
||||
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
|
||||
|
||||
@ -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ú
|
||||
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
|
||||
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.
|
||||
|
||||
4
packages/claude/dist/packages.json
vendored
4
packages/claude/dist/packages.json
vendored
@ -3,12 +3,12 @@
|
||||
{
|
||||
"archive": "zpwiki-rag.zip",
|
||||
"name": "rag",
|
||||
"sha256": "0c4b1a65d7ec97e89505000c7f60239ae220098ba11dc8a6ae9fca8be396f151"
|
||||
"sha256": "8e5ab92284ee6f53d10e468cb4edee6cfd354ae869583673cbb6aea8ea9ca437"
|
||||
},
|
||||
{
|
||||
"archive": "zpwiki-graphrag.zip",
|
||||
"name": "graphrag",
|
||||
"sha256": "cbfd609ec66c24a03e4c75b33e5a92438a1131c25019df8f2ea522bc917ba5e3"
|
||||
"sha256": "99a9ea36b439d6b14edc35c2d4d0c1c2cf97139f78e8fd73b8ce2347d82b1092"
|
||||
}
|
||||
],
|
||||
"schema_version": 1
|
||||
|
||||
BIN
packages/claude/dist/zpwiki-graphrag.zip
vendored
BIN
packages/claude/dist/zpwiki-graphrag.zip
vendored
Binary file not shown.
BIN
packages/claude/dist/zpwiki-rag.zip
vendored
BIN
packages/claude/dist/zpwiki-rag.zip
vendored
Binary file not shown.
@ -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.
|
||||
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
|
||||
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,
|
||||
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.
|
||||
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;
|
||||
keep all conditions belonging to each question together.
|
||||
|
||||
|
||||
@ -20,11 +20,16 @@ from starlette.responses import JSONResponse
|
||||
|
||||
# Host-model rules belong to MCP initialization and Skills, not tool-result data.
|
||||
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. '
|
||||
'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. '
|
||||
'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. '
|
||||
'If they differ, do not present the mismatched answer. Ask for a concrete clarification when needed. '
|
||||
)
|
||||
|
||||
@ -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.
|
||||
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
|
||||
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,
|
||||
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.
|
||||
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;
|
||||
keep all conditions belonging to each question together.
|
||||
|
||||
|
||||
@ -11,7 +11,7 @@ import zipfile
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
BASE = ROOT / 'packages/claude'
|
||||
ENGINES = ('rag', 'graphrag')
|
||||
PACKAGE_VERSION = '1.4.0'
|
||||
PACKAGE_VERSION = '1.5.0'
|
||||
|
||||
|
||||
def json_bytes(value):
|
||||
|
||||
152
test/test_work_scope_selection.py
Normal file
152
test/test_work_scope_selection.py
Normal 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'
|
||||
@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
|
||||
import re
|
||||
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|'
|
||||
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:
|
||||
n = normalized(text)
|
||||
n = normalized(re.sub(r'^\s*(?:\d+[.)]|[-+*])\s+','',text))
|
||||
if role in {'other','progress'}:
|
||||
from .presentation import GOAL_VERBS
|
||||
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 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')
|
||||
if re.search(r'\b(?:metod\w*|technolog\w*|nastroj\w*|pouzi\w*|vyuziv\w*|pomocou|implementovan\w*|natrenovan\w* model\w*)\b', n):
|
||||
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:
|
||||
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=actual_methods_requested(question)
|
||||
actual_statement=not re.search(NOT_COMPLETED,n) and (re.search(COMPLETED,n) or
|
||||
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')
|
||||
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))
|
||||
@ -159,13 +160,29 @@ def evidence_units(context: dict, quotes: list[dict] | None = None) -> list[dict
|
||||
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]:
|
||||
from .evidence_selection import identify
|
||||
facets = context['requirements'].get('facets', [])
|
||||
selected = []; seen = set(); units = evidence_units(context)
|
||||
for person in context['requirements']['people']:
|
||||
for person,label in scope_groups(context):
|
||||
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):
|
||||
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))
|
||||
@ -186,10 +203,14 @@ def candidates(context: dict) -> list[dict]:
|
||||
|
||||
|
||||
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', []))
|
||||
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', [])
|
||||
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:
|
||||
@ -200,8 +221,7 @@ def fit_candidates(sent: dict, context: dict, limit: int, encoding: str) -> None
|
||||
if counter.encoding is None:
|
||||
raise RuntimeError('Missing context tokenizer')
|
||||
original=list(context.get('content_candidates',[]))
|
||||
groups=[[row for row in original if row.get('person')==person]
|
||||
for person in context['requirements']['people']]
|
||||
groups=[[row for row in original if matches_scope(row,person,label)] for person,label in scope_groups(context)]
|
||||
ordered=[group[i] for i in range(max(map(len,groups),default=0)) for group in groups if i<len(group)]
|
||||
selected=[]
|
||||
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}
|
||||
if 'experiments' in facets and planned_experiments(context.get('question','')):
|
||||
labels['experiments']='Plánované experimenty a vyhodnotenie'
|
||||
for person in req['people']:
|
||||
group={'subject':evidence_subject(person,context),'items':{},'missing':[]};seen=set()
|
||||
for person,work_label in scope_groups(context):
|
||||
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:
|
||||
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):
|
||||
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))
|
||||
|
||||
@ -20,10 +20,10 @@ def identify(row: dict, *, required: bool = False) -> dict:
|
||||
|
||||
def overview_candidates(context: dict) -> list[dict]:
|
||||
"""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=[]
|
||||
for person in context['requirements']['people']:
|
||||
own=[row for row in rows if row['person']==person]
|
||||
for person,label in scope_groups(context):
|
||||
own=[row for row in rows if matches_scope(row,person,label)]
|
||||
main=[row for row in own if row['kind']=='assignment']
|
||||
# Without an explicit assignment, retain a bounded descriptive sample.
|
||||
# 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]:
|
||||
"""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',[]))
|
||||
selected=[]
|
||||
for person in context.get('requirements',{}).get('people',[]):
|
||||
own=[row for row in rows if row['person']==person]
|
||||
for person,label in scope_groups(context):
|
||||
own=[row for row in rows if matches_scope(row,person,label)]
|
||||
main=[row for row in own if row['kind']=='assignment']
|
||||
for row in main or own[:2]:
|
||||
quote=next(q for q in answer['evidence_quotes'] if q['source_id']==row['source_id'] and
|
||||
|
||||
@ -108,6 +108,8 @@ GOAL_VERBS = {
|
||||
'identifikujte': 'Identifikovať', 'preštudujte': 'Preštudovať',
|
||||
'pripravte': 'Pripraviť',
|
||||
'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:
|
||||
"""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',
|
||||
'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í')}
|
||||
values=list(dict.fromkeys(labels.get(f,f) for f in facets))
|
||||
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:
|
||||
subject+=' ('+str(next(iter(scopes))[1])+')'
|
||||
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())
|
||||
return subject
|
||||
|
||||
|
||||
@ -34,6 +34,16 @@ def facet_mode(question: str) -> bool:
|
||||
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:
|
||||
# A supplied year scopes the evidence; it does not itself ask for a year claim.
|
||||
text=normalized(question)
|
||||
@ -46,7 +56,7 @@ def graph_needs_text(question: str) -> bool:
|
||||
# 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):
|
||||
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:
|
||||
|
||||
@ -128,8 +128,9 @@ def requested_types(question: str, people: list[str]) -> dict[str, 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
|
||||
from tools.retrieval.work_selector import parse_work_selector
|
||||
numbers=parse_work_selector(text).numbers
|
||||
return numbers[0] if len(numbers)==1 else None
|
||||
|
||||
|
||||
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]:
|
||||
"""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']
|
||||
profiles = [row['document_path'].removesuffix('README.md') for row in blocks
|
||||
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
|
||||
if requirements['unsupported_year'] or (requirements['years'] and scope[1] not in requirements['years']):
|
||||
continue
|
||||
if requirements.get('project_number') is not None and project_number(label or '')!=requirements['project_number']:
|
||||
if not selector.matches(label or ''):
|
||||
continue
|
||||
if not descriptive_text(text, titles):
|
||||
continue
|
||||
@ -258,9 +261,10 @@ def content_sections(text: str) -> list[dict]:
|
||||
'zadanie diplomoveho projektu', 'zadanie bakalarskeho projektu',
|
||||
'navrh na zadanie', 'navrh zadania'}:
|
||||
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'
|
||||
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'
|
||||
elif heading and role_value in {'napr','napriklad','priklady','priklad'} and not line[:1].isspace():
|
||||
next_kind = 'other'
|
||||
@ -269,7 +273,7 @@ def content_sections(text: str) -> list[dict]:
|
||||
if next_kind is not None:
|
||||
flush(); kind = next_kind
|
||||
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:
|
||||
lines.append(line)
|
||||
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())
|
||||
requested = [person for person in people if text_contains_value(question, person, person=True)]
|
||||
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)
|
||||
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))
|
||||
@ -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['unsupported_year'] = any(len(value) != 4 for value in stated_years)
|
||||
requirements['project_number'] = project_number(question)
|
||||
requirements['exact_work_label']=selector.labels[0] if len(selector.labels)==1 else None
|
||||
if requested:
|
||||
facts = [row for row in facts if row['person'] in requested]
|
||||
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': []}
|
||||
if content:
|
||||
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'):
|
||||
from .content_selection import candidates
|
||||
context['content_candidates'] = candidates(context)
|
||||
|
||||
@ -18,7 +18,9 @@ def clarify(payload: dict, reason: str, message: str) -> 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
|
||||
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',
|
||||
@ -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.')
|
||||
if not named:
|
||||
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)
|
||||
selected=[]
|
||||
selected=[];scopes=[]
|
||||
for person in named:
|
||||
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'],)))
|
||||
@ -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):
|
||||
continue
|
||||
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
|
||||
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 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()))
|
||||
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.')
|
||||
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
|
||||
frames=prioritize_frames(frames,question)
|
||||
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=[{**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),
|
||||
'named_subjects':[p['title'] for p in named]}
|
||||
'named_subjects':[p['title'] for p in named],'work_scopes':scopes}
|
||||
|
||||
@ -13,7 +13,8 @@ from .rag_evidence import descriptive_text, rag_evidence_context, quote_sections
|
||||
from .answer_coverage import answer_coverage, coverage_notices
|
||||
from .presentation import (content_lines, fact_sentences, no_answer_text, work_subject,
|
||||
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,
|
||||
missing_required_evidence, overview_units)
|
||||
|
||||
@ -294,10 +295,10 @@ def rag_answer_issues(answer: dict, context: dict) -> list[str]:
|
||||
if kind not in kinds:
|
||||
issues.append('missing_' + kind + '_evidence')
|
||||
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':
|
||||
for person,facet in missing_facets(answer,context):
|
||||
if (person,facet) in available:
|
||||
for person,label,facet in missing_work_facets(answer,context):
|
||||
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')
|
||||
elif answer['status']=='no_answer' and available:
|
||||
issues.append('rejected_supported_content_comparison')
|
||||
@ -430,17 +431,18 @@ def render_rag_answer(answer: dict, context: dict) -> str:
|
||||
quote = selection['evidence']
|
||||
passage = next((p for p in context.get('content_passages', [])
|
||||
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'])
|
||||
table = None
|
||||
if style == 'overview' and len(groups)>1 and re.search(r'\bporovnaj\w*\b',normalized(context.get('question',''))):
|
||||
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)
|
||||
for units in groups.values()])])
|
||||
if table is not None:
|
||||
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':
|
||||
units = selections
|
||||
else:
|
||||
@ -451,7 +453,9 @@ def render_rag_answer(answer: dict, context: dict) -> str:
|
||||
units.extend(block.strip() for block in re.split(
|
||||
r'(?=^[ \t]*(?:\d+[.)]|[-+*])\s+)', text, flags=re.M)
|
||||
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:
|
||||
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']) +
|
||||
@ -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.'
|
||||
if context['requirements']['response_mode']=='quotes' else
|
||||
'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',[])
|
||||
if facets:
|
||||
support={f'osoba_{i}':[f for f in facets if any(row.get('person')==person and f in row.get('facets',[])
|
||||
|
||||
@ -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.'}
|
||||
try:
|
||||
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)
|
||||
if unsupported:
|
||||
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
|
||||
elif engine == 'graphrag':
|
||||
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,
|
||||
'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.',
|
||||
|
||||
@ -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/question_intent.py', 'tools/answer_evaluation/content_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/graph_answer_prompt.txt',
|
||||
'tools/rag/evaluation/rag_metrics.py', 'tools/rag/evaluation/rag_runner.py'])
|
||||
|
||||
@ -12,7 +12,7 @@ from tools.retrieval.text_budget import split_to_fit
|
||||
SOURCE_FIELDS = ('source_id', 'title', 'author', 'document_path', 'source_url', 'published',
|
||||
'categories', 'tags', 'section')
|
||||
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,
|
||||
|
||||
@ -1,17 +1,18 @@
|
||||
"""Fail closed for recognized query operators without an implemented meaning."""
|
||||
import re
|
||||
import unicodedata
|
||||
|
||||
|
||||
def requested_years(question: str) -> list[int]:
|
||||
"""An unquoted four-digit year remains a condition without 'v roku'."""
|
||||
text=re.sub(r'[„“"«][^„“”"»]+[“”"»]', ' ', question)
|
||||
return list(dict.fromkeys(int(value) for value in re.findall(r'\b\d{4}\b',text)))
|
||||
from .work_selector import selector_years
|
||||
return selector_years(question)
|
||||
|
||||
|
||||
def unsupported_constraint(question: str) -> str | None:
|
||||
text = ''.join(c for c in unicodedata.normalize('NFKD', question).casefold()
|
||||
if not unicodedata.combining(c))
|
||||
from .work_selector import parse_work_selector
|
||||
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.
|
||||
text = re.sub(r'[„“"«][^„“”"»]+[“”"»]', ' ', text)
|
||||
academic = re.search(r'\b(?:prac\w*|projekt\w*)\b',text)
|
||||
|
||||
79
tools/retrieval/work_selector.py
Normal file
79
tools/retrieval/work_selector.py
Normal 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))
|
||||
Loading…
Reference in New Issue
Block a user