diff --git a/README.md b/README.md index 35c304a..3c628c3 100644 --- a/README.md +++ b/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. diff --git a/packages/claude/dist/packages.json b/packages/claude/dist/packages.json index 3090d27..e5fdc26 100644 --- a/packages/claude/dist/packages.json +++ b/packages/claude/dist/packages.json @@ -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 diff --git a/packages/claude/dist/zpwiki-graphrag.zip b/packages/claude/dist/zpwiki-graphrag.zip index e4b6b6c..63dd60f 100644 Binary files a/packages/claude/dist/zpwiki-graphrag.zip and b/packages/claude/dist/zpwiki-graphrag.zip differ diff --git a/packages/claude/dist/zpwiki-rag.zip b/packages/claude/dist/zpwiki-rag.zip index c75e00c..7f1361d 100644 Binary files a/packages/claude/dist/zpwiki-rag.zip and b/packages/claude/dist/zpwiki-rag.zip differ diff --git a/packages/claude/graphrag/SKILL.md b/packages/claude/graphrag/SKILL.md index 40f4b0d..c0d9457 100644 --- a/packages/claude/graphrag/SKILL.md +++ b/packages/claude/graphrag/SKILL.md @@ -10,12 +10,16 @@ For `/graphrag `, 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. diff --git a/packages/claude/mcp_server.py b/packages/claude/mcp_server.py index 914c3b7..33df4fd 100644 --- a/packages/claude/mcp_server.py +++ b/packages/claude/mcp_server.py @@ -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. ' ) diff --git a/packages/claude/rag/SKILL.md b/packages/claude/rag/SKILL.md index aef6d4e..650c9bf 100644 --- a/packages/claude/rag/SKILL.md +++ b/packages/claude/rag/SKILL.md @@ -10,12 +10,18 @@ For `/rag `, 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. diff --git a/scripts/build_claude_packages.py b/scripts/build_claude_packages.py index c24a8b4..9a0042a 100644 --- a/scripts/build_claude_packages.py +++ b/scripts/build_claude_packages.py @@ -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): diff --git a/test/test_work_scope_selection.py b/test/test_work_scope_selection.py new file mode 100644 index 0000000..f3a7ae3 --- /dev/null +++ b/test/test_work_scope_selection.py @@ -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' diff --git a/tools/answer_evaluation/content_selection.py b/tools/answer_evaluation/content_selection.py index ee636cb..99baf95 100644 --- a/tools/answer_evaluation/content_selection.py +++ b/tools/answer_evaluation/content_selection.py @@ -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 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)) diff --git a/tools/answer_evaluation/evidence_selection.py b/tools/answer_evaluation/evidence_selection.py index 5edc2ea..2131b84 100644 --- a/tools/answer_evaluation/evidence_selection.py +++ b/tools/answer_evaluation/evidence_selection.py @@ -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 diff --git a/tools/answer_evaluation/presentation.py b/tools/answer_evaluation/presentation.py index fd4c34c..ed672d9 100644 --- a/tools/answer_evaluation/presentation.py +++ b/tools/answer_evaluation/presentation.py @@ -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 diff --git a/tools/answer_evaluation/question_intent.py b/tools/answer_evaluation/question_intent.py index 5c62fdf..f04c8a9 100644 --- a/tools/answer_evaluation/question_intent.py +++ b/tools/answer_evaluation/question_intent.py @@ -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: diff --git a/tools/answer_evaluation/rag_evidence.py b/tools/answer_evaluation/rag_evidence.py index 46e1da8..e8d1518 100644 --- a/tools/answer_evaluation/rag_evidence.py +++ b/tools/answer_evaluation/rag_evidence.py @@ -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) diff --git a/tools/answer_evaluation/rag_preparation.py b/tools/answer_evaluation/rag_preparation.py index b629f3e..1ab5a2e 100644 --- a/tools/answer_evaluation/rag_preparation.py +++ b/tools/answer_evaluation/rag_preparation.py @@ -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} diff --git a/tools/answer_evaluation/verified_rag.py b/tools/answer_evaluation/verified_rag.py index 8ea7a8c..7be9861 100644 --- a/tools/answer_evaluation/verified_rag.py +++ b/tools/answer_evaluation/verified_rag.py @@ -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',[]) diff --git a/tools/answers/answer_delivery.py b/tools/answers/answer_delivery.py index d2eb048..e596947 100644 --- a/tools/answers/answer_delivery.py +++ b/tools/answers/answer_delivery.py @@ -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.', diff --git a/tools/graphrag/evaluation/reproducibility.py b/tools/graphrag/evaluation/reproducibility.py index d7372c7..71f853d 100644 --- a/tools/graphrag/evaluation/reproducibility.py +++ b/tools/graphrag/evaluation/reproducibility.py @@ -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']) diff --git a/tools/rag/llm_context.py b/tools/rag/llm_context.py index fe5b2a1..807b384 100644 --- a/tools/rag/llm_context.py +++ b/tools/rag/llm_context.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, diff --git a/tools/retrieval/question_constraints.py b/tools/retrieval/question_constraints.py index 66fe859..89846cd 100644 --- a/tools/retrieval/question_constraints.py +++ b/tools/retrieval/question_constraints.py @@ -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) diff --git a/tools/retrieval/work_selector.py b/tools/retrieval/work_selector.py new file mode 100644 index 0000000..694114f --- /dev/null +++ b/tools/retrieval/work_selector.py @@ -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()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))