From 7d07374ce9ca88787a9b47f27d121bb1abb331be Mon Sep 17 00:00:00 2001 From: jp170na Date: Sat, 7 Nov 2026 12:11:41 +0100 Subject: [PATCH] Kontrola zdrojov --- README.md | 2 + packages/claude/client.py | 3 +- packages/claude/dist/packages.json | 4 +- packages/claude/dist/zpwiki-graphrag.zip | Bin 8120 -> 8278 bytes packages/claude/dist/zpwiki-rag.zip | Bin 8239 -> 8449 bytes packages/claude/graphrag/SKILL.md | 4 + packages/claude/mcp_server.py | 3 + packages/claude/rag/SKILL.md | 4 + test/test_source_audit.py | 173 +++++++++++++++++++ tools/answer_evaluation/content_selection.py | 13 ++ tools/answers/answer_delivery.py | 8 + tools/answers/answer_errors.py | 1 + tools/answers/reference_links.py | 92 ++++++++++ tools/claude/build_claude_packages.py | 2 +- tools/knowledge/build.py | 20 ++- tools/knowledge/query.py | 54 +++++- tools/knowledge/sources.py | 51 +++++- tools/retrieval/person_names.py | 4 +- 18 files changed, 417 insertions(+), 21 deletions(-) create mode 100644 test/test_source_audit.py create mode 100644 tools/answers/reference_links.py diff --git a/README.md b/README.md index 7ad6e96..d2bd1f9 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,8 @@ Obsahový graf dopĺňa pojmy a vzťahy extrahované jazykovým modelom z profil a explicitne uvedeným dátumom. Podporuje otázky na pokrok, čas výsledkov, podobné témy a študentov venujúcich sa určitej činnosti. Zadanie ani plán sa nevydávajú za dokončený výsledok; chýbajúce údaje sa neodhadujú. +Pri výsledkoch sa rozlišuje dátum záznamu od skutočného dňa vykonania práce. +Repozitárové odkazy sa overujú cez verejné API; neoveriteľné odkazy sa nezobrazujú. Extrakciu spúšťa `python -m tools.knowledge.build` s nakonfigurovaným LLM API. Vyžaduje pripravené spoločné indexy a Neo4j. Nezmenené vstupy využívajú cache; diff --git a/packages/claude/client.py b/packages/claude/client.py index 72d01a6..5df06fb 100644 --- a/packages/claude/client.py +++ b/packages/claude/client.py @@ -83,6 +83,7 @@ class Config: def error_result(reason): messages={ + 'reference_unavailable':'Zdrojový repozitár sa nepodarilo verejne overiť. Odpoveď založenú iba na tomto odkaze neuvádzam.', 'unauthorized':'Prístup nebol autorizovaný. Skontroluj prihlásenie konektora a jeho konfiguráciu.', 'forbidden':'Pre tento nástroj nemáš potrebné oprávnenie.', 'invalid_query':'Otázku sa nepodarilo spracovať. Zadaj konkrétnu otázku a primeraný počet výsledkov.', @@ -177,7 +178,7 @@ def query_backend(query: str, engine: str, *, mode='answer', limit=5, config=Non # Raw model output, diagnostics and rejected text must not escape. reason = value.get('reason') safe_reasons = {'model_unavailable', 'answer_capacity_exceeded', 'answer_rejected', - 'answer_unavailable', 'retrieval_unavailable', 'content_index_unavailable'} + 'answer_unavailable', 'retrieval_unavailable', 'content_index_unavailable','reference_unavailable'} raise ClientError(reason if isinstance(reason, str) and reason in safe_reasons else 'backend_answer_error' if mode == 'answer' else 'retrieval_unavailable') validate_metadata(value) diff --git a/packages/claude/dist/packages.json b/packages/claude/dist/packages.json index bb9a2a2..398fc8b 100644 --- a/packages/claude/dist/packages.json +++ b/packages/claude/dist/packages.json @@ -3,12 +3,12 @@ { "archive": "zpwiki-rag.zip", "name": "rag", - "sha256": "e4813b8da2d0943b8587d5d4e1446ff10729a35b5d480812cf6e89b0d53b61a7" + "sha256": "812764028d95fc0b9f0ebd52f3aa20f38984eb1d3477da3f55a633703d423123" }, { "archive": "zpwiki-graphrag.zip", "name": "graphrag", - "sha256": "1663af0ca32ef79e43fd5cdbfb14120d4cdb8fb4c3843284ef329b41e238de1f" + "sha256": "f548b9a4ec313fd687cffb2f004435cae0f4b40a0e5f57a955fde4dff27cb5d1" } ], "schema_version": 1 diff --git a/packages/claude/dist/zpwiki-graphrag.zip b/packages/claude/dist/zpwiki-graphrag.zip index 92fe66ca7355188eed3b8c87782fa635b4936eb5..3311b67c2766e25fab7fa428f39c7754122ea2e7 100644 GIT binary patch delta 7832 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 w!RaNXQ?Zc$Z;b;2k^MKt{{Q`9JRe11gA;UH||9 diff --git a/packages/claude/dist/zpwiki-rag.zip b/packages/claude/dist/zpwiki-rag.zip index 79eee0ff04c3b8b4812ae545862dd0aab00f1f48..1e93be1d970e6b7583c1f7c4fabe4db330ea2609 100644 GIT binary patch delta 7982 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 delta 7793 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ár', +]) +def test_unverified_repository_is_not_delivered_as_a_clickable_link(text): + value=links.checked_repository_links(result(text),check=lambda _: 'not_public') + assert REPO not in value['answer'] and value['source_urls']==[SOURCE] + assert value['accepted_for_delivery'] and 'súkromný' in value['answer'] + assert value['suppressed_repository_links']==1 + + +def test_verified_repository_preserves_the_exact_answer_and_sources(): + value=result('[Repozitár]('+REPO+')') + assert links.checked_repository_links(value,check=lambda _: 'verified')==value + + +def test_html_escaped_markdown_reference_cannot_bypass_link_suppression(): + url='https://github.com/example/missing?x=1&y=2' + value=links.checked_repository_links(result('[Repozitár]('+url+')'),check=lambda _: 'unverified') + assert 'github.com' not in value['answer'] and '](' not in value['answer'] + + +def test_unverifiable_repository_cannot_be_the_only_factual_citation(): + value={**result('Práca je hotová.'),'source_urls':[REPO]} + checked=links.checked_repository_links(value,check=lambda _: 'unverified') + assert not checked['accepted_for_delivery'] and checked['reason']=='reference_unavailable' + assert not checked['source_urls'] + + +@pytest.mark.parametrize('url',[ + 'http://git.kemt.fei.tuke.sk/a/b','https://git.kemt.fei.tuke.sk:8443/a/b', + 'https://user:password@git.kemt.fei.tuke.sk/a/b','https://git.internal/a/b', + 'https://github.com/a/b?access_token=private','https://git.kemt.fei.tuke.sk/../admin', +]) +def test_repository_probe_never_contacts_unapproved_destinations(monkeypatch,url): + monkeypatch.setattr(links.urllib.request,'build_opener',lambda *a:pytest.fail('Unsafe network request')) + assert links._probe.__wrapped__(url,0)=='unverified' + + +@pytest.mark.parametrize('status,expected',[(404,'not_public'),(401,'not_public'),(403,'not_public'),(302,'unverified'),(503,'unverified')]) +def test_repository_api_failures_are_not_treated_as_success(monkeypatch,status,expected): + class Opener: + def open(self,request,**kw): + assert request.full_url.endswith('/api/v1/repos/sh662er/Rasa') + assert 'Authorization' not in request.headers + raise urllib.error.HTTPError(request.full_url,status,'Unavailable',{},None) + monkeypatch.setattr(links.urllib.request,'build_opener',lambda *a:Opener()) + assert links._probe.__wrapped__(REPO,0)==expected + + +def test_public_repo_api_metadata_is_required(monkeypatch): + class Opener: + def open(self,request,**kw):return io.BytesIO(json.dumps({'private':False,'full_name':'sh662er/Rasa'}).encode()) + monkeypatch.setattr(links.urllib.request,'build_opener',lambda *a:Opener()) + assert links._probe.__wrapped__(REPO,0)=='verified' + + +def index(): + doc={'title':'Alena Nová','path':'pages/students/2022/alena/README.md', + 'content':'# Diplomová práca 2024\nNázov: Overené grafy\nVedúci: Daniel Hládek\n' + 'Stav:\n- Implementovaný systém RAG.\nUkončil predčasne\n'} + rows=profile_units(doc) + return {'units':rows,'version':'fixture','index_version':{'version':'fixture'}} + + +def test_lifecycle_qualification_is_preserved_even_when_it_has_only_two_words(): + rows=index()['units'] + note=next(r for r in rows if r['kind']=='lifecycle') + assert note['text']=='Ukončil predčasne' and note['work_year']==2024 + r=answer_from_index('Ako pokročila Alena Nová?',index(),engine='rag') + assert 'Ukončil predčasne' in r['answer'] and any(e['evidence_role']=='lifecycle' for e in r['evidence']) + + +def test_supervisor_year_is_never_satisfied_by_a_historical_work(): + r=answer_from_index('Ktoré práce vedie Daniel Hladek v roku 2026?',index(),engine='rag') + assert r['status']=='no_answer' and r['reason']=='supervision_not_documented' + assert '2026' in r['answer'] and 'autor dokumentu' in r['answer'].lower() + assert not r['evidence'] and not r['source_urls'] + + +def test_current_supervision_is_not_inferred_from_a_formal_supervisor_line(): + r=answer_from_index('Ktoré práce aktuálne vedie Daniel Hladek?',index(),engine='graphrag') + assert r['status']=='no_answer' and r['reason']=='active_supervision_unknown' + assert 'historickom' in r['answer'] and not r['evidence'] + + +def test_missing_supervisor_in_followup_requires_a_name_and_keeps_the_year(): + r=answer_from_index('Ktoré práce sú teraz vedené v roku 2026?',index(),engine='rag') + assert r['status']=='ambiguous' and r['reason']=='missing_supervisor' + assert '2026' in r['answer'] + + +def test_title_typo_is_not_misclassified_as_a_person(): + catalog=[{'name':'Daniel Hládek','kind':'Person'}] + r=resolve_person_names('Ktoré práce aktuálne vedie Daniel Hladek a nazv prác?',catalog) + assert r['status']=='ok' and [m['name'] for m in r['matches']]==['Daniel Hládek'] + + +def test_dated_status_overrides_previous_meeting_instead_of_inheriting_its_year(): + from tools.knowledge.sources import meeting_date + assert meeting_date('Stav 8.11.2024:')['date']=='2024-11-08' + rows=profile_units({'title':'Alena Nová','path':'pages/students/2022/alena/README.md', + 'content':'# Bakalárska práca 2025\nStretnutie 4.2.2025\nStav:\n- Implementovaný systém RAG.\n\n' + 'Stav 8.11.2024:\n- Urobené poznámky o RAG.\n'}) + assert next(r for r in rows if 'poznámky' in r['text'])['date']=='2024-11-08' + + +def test_new_lifecycle_note_does_not_reextract_unchanged_later_profiles(tmp_path): + import hashlib + from tools.knowledge.build import build + from tools.sync.sync_indexes import source_files + from tools.sync.index_state import durable_json + wiki=tmp_path/'wiki';data=tmp_path/'data';data.mkdir() + paths=[] + for slug,name in [('alena','Alena Nová'),('boris','Boris Starý')]: + p=wiki/f'pages/students/2022/{slug}/README.md';p.parent.mkdir(parents=True) + p.write_text(f'---\ntitle: {name}\n---\n# Diplomová práca 2026\nStav:\n- Implementovaný systém RAG.\n') + paths.append(p) + def version(number): + return {'version':str(number),'source_sha256':hashlib.sha256(json.dumps(source_files(wiki),sort_keys=True).encode()).hexdigest()} + def complete(messages): + return {'choices':[{'message':{'content':json.dumps({'items':[]})}}]} + durable_json(data/'index_version.json',version(1)) + build(wiki,data,complete,{'model':'fixture'},graph=False,vectors=False) + old=json.loads((data/'knowledge/index.json').read_text()) + old_ids={u['person']:u['id'] for u in old['units']} + paths[0].write_text(paths[0].read_text()+'\nUkončil predčasne\n') + durable_json(data/'index_version.json',version(2)) + report=build(wiki,data,complete,{'model':'fixture'},graph=False,vectors=False) + new=json.loads((data/'knowledge/index.json').read_text()) + assert report['llm_batches']==0 + assert all(u['id']==old_ids[u['person']] for u in new['units'] if u['kind']!='lifecycle') + assert any(u['kind']=='lifecycle' for u in new['units']) diff --git a/tools/answer_evaluation/content_selection.py b/tools/answer_evaluation/content_selection.py index 99baf95..bfed4ed 100644 --- a/tools/answer_evaluation/content_selection.py +++ b/tools/answer_evaluation/content_selection.py @@ -22,6 +22,17 @@ def administrative_only(text: str) -> bool: return bool(re.search(ADMIN,n) and not re.search(COMPLETED,n)) +def reference_only(text: str) -> bool: + """A link alone establishes a reference, never an implemented result.""" + value=re.sub(r'^\s*(?:[-*+]|\d+[.)])\s+','',text).strip() + if re.fullmatch(r'\[[^]]+\]\([^\s]+\)',value):return True + from .context_policy import text_urls + links=text_urls(value) + if not links:return False + for url in links:value=value.replace(url,'') + return bool(re.fullmatch(r'(?:repozitar(?: s \w+(?: \w+)?)?|zdrojove kody|vystup je tu|vystupy su tu|odkaz|git)?',normalized(value))) + + def split_units(text: str) -> list[str]: from .rag_evidence import descriptive_text parts = re.split(r'\n\s*\n|(?=^[ \t]*(?:\d+[.)]|[-+*])\s+)', text, flags=re.M) @@ -51,6 +62,7 @@ def effective_role(text: str, role: str) -> str: def evidence_phase(text: str, role: str, source_phase: str = 'unknown') -> str: + if reference_only(text):return 'unknown' if role=='assignment':return 'assignment' if role=='task':return 'planned' if administrative_only(text):return 'administrative' @@ -63,6 +75,7 @@ def evidence_phase(text: str, role: str, source_phase: str = 'unknown') -> str: def facets_for(text: str, role: str, question: str, phase: str | None = None) -> list[str]: + if reference_only(text):return [] n = normalized(text); facets = [] if role == 'assignment': facets.append('assignment') if role == 'task':facets.append('plans') diff --git a/tools/answers/answer_delivery.py b/tools/answers/answer_delivery.py index fa9d452..e52ad9e 100644 --- a/tools/answers/answer_delivery.py +++ b/tools/answers/answer_delivery.py @@ -47,6 +47,14 @@ def with_name_resolution(result: dict, retrieved: dict) -> dict: def deliver_answer(question: str, *, engine: str = 'rag', limit: int = 5, complete=None, db_file: Path = DB_FILE, include_diagnostics: bool = False) -> dict: + from tools.answers.reference_links import checked_repository_links + result=_deliver_answer(question,engine=engine,limit=limit,complete=complete, + db_file=db_file,include_diagnostics=include_diagnostics) + return checked_repository_links(result) + + +def _deliver_answer(question: str, *, engine: str = 'rag', limit: int = 5, + complete=None, db_file: Path = DB_FILE, include_diagnostics: bool = False) -> dict: if not _capacity.acquire(blocking=False): return public_failure({'reason':'answer_capacity_exceeded','engine':engine}) try: diff --git a/tools/answers/answer_errors.py b/tools/answers/answer_errors.py index 68acc81..6bacefe 100644 --- a/tools/answers/answer_errors.py +++ b/tools/answers/answer_errors.py @@ -1,5 +1,6 @@ """Public failure wording; transport/model exception text is never a UI message.""" MESSAGES={ + 'reference_unavailable':'Zdrojový repozitár sa nepodarilo verejne overiť. Odpoveď založenú iba na tomto odkaze neuvádzam.', 'content_index_unavailable':'Obsahové vyhľadávanie momentálne nie je pripravené alebo sa jeho výstup ' 'nepodarilo overiť. Treba overiť extrakciu pre aktuálnu verziu dát; ' 'nejde o potvrdenie, že hľadané údaje neexistujú.', diff --git a/tools/answers/reference_links.py b/tools/answers/reference_links.py new file mode 100644 index 0000000..1bab42a --- /dev/null +++ b/tools/answers/reference_links.py @@ -0,0 +1,92 @@ +"""Verify public repository references without forwarding any credentials. + +Only the school's Gitea and GitHub APIs are queried. Redirects, userinfo, +custom ports and unsupported git hosts never expand the network allowlist. +""" +from __future__ import annotations + +from concurrent.futures import ThreadPoolExecutor +from functools import lru_cache +from html import unescape, escape +import json +import re +import time +import urllib.error +import urllib.parse +import urllib.request + +from tools.answer_evaluation.context_policy import text_urls + + +class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + return None + + +def repository_reference(url): + try: + host=(urllib.parse.urlsplit(url).hostname or '').lower() + return host in {'github.com','gitlab.com'} or host.startswith(('git.','gitlab.','gitea.')) + except ValueError:return False + + +@lru_cache(maxsize=512) +def _probe(url, minute): + try: + parsed=urllib.parse.urlsplit(url) + if (parsed.scheme!='https' or parsed.username or parsed.password or parsed.port not in {None,443} + or parsed.query or parsed.hostname not in {'git.kemt.fei.tuke.sk','github.com'}): + return 'unverified' + parts=parsed.path.strip('/').split('/') + if len(parts)<2 or any(not re.fullmatch(r'[A-Za-z0-9_.-]+',p) or p in {'.','..'} for p in parts[:2]): + return 'unverified' + owner,repo=parts[0],parts[1].removesuffix('.git') + base='https://api.github.com/repos/' if parsed.hostname=='github.com' else 'https://git.kemt.fei.tuke.sk/api/v1/repos/' + opener=urllib.request.build_opener(NoRedirect()) + request=urllib.request.Request(base+owner+'/'+repo,headers={'Accept':'application/json','User-Agent':'ZP-Agent repository verification'}) + with opener.open(request,timeout=4) as response: + raw=response.read(262145) + if len(raw)>262144:return 'unverified' + data=json.loads(raw) + if data.get('private') is not False or str(data.get('full_name','')).casefold()!=(owner+'/'+repo).casefold(): + return 'unverified' + # A repository can exist while a linked file/branch has been removed. + if len(parts)>2: + with opener.open(urllib.request.Request(url,headers={'User-Agent':'ZP-Agent repository verification'}),timeout=4) as response: + if response.status!=200:return 'unverified' + return 'verified' + except urllib.error.HTTPError as error: + code=error.code;error.close() + return 'not_public' if code in {401,403,404,410} else 'unverified' + except Exception: + return 'unverified' + + +def repository_status(url): + return _probe(url,int(time.monotonic()//60)) + + +def checked_repository_links(result, *, check=None): + if not result.get('accepted_for_delivery'):return result + text=result.get('answer','') + sources=result.get('source_urls',[]) + urls=sorted(u for u in (text_urls(text)|set(sources)) if repository_reference(u)) + if not urls:return result + checker=check or repository_status + with ThreadPoolExecutor(max_workers=4) as pool: + statuses=dict(zip(urls[:8],pool.map(checker,urls[:8]))) + bad={url for url in urls if statuses.get(url)!='verified'} + if not bad:return result + if bad & set(sources): + from tools.answers.answer_errors import public_failure + return public_failure({**result,'reason':'reference_unavailable'}) + variants={variant for url in bad for variant in (url,escape(url,quote=False))} + for url in sorted(variants,key=len,reverse=True): + text=re.sub(r'\[([^]\n]+)\]\(\s*?(?:\s+"[^"]*")?\s*\)',r'\1',text) + text=re.sub(r'^\s*\[[^]\n]+\]:\s*?.*$', '',text,flags=re.M) + text=re.sub(r'\s+(?:href|src)\s*=\s*(["\x27])([^"\x27]*)\1', + lambda m:'' if unescape(m[2])==url else m[0],text,flags=re.I) + text=text.replace(url,'[odkaz neoverený]') + text+='\n\nRepozitárový odkaz zo zdroja sa nepodarilo verejne overiť, preto ho neuvádzam. ' + text+='Nedostupnosť ani HTTP 404 samy nerozlišujú zmazaný a súkromný repozitár.' + return {**result,'answer':text,'suppressed_repository_links':len(bad)} diff --git a/tools/claude/build_claude_packages.py b/tools/claude/build_claude_packages.py index b32a12c..3b49761 100644 --- a/tools/claude/build_claude_packages.py +++ b/tools/claude/build_claude_packages.py @@ -11,7 +11,7 @@ import zipfile ROOT = Path(__file__).resolve().parents[2] BASE = ROOT / 'packages/claude' ENGINES = ('rag', 'graphrag') -PACKAGE_VERSION = '1.7.0' +PACKAGE_VERSION = '1.7.1' def json_bytes(value): diff --git a/tools/knowledge/build.py b/tools/knowledge/build.py index 3c6420b..29c8cfe 100644 --- a/tools/knowledge/build.py +++ b/tools/knowledge/build.py @@ -57,7 +57,16 @@ def _build(wiki_root, data_dir, complete, model_identity, *, graph, vectors, wor units = [unit for d in documents for unit in profile_units(d)] # Keep whole statements, including trailing negations/qualifications. # Only the model's annotation view may be shortened; evidence never is. - bounded = [{**unit,'id':identifier(unit['id'],i,unit['text'])} for i,unit in enumerate(units)] + previous=data_dir/'knowledge/index.json' + prior=json.loads(previous.read_text()).get('units',[]) if previous.exists() else [] + def source_identity(unit): + return (unit['source_path'],unit.get('work_label'),unit['line'],unit['text']) + prior_ids={source_identity(u):u['id'] for u in prior} + # Inserting a status note must not invalidate every subsequent profile's + # model cache just because its position in the corpus has shifted. + bounded = [{**unit,'id':prior_ids.get(source_identity(unit),identifier(unit['id'],i,unit['text']))} + for i,unit in enumerate(units)] + annotatable=[u for u in bounded if u['kind'] not in {'reference','lifecycle'}] cache = data_dir / 'knowledge/cache' cache.mkdir(parents=True, exist_ok=True) # Reuse annotations independently of dates/line offsets. These are parsed @@ -97,7 +106,7 @@ def _build(wiki_root, data_dir, complete, model_identity, *, graph, vectors, wor if not 1 <= workers <= 4: raise ValueError('Extraction workers must be between 1 and 4.') with ThreadPoolExecutor(max_workers=workers) as pool: - pending = iter(batches(bounded)) + pending = iter(batches(annotatable)) while window := list(islice(pending, workers)): # Bounded submission: an API failure cannot leave hundreds of # queued requests running after a failed extraction. @@ -110,16 +119,19 @@ def _build(wiki_root, data_dir, complete, model_identity, *, graph, vectors, wor # A batch can straddle two profiles. Give profiles with no accepted # annotation their own bounded pass rather than silently losing them. absent={u['person'] for u in extracted}-{u['person'] for u in extracted if u['concepts']} - mixed={u['person'] for batch in batches(bounded) if len({r['person'] for r in batch})>1 for u in batch} + mixed={u['person'] for batch in batches(annotatable) if len({r['person'] for r in batch})>1 for u in batch} absent &= mixed by_id={u['id']:u for u in extracted} for person in sorted(absent): - own=[u for u in bounded if u['person']==person and u['kind']!='supervisor'][:32] + own=[u for u in annotatable if u['person']==person and u['kind']!='supervisor'][:32] if own: rows,cached=process(own) by_id.update({u['id']:u for u in rows}) calls+=int(not cached);hits+=int(cached) extracted=[by_id[u['id']] for u in extracted] + extracted.extend({**u,'concepts':[],'relations':[]} for u in bounded if u['kind'] in {'reference','lifecycle'}) + order={u['id']:i for i,u in enumerate(bounded)} + extracted.sort(key=lambda u:order[u['id']]) index = {'schema': 1, 'index_version': version, 'model': model_identity, 'source_scope': 'published_student_profiles', 'profiles': len(documents), 'units': extracted, 'graph_ready': graph} diff --git a/tools/knowledge/query.py b/tools/knowledge/query.py index 40e8319..7107562 100644 --- a/tools/knowledge/query.py +++ b/tools/knowledge/query.py @@ -11,7 +11,7 @@ from datetime import date,timedelta import re from tools.answer_evaluation.context_policy import normalized -from tools.answer_evaluation.content_selection import administrative_only, facets_for +from tools.answer_evaluation.content_selection import administrative_only, facets_for, reference_only from tools.retrieval.person_names import resolve_person_names from tools.retrieval.question_constraints import unsupported_constraint, constraint_message from tools.knowledge.store import load_index, read_graph @@ -44,7 +44,7 @@ def intent(question: str) -> str | None: text = normalized(question) if re.search(r'\b(?:podobn\w*|pribuzn\w*)\b', text) and re.search(r'\b(?:kto|student\w*|osob\w*|prac\w*)\b', text): return 'similar' - if re.search(r'\b(?:vedie|viedol|veduc\w*|skolitel\w*)\b', text) and re.search(r'\b(?:ktore|ake|kto)\b', text): + if re.search(r'\b(?:vedie|viedol|veduc\w*|veden\w*|skolitel\w*)\b', text) and re.search(r'\b(?:ktore|ake|kto)\b', text): return 'supervisor' if re.search(r'\b(?:pokroc\w*|pokrok\w*|posun\w*|napred\w*)\b|\bna com\b', text): return 'progress' @@ -248,7 +248,8 @@ def answer_from_index(question: str, index: dict, *, engine: str, limit: int = 5 return row.get('work_year') in range(int(period[0][:4]), int(period[1][:4])+1) return True - filtered = [r for r in rows if scoped(r)] + filtered = [r for r in rows if scoped(r) and r['kind'] not in {'reference','lifecycle'} + and not reference_only(r['text'])] candidate_limited=False semantic_similarity=False if mode == 'supervisor': @@ -257,7 +258,10 @@ def answer_from_index(question: str, index: dict, *, engine: str, limit: int = 5 from tools.answer_evaluation.context_policy import text_contains_value names=people & supervisor_names if len(names)!=1: - return respond('Uveď celé meno vedúceho práce.', status='ambiguous', reason='missing_supervisor') + years=re.findall(r'\b[12]\d{3}\b',resolved) + suffix=f' Podmienku roku {years[0]} zachovám.' if len(set(years))==1 else '' + return respond('Koho máš na mysli? Uveď celé meno vedúceho práce.'+suffix, + status='ambiguous', reason='missing_supervisor') name=next(iter(names));students=people-{name} filtered = [r for r in candidates if text_contains_value(r['text'], name, person=True) and (not students or r['person'] in students and @@ -354,8 +358,29 @@ def answer_from_index(question: str, index: dict, *, engine: str, limit: int = 5 if row['text'] not in {r['text'] for r in groups[key]}: groups[key].append(row) if not groups: + if mode=='supervisor': + years=re.findall(r'\b[12]\d{3}\b',resolved) + period_text=f' s rokom {years[0]}' if len(set(years))==1 else ' zodpovedajúce otázke' + return respond(f'V dostupných podkladoch nie je doložené, ktoré práce{period_text} vedie {name}. ' + 'Údaj „autor dokumentu“ neurčuje vedúceho práce a rok práce nepotvrdzuje jej aktuálnu aktivitu. ' + 'Preto nemôžem uviesť potvrdený zoznam aktuálne vedených prác. ' + 'Môžeš sa osobitne opýtať na dokumenty daného autora a práce uvedené v nich.', + status='no_answer',reason='supervision_not_documented') return respond('V dostupnom výbere podkladov sa nenašiel spoľahlivý dôkaz zodpovedajúci tejto otázke.', status='no_answer', reason='no_content_evidence',truncated=candidate_limited) + if mode=='supervisor' and re.search(r'\b(?:aktualne|teraz|sucasn\w*)\b',normalized(question)): + return respond(f'Aktuálny zoznam prác, ktoré vedie {name}, dostupné podklady nepotvrdzujú. ' + 'Samotný údaj o vedúcom pri historickom zázname nepotvrdzuje, ktoré práce sú aktuálne aktívne. ' + 'Autor dokumentu nie je automaticky vedúci práce. ' + 'Môžem vypísať historicky evidované práce s explicitne uvedeným vedúcim alebo dokumenty daného autora.', + status='no_answer',reason='active_supervision_unknown') + if mode=='similar': + def similar_priority(key): + group=groups[key] + actual=[r for r in group if r['phase'] in {'result','progress'}] + substantive=[r for r in actual if not re.search(r'\bpoznam\w*|\bprecitan\w*',normalized(r['text']))] + return (bool(substantive),bool(actual),max((r.get('date') or '' for r in group),default='')) + groups=dict(sorted(groups.items(),key=lambda item:similar_priority(item[0]),reverse=True)) selected_people = list(groups)[:limit] evidence, lines, omitted = [], [], False phase_names = {'assignment': 'zadanie', 'planned': 'plán', 'result': 'zaznamenaný výsledok', @@ -363,6 +388,10 @@ def answer_from_index(question: str, index: dict, *, engine: str, limit: int = 5 for person in selected_people: lines.append(f'**{person}**') group = groups[person] + if mode=='similar': + group.sort(key=lambda r:(r['phase'] in {'result','progress'}, + not bool(re.search(r'\bpoznam\w*|\bprecitan\w*',normalized(r['text']))), + r.get('date') or '',r.get('work_year') or 0),reverse=True) if mode in {'progress','timeline','period'}: group.sort(key=lambda r: (r.get('date') or '', r['line']), reverse=True) chosen = group[:8] @@ -409,7 +438,7 @@ def answer_from_index(question: str, index: dict, *, engine: str, limit: int = 5 if row.get('completion_basis')=='checked_task': prefix=f'{work}; úloha označená ako splnená' if timestamp: - prefix += f'; {timestamp}' + (' (rok neuvedený)' if row['date_precision'] == 'day_month' else '') + prefix += f'; záznam {timestamp}' + (' (rok neuvedený)' if row['date_precision'] == 'day_month' else '') quote = re.sub(r'^\s*(?:[-*+]|\d+[.)])\s+', '', row['text']) if row['phase'] in {'assignment','planned'}: from tools.answer_evaluation.presentation import goal_text @@ -425,6 +454,11 @@ def answer_from_index(question: str, index: dict, *, engine: str, limit: int = 5 lines.append('Zadanie je doložené; dosiahnuté výsledky ani pokrok tento profil nedokladá.') if missing: lines.append('V tomto profile chýbajú doložené údaje pre: '+', '.join(missing)+'.') + work_keys={(r['source_path'],r.get('work_label')) for r in chosen} + for note in rows: + if note['kind']=='lifecycle' and (note['source_path'],note.get('work_label')) in work_keys: + lines.append('Poznámka o stave tejto práce zo zdroja: '+note['text']) + evidence.append({**note,'evidence_role':'lifecycle'}) if len(group) > len(chosen): omitted=True lines.append('Zobrazený je výber podkladov; otázku môžeš zúžiť na prácu, dátum alebo konkrétny výsledok.') @@ -441,6 +475,8 @@ def answer_from_index(question: str, index: dict, *, engine: str, limit: int = 5 lines.append('Ide o obsahovú podobnosť podloženú uvedenými úryvkami, nie dôkaz spolupráce ani rovnakej fázy dokončenia.') if mode in {'progress','period','timeline'} and re.search(r'\b(?:aktual\w*|teraz|dnes)\b',normalized(question)): lines.append('Ide o stav zaznamenaný v profiloch; tieto podklady samy nepotvrdzujú aktuálny stav k dnešnému dňu.') + if mode=='timeline': + lines.append('Dátumy označujú záznamy o výsledkoch; presný deň vykonania práce z nich automaticky nevyplýva.') truncated = len(groups) > len(selected_people) or omitted or candidate_limited if candidate_limited: lines.append('Zobrazené sú príklady z obmedzeného výberu textových dôkazov; zoznam nemusí byť úplný. ' @@ -505,6 +541,12 @@ def deliver_content(question: str, *, engine: str, limit: int, db_file, complete return public_failure({'reason':'content_index_unavailable','engine':engine}) return None units = read_graph(index) if engine == 'graphrag' else index['units'] + from tools.knowledge.sources import source_lifecycle_notes + from tools.rag.common import ZPWIKI_ROOT + notes=source_lifecycle_notes(ZPWIKI_ROOT,index['index_version'].get('source_sha256')) + existing={(row['source_path'],row.get('work_label'),row['text']) for row in units} + units=[*units,*(note for note in notes if + (note['source_path'],note.get('work_label'),note['text']) not in existing)] # Slow model calls must not hold the publication lock. Everything below # uses this captured source/graph generation, never a mixture of versions. if not mode: @@ -514,7 +556,7 @@ def deliver_content(question: str, *, engine: str, limit: int, db_file, complete 'answer':'Upresni, či sa pýtaš na pokrok, výsledky s dátumami, podobné témy alebo konkrétnu činnosť študentov.', 'engine':engine,'claims':[],'source_urls':[],'index_version':index['index_version'], 'complete':False,'truncated':False,'truncation_reasons':[]} - if mode in {'discovery','similar'}: + if mode in {'discovery','similar','supervisor'}: from tools.knowledge.sources import with_work_context units=with_work_context(units) relevance = None diff --git a/tools/knowledge/sources.py b/tools/knowledge/sources.py index dc18311..bdd972e 100644 --- a/tools/knowledge/sources.py +++ b/tools/knowledge/sources.py @@ -13,10 +13,11 @@ from tools.rag.common import clean_heading_text from tools.rag.search_core import make_source_url from tools.answer_evaluation.context_policy import normalized from tools.answer_evaluation.rag_evidence import academic_scope -from tools.answer_evaluation.content_selection import effective_role, evidence_phase +from tools.answer_evaluation.content_selection import effective_role, evidence_phase, reference_only PROFILE = re.compile(r'(?:pages/)?students/\d{4}/[^/]+/README\.md$') DATE = re.compile(r'(? str: @@ -24,9 +25,12 @@ def identifier(*values) -> str: def supervisor_name(unit: dict) -> str | None: - if unit['kind']!='supervisor' or ':' not in unit['text']: + if unit['kind']!='supervisor': return None - value=unit['text'].split(':',1)[1] + plain=re.sub(r'[*_]','',unit['text']).strip().lstrip('-+ ') + match=re.match(r'^(?:meno ved[uú]ceho(?: pr[aá]ce)?|ved[uú]c[iaí](?: pr[aá]ce)?|[šs]kolite[lľ])\s*[:\-]?\s*(.+)$',plain,re.I) + if not match:return None + value=match[1] value=re.sub(r'\[([^]]+)\]\([^)]*\)',r'\1',value) value=re.sub(r'\b(?:ing|mgr|bc|rndr|phdr|phd|doc|prof)\.?\b',' ',value,flags=re.I) words=re.findall(r'[^\W\d_]+(?:-[^\W\d_]+)*',value,re.UNICODE) @@ -46,7 +50,8 @@ def with_work_context(units: list[dict]) -> list[dict]: def meeting_date(line: str) -> dict | None: text = normalized(line) - if not re.search(r'\b(?:stretnutie|revizia|konzultacia)\b', text) and not DATE.fullmatch(line.strip().rstrip(':')): + dated_status=bool(re.match(r'^(?:stav|stav pr[aá]ce)\s+\d{1,2}\.\d{1,2}',line.strip(' *#_'),re.I)) + if not dated_status and not re.search(r'\b(?:stretnutie|revizia|konzultacia)\b', text) and not DATE.fullmatch(line.strip().rstrip(':')): return None match = DATE.search(line) if not match: @@ -79,7 +84,8 @@ def profile_units(document: dict) -> list[dict]: text = '\n'.join(buffer).strip() buffer.clear() plain = re.sub(r'^\s*(?:[-*+]|\d+[.)])\s+', '', text) - if len(normalized(text).split()) < 3 or re.fullmatch(r'https?://\S+', plain): + lifecycle=bool(LIFECYCLE.search(normalized(text))) + if (len(normalized(text).split()) < 3 and not lifecycle) or re.fullmatch(r'https?://\S+', plain): if role=='topic':role='other' return current_role = effective_role(text, role) @@ -105,6 +111,8 @@ def profile_units(document: dict) -> list[dict]: kind='topic' if phase == 'result': kind = 'result' + if reference_only(text):kind,phase='reference','unknown' + if lifecycle:kind,phase='lifecycle','unknown' if re.search(r'\b(?:meno veduceho|veduci(?: prace)?|veduca(?: prace)?|skolitel)\b', normalized(text)): kind, phase = 'supervisor', 'unknown' units.append({'id': identifier(path, work_label, start, text), 'person': person, @@ -158,6 +166,11 @@ def profile_units(document: dict) -> list[dict]: scope,work_label,work_level=None,None,None role,source_phase='other','unknown' timestamp={'date':None,'date_label':None,'date_precision':'unknown'} + inline_label=normalized(line.split(':',1)[0]) if ':' in line else '' + if (LIFECYCLE.search(normalized(line)) or inline_label in { + 'nazov','nazov prace','predbezny nazov','tema','veduci','veduci prace','meno veduceho', + 'meno veduceho prace','skolitel','repozitar'}): + flush() if not line.strip() or re.match(r'^\s*(?:[-*+]\s+|\d+[.)]\s+)', line): flush() if line.strip(): @@ -166,3 +179,31 @@ def profile_units(document: dict) -> list[dict]: buffer.append(line) flush() return units + + +def source_lifecycle_notes(wiki_root, source_digest): + """Read explicit lifecycle qualifications only from the indexed snapshot.""" + return _source_lifecycle_notes(str(wiki_root),source_digest) + + +from functools import lru_cache + +@lru_cache(maxsize=2) +def _source_lifecycle_notes(wiki_root, source_digest): + import json + from pathlib import Path + from tools.sync.sync_indexes import source_files + from tools.rag.common import load_zpwiki_page + root=Path(wiki_root) + if not (root/'pages').is_dir():return [] + files=source_files(root) + if hashlib.sha256(json.dumps(files,sort_keys=True).encode()).hexdigest()!=source_digest: + return [] # Never attach qualifications from a different source version. + notes=[] + for path in files: + if not PROFILE.fullmatch(path):continue + if not LIFECYCLE.search(normalized((root/path).read_text())):continue + document=load_zpwiki_page(root/path,wiki_root=root) + if document.get('published') is False:continue + notes.extend({**row,'evidence_role':'lifecycle'} for row in profile_units(document) if row['kind']=='lifecycle') + return notes diff --git a/tools/retrieval/person_names.py b/tools/retrieval/person_names.py index 8c02ddf..2706abf 100644 --- a/tools/retrieval/person_names.py +++ b/tools/retrieval/person_names.py @@ -80,7 +80,7 @@ def _score(words: list[str], canonical: list[str]) -> int | None: _STOP = set('a aj ale alebo ak ako ake aky aku ktore ktori ktora ktory kto kolko ci co obaja obidvaja ' 'ma maju mal mali je su bol bola bolo boli budu bude sa s so pri pre z zo na v od do roku rokoch ' - 'praca prace pracu projekt projekty dokument dokumenty nazov nazvy ' + 'praca prace pracu projekt projekty dokument dokumenty nazov nazvy nazv ' 'tag tagy klucove slova slovo temu temy kategoriu kategorie ' 'uved uvedte vypis vyber najdi zisti porovnaj popis opis čomu komu vymenuj pomenuj ' 'vsetci vsetky vsetkych plan stav vysledky problemy metody experimenty ' @@ -95,7 +95,7 @@ def _generic(word: str) -> bool: 'zameran', 'ciel', 'vysled', 'priebeh', 'pouz', 'experiment', 'problem', 'metod', 'riesen', 'system', 'vyhladav', 'inform', 'studij', 'databaz', 'zdroj', 'prehlad', 'presn', 'postup', - 'hodnot', 'technol', 'zadani', 'uloh', 'spaja', 'evidov')) + 'hodnot', 'technol', 'zadani', 'uloh', 'spaja', 'evidov', 'veden')) def _identity(row: dict) -> tuple: