From dd487d0c0584060f8d0654fadb80ea5c86569185 Mon Sep 17 00:00:00 2001 From: jp170na Date: Wed, 4 Nov 2026 01:54:59 +0100 Subject: [PATCH] =?UTF-8?q?Ot=C3=A1zky=20a=20formul=C3=A1cie?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 12 +++- app/routes.py | 1 + packages/claude/client.py | 17 ++++- packages/claude/dist/packages.json | 4 +- packages/claude/dist/zpwiki-graphrag.zip | Bin 6788 -> 7270 bytes packages/claude/dist/zpwiki-rag.zip | Bin 6866 -> 7358 bytes packages/claude/graphrag/SKILL.md | 9 +++ packages/claude/mcp_server.py | 11 ++- packages/claude/rag/SKILL.md | 9 +++ scripts/build_claude_packages.py | 2 +- test/test_answer_presentation.py | 31 +++++++++ test/test_comparison_answers.py | 11 +-- test/test_question_delivery.py | 81 +++++++++++++++++++++++ test/test_verified_rag.py | 23 +++++++ tools/answer_evaluation/presentation.py | 22 ++++-- tools/answer_evaluation/render_answer.py | 4 +- tools/answer_evaluation/verified_rag.py | 26 +++++++- 17 files changed, 244 insertions(+), 19 deletions(-) create mode 100644 test/test_question_delivery.py diff --git a/README.md b/README.md index 940cd37..35c304a 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.3.0) a over správanie v novom chate. +novými ZIPmi z `packages/claude/dist/` (verzia 1.4.0) a over správanie v novom chate. ### Nahratie Skills do webového Claude @@ -1503,6 +1503,16 @@ známe rozkazovacie formulácie zadania menia na pomenovania cieľov, napríklad pravidlá po kontrole doslovného dôkazu, nie o voľné dopĺňanie faktov modelom. Neznámy opis, podmienky, negácie a údaje o dosiahnutom stave zostávajú zachované. +Známe gramatické úpravy sa uplatňujú iba na text mimo URL, kódu a HTML značiek. +Citované výňatky v dôkazoch sa nemenia. Konektor posiela aktuálnu otázku bez +parafrázy; môže odstrániť iba úvodný príkaz `/rag` alebo `/graphrag`. Odpoveď +z `/answer` nesie aj `query` a `engine`; klient odmietne nezhodu s požiadavkou. +Skills a inicializačné pokyny MCP vyžadujú porovnať otázku aj s aktuálnou správou +používateľa. Výber otázky samotným Claude zostáva potrebné overiť v jeho rozhraní. +Pri názve a roku práce model dostáva typované fakty a stručné zdroje. RAG tvrdenia +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. + 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/app/routes.py b/app/routes.py index 9d98c3c..87222e2 100644 --- a/app/routes.py +++ b/app/routes.py @@ -194,6 +194,7 @@ def verified_answer(request: AnswerRequest) -> dict: if (result.get('accepted_for_delivery') is not True or result.get('status') not in {'ok', 'no_answer', 'ambiguous'}): result = public_failure(result) + result={**result,'query':request.query.strip(),'engine':request.engine} if not request.include_diagnostics: result.pop('attempts', None) result.pop('retrieval_context', None) diff --git a/packages/claude/client.py b/packages/claude/client.py index 539b398..748af74 100644 --- a/packages/claude/client.py +++ b/packages/claude/client.py @@ -6,6 +6,7 @@ from dataclasses import dataclass import ipaddress import json import os +import re from pathlib import Path import stat import urllib.error @@ -85,6 +86,8 @@ def error_result(reason): '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.', + 'question_mismatch':'Odpoveď patrí k inej otázke a nezobrazí sa. Zopakuj aktuálnu otázku.', + 'engine_mismatch':'Príkaz a zvolený nástroj sa nezhodujú. Použi nástroj podľa /rag alebo /graphrag.', 'model_unavailable':'Jazykový model momentálne nie je dostupný. Skús otázku neskôr.', 'answer_rejected':'Odpoveď sa nepodarilo spoľahlivo overiť podľa podkladov. ' 'Spresni osobu, typ práce alebo rok; neoverený text sa nezobrazí.', @@ -146,8 +149,14 @@ def query_backend(query: str, engine: str, *, mode='answer', limit=5, config=Non """Return only approved answer fields, or explicitly marked retrieval context.""" try: if (engine not in ENGINES or mode not in {'answer', 'retrieve'} or type(limit) is not int - or not 1 <= limit <= 20 or not isinstance(query, str) or not 1 <= len(query.strip()) <= 500): + or not 1 <= limit <= 20 or not isinstance(query, str)): raise ClientError('invalid_query') + query=query.strip() + command=re.match(r'^/(rag|graphrag)(?=\s|$)',query,re.I) + if command: + if command[1].lower()!=engine:raise ClientError('engine_mismatch') + query=query[command.end():].strip() + if not 1 <= len(query) <= 500:raise ClientError('invalid_query') config = config or Config.load() endpoint = '/answer' if mode == 'answer' else '/' + engine body = {'query': query.strip(), 'limit': limit} @@ -170,6 +179,10 @@ def query_backend(query: str, engine: str, *, mode='answer', limit=5, config=Non validate_metadata(value) fields = {'status', 'reason', 'index_version', 'complete', 'truncated', 'truncation_reasons'} if mode == 'answer': + if not isinstance(value.get('query'),str): + raise ClientError('invalid_backend_response') + if value['query']!=body['query'] or value.get('engine')!=engine: + raise ClientError('question_mismatch') if value.get('accepted_for_delivery') is not True: raise ClientError('answer_rejected') if (not isinstance(value.get('answer'), str) or not value['answer'].strip() @@ -184,7 +197,7 @@ def query_backend(query: str, engine: str, *, mode='answer', limit=5, config=Non or any(not isinstance(coverage.get(key), list) or any(not isinstance(item, str) for item in coverage[key]) for key in ['warnings','limitations'])): raise ClientError('invalid_backend_response') - fields |= {'answer', 'source_urls', 'claims', 'count', 'accepted_for_delivery', 'answer_coverage'} + fields |= {'query','answer', 'source_urls', 'claims', 'count', 'accepted_for_delivery', 'answer_coverage'} else: if not isinstance(value.get('sources'), list): raise ClientError('invalid_backend_response') diff --git a/packages/claude/dist/packages.json b/packages/claude/dist/packages.json index bcbfe9a..3090d27 100644 --- a/packages/claude/dist/packages.json +++ b/packages/claude/dist/packages.json @@ -3,12 +3,12 @@ { "archive": "zpwiki-rag.zip", "name": "rag", - "sha256": "09d3e6b196b61f6c025eb86ccd1773b913b37660163a995ad1a2acfa6d947a0c" + "sha256": "0c4b1a65d7ec97e89505000c7f60239ae220098ba11dc8a6ae9fca8be396f151" }, { "archive": "zpwiki-graphrag.zip", "name": "graphrag", - "sha256": "cfcb60213e103d40c2adbc6c7d4133751120148415836cfffa7935c020167e95" + "sha256": "cbfd609ec66c24a03e4c75b33e5a92438a1131c25019df8f2ea522bc917ba5e3" } ], "schema_version": 1 diff --git a/packages/claude/dist/zpwiki-graphrag.zip b/packages/claude/dist/zpwiki-graphrag.zip index 01336d627c1d287bf25ec2c149731c0f7f547528..e4b6b6cbef8938a6b7d47ca12eb3fe382043b067 100644 GIT binary patch delta 6789 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 delta 6305 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 a!c3OIAWIGaav*vDlS_ass1=m|z3^XFEAlu1 delta 6399 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 JB>PX%e*wsxCoBK} diff --git a/packages/claude/graphrag/SKILL.md b/packages/claude/graphrag/SKILL.md index ebb4541..40f4b0d 100644 --- a/packages/claude/graphrag/SKILL.md +++ b/packages/claude/graphrag/SKILL.md @@ -10,6 +10,15 @@ 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 +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 multiple explicit commands in one message, handle each question separately in order; +keep all conditions belonging to each question together. + 1. With a ZP Wiki MCP connector, call `zpwiki_graphrag` with the complete question. 2. Otherwise, with securely configured backend access in a Python execution environment, run `python scripts/query.py "the complete question"` relative to this directory. diff --git a/packages/claude/mcp_server.py b/packages/claude/mcp_server.py index 6aec4d2..914c3b7 100644 --- a/packages/claude/mcp_server.py +++ b/packages/claude/mcp_server.py @@ -19,6 +19,15 @@ 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 ' + '/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. ' + '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. ' +) ANSWER_PRESENTATION_POLICY = ( 'For this question, present the current accepted answer unchanged, including its sources and caveats. ' 'Do not add factual claims, guesses or commentary from earlier chat answers. ' @@ -71,7 +80,7 @@ async def zpwiki_health() -> CallToolResult: def create_server(**kwargs): server = FastMCP('ZP Wiki', instructions=( 'Use zpwiki_rag for text and individual facts; zpwiki_graphrag for relationships and counts. ' - 'Tools return backend-verified answers. ' + ANSWER_PRESENTATION_POLICY), **kwargs) + 'Tools return backend-verified answers. ' + QUESTION_POLICY + ANSWER_PRESENTATION_POLICY), **kwargs) for handler in (zpwiki_rag, zpwiki_graphrag, zpwiki_health): server.add_tool(handler, annotations=ToolAnnotations( readOnlyHint=True, destructiveHint=False, openWorldHint=True)) diff --git a/packages/claude/rag/SKILL.md b/packages/claude/rag/SKILL.md index c5e541f..aef6d4e 100644 --- a/packages/claude/rag/SKILL.md +++ b/packages/claude/rag/SKILL.md @@ -10,6 +10,15 @@ 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 +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 multiple explicit commands in one message, handle each question separately in order; +keep all conditions belonging to each question together. + 1. If a ZP Wiki MCP connector is available, call `zpwiki_rag` with the complete question. 2. Otherwise, in a Python execution environment with securely configured backend access, run `python scripts/query.py "the complete question"` relative to this skill's directory. diff --git a/scripts/build_claude_packages.py b/scripts/build_claude_packages.py index 0dd4f7b..c24a8b4 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.3.0' +PACKAGE_VERSION = '1.4.0' def json_bytes(value): diff --git a/test/test_answer_presentation.py b/test/test_answer_presentation.py index ff39ed8..ce2dbeb 100644 --- a/test/test_answer_presentation.py +++ b/test/test_answer_presentation.py @@ -81,6 +81,37 @@ def test_progress_negations_and_unknown_prose_are_not_reinterpreted(text): assert text in rendered and 'tieto ciele' not in rendered +@pytest.mark.parametrize('command',['vyhodnoťte','vyhodnotte','vyhodnnotte']) +def test_known_coordinated_command_variants_are_natural_without_claiming_completion(command): + raw='Preštudujte si spôsoby vyhodnotenia RAG - RAGAS, DeepEval a '+command+' systém.' + text,converted=goal_text(raw) + assert converted and text=='Preštudovať si spôsoby vyhodnotenia RAG - RAGAS, DeepEval a vyhodnotiť systém.' + assert 'vyhodnotený' not in text and 'dokončený' not in text + + +def test_goal_formatting_protects_urls_code_attributes_and_conditions(): + raw=('Vypracujte prehľad iba pre slovenské texty a vyhodnnotte systém podľa ' + 'https://example.test/implementujte/vyhodnnotte?x=navrhnite ' + 's kódom `implementujte("vyhodnnotte")` a odkazom.') + text,converted=goal_text(raw) + assert converted and 'Vypracovať prehľad iba pre slovenské texty a vyhodnotiť systém' in text + assert 'https://example.test/implementujte/vyhodnnotte?x=navrhnite' in text + assert '`implementujte("vyhodnnotte")`' in text and '' in text + + +@pytest.mark.parametrize('complete',[True,False]) +def test_shared_document_authors_have_a_grammatical_bounded_summary(complete): + people=['Eva Nová','Boris Nový'];urls=['https://example.test/eva','https://example.test/boris'] + claims=[{'person':person,'document_author':'Daniel Hladek'} for person in people] + c={'status':'ok','facts':[{**claim,'source_url':url} for claim,url in zip(claims,urls)], + 'sources':[{'source_url':url} for url in urls], 'complete':complete,'truncated':not complete, + 'shared_properties':[{'field':'document_author','value':'Daniel Hladek','people':people}]} + rendered=render_graph_answer({'status':'ok','claims':claims,'source_urls':urls},c) + prefix='Spoloční autori dokumentov:' if complete else 'Doložení spoloční autori dokumentov:' + assert rendered['answer'].startswith(prefix) + assert answer_context_issues(rendered,c)==[] + + def test_assignment_heading_does_not_keep_imperative_style(): lines = content_lines(['*Zadanie diplomovej práce*:', '1. Vypracujte prehľad metód.', '2. Implementujte systém.'], 'Ján Holp', 'diploma_thesis') diff --git a/test/test_comparison_answers.py b/test/test_comparison_answers.py index 8a9f56c..08e6450 100644 --- a/test/test_comparison_answers.py +++ b/test/test_comparison_answers.py @@ -74,10 +74,13 @@ def test_goal_headings_are_not_extra_evidence_and_sentences_keep_capitals(): assert 'Ďalšie podklady' not in text and 'Zlepšiť proces. Zlepšiť extrakciu.' in text -@pytest.mark.parametrize('literal', ['Použite https://example.test/pouzite/vypracujte.', - 'Implementujte parser pre `navrhnite a implementujte`.']) -def test_grammar_does_not_change_urls_or_code_literals(literal): - assert goal_text(literal)==(literal,False) +@pytest.mark.parametrize('literal,expected', [ + ('Použite https://example.test/pouzite/vypracujte.', + 'Použiť https://example.test/pouzite/vypracujte.'), + ('Implementujte parser pre `navrhnite a implementujte`.', + 'Implementovať parser pre `navrhnite a implementujte`.')]) +def test_grammar_does_not_change_urls_or_code_literals(literal,expected): + assert goal_text(literal)==(expected,True) @pytest.fixture diff --git a/test/test_question_delivery.py b/test/test_question_delivery.py new file mode 100644 index 0000000..da9e2e3 --- /dev/null +++ b/test/test_question_delivery.py @@ -0,0 +1,81 @@ +"""Question/engine binding across actual HTTP/MCP boundaries, without an LLM.""" +import asyncio +import json +import os +import sys + +import pytest + +from test_claude_packages import backend, ROOT +from packages.claude import client + + +QUESTIONS=[ + 'Čomu sa venuje diplomová práca Jána Holpa? Stručne ju predstav a uveď zdroj.', + 'Porovnaj zadanie a výsledky Ondreja Megelu a Jána Holpa.\nPlán nie je výsledok; uveď zdroje.', + 'Ktorí študenti majú tag "RAG" a zároveň diplomový projekt z roku 2026?'] + + +@pytest.mark.parametrize('engine',['rag','graphrag']) +def test_latest_question_and_all_conditions_cross_http_unchanged(backend,engine): + for question in QUESTIONS: + r=client.query_backend(question,engine,limit=7) + assert r['accepted_for_delivery'] and r['query']==question and r['engine']==engine + path,body,_=backend['requests'][-1] + assert path=='/answer' and body['query']==question and body['engine']==engine and body['limit']==7 + assert len(backend['requests'])==len(QUESTIONS) + + +@pytest.mark.parametrize('engine',['rag','graphrag']) +def test_only_a_matching_leading_command_is_removed(backend,engine): + question='Aké práce má Ján Holp v roku 2020? Zachovaj výraz "RAG".' + r=client.query_backend(' /'+engine.upper()+' '+question+' ',engine) + assert r['accepted_for_delivery'] and r['query']==question + assert backend['requests'][-1][1]['query']==question + other='graphrag' if engine=='rag' else 'rag' + r=client.query_backend('/'+other+' '+question,engine) + assert r['reason']=='engine_mismatch' and not r['accepted_for_delivery'] + assert len(backend['requests'])==1 + + +@pytest.mark.parametrize('question',['/rag','/rag ','/graphrag']) +def test_command_without_a_question_never_reaches_backend(backend,question): + engine='graphrag' if question.startswith('/graphrag') else 'rag' + assert client.query_backend(question,engine)['reason']=='invalid_query' + assert not backend['requests'] + + +@pytest.mark.parametrize('change',[ + {'query':'A previous unrelated question','engine':'rag'}, + {'query':QUESTIONS[0],'engine':'graphrag'}, +]) +def test_stale_question_or_wrong_engine_cannot_deliver_another_answer(backend,change): + backend['raw']=json.dumps({**backend['checked'],'answer':'OTHER_ANSWER_SHOULD_NOT_ESCAPE',**change}).encode() + r=client.query_backend(QUESTIONS[0],'rag') + assert r['reason']=='question_mismatch' and not r['accepted_for_delivery'] + assert 'OTHER_ANSWER' not in json.dumps(r) + + +def test_missing_question_echo_is_not_accepted(backend): + backend['raw']=json.dumps({**backend['checked'],'engine':'rag'}).encode() + r=client.query_backend(QUESTIONS[0],'rag') + assert r['reason']=='invalid_backend_response' and not r['accepted_for_delivery'] + + +def test_consecutive_mcp_calls_do_not_reuse_previous_queries(backend): + from mcp import ClientSession,StdioServerParameters + from mcp.client.stdio import stdio_client + from packages.claude.mcp_server import QUESTION_POLICY + async def run(): + params=StdioServerParameters(command=sys.executable,args=[str(ROOT/'packages/claude/mcp_server.py')], + env={**os.environ,'PYTHONDONTWRITEBYTECODE':'1'}) + async with stdio_client(params) as (read,write): + async with ClientSession(read,write) as session: + initialized=await session.initialize() + assert QUESTION_POLICY in initialized.instructions + for engine,question in [('rag',QUESTIONS[0]),('graphrag',QUESTIONS[2]),('rag',QUESTIONS[1])]: + result=await session.call_tool('zpwiki_'+engine,{'query':question}) + assert not result.isError and result.structuredContent['query']==question + assert backend['requests'][-1][1]['query']==question + assert backend['requests'][-1][1]['engine']==engine + asyncio.run(run()) diff --git a/test/test_verified_rag.py b/test/test_verified_rag.py index 6a2b16c..621798f 100644 --- a/test/test_verified_rag.py +++ b/test/test_verified_rag.py @@ -109,6 +109,29 @@ def test_bad_answer_is_repaired_once_and_only_verified_fields_are_rendered(paylo assert 'unsupported_person_work_type_year_binding' in result['attempts'][0]['issues'] +def test_scalar_claims_require_real_source_urls_without_graph_record_ids(payload,answer): + empty=copy.deepcopy(answer);empty['source_urls']=[] + outputs=iter([empty,answer]);calls=[] + def complete(messages): + calls.append(copy.deepcopy(messages)) + assert 'source_urls pri kladnej odpovedi nesmie byť prázdne' in messages[0]['content'] + sent=json.loads(messages[1]['content'])['context'] + assert all('text' not in row for row in sent['sources']) + return completion(next(outputs)) + r=generate_verified_rag(QUESTION,payload,complete) + assert r['accepted_for_delivery'] and len(calls)==2 + assert 'missing_required_citations' in r['attempts'][0]['issues'] + assert 'skutočnú wiki URL' in calls[1][-1]['content'] + assert r['source_urls']==answer['source_urls'] + + +def test_rag_claim_schema_does_not_accept_a_source_id_as_a_graph_record(payload,answer): + wrong=copy.deepcopy(answer);wrong['claims'][0]['record_id']='S1' + r=generate_verified_rag(QUESTION,payload,lambda _:completion(wrong)) + assert not r['accepted_for_delivery'] + assert all(attempt['issues']==['invalid_answer_format'] for attempt in r['attempts']) + + def test_two_bad_attempts_never_return_unverified_model_text(payload, answer): answer['claims'][0]['year'] = 2021 calls = [] diff --git a/tools/answer_evaluation/presentation.py b/tools/answer_evaluation/presentation.py index 9501e14..fd4c34c 100644 --- a/tools/answer_evaluation/presentation.py +++ b/tools/answer_evaluation/presentation.py @@ -107,15 +107,29 @@ GOAL_VERBS = { 'natrénujte': 'Natrénovať', 'optimalizujte': 'Optimalizovať', 'identifikujte': 'Identifikovať', 'preštudujte': 'Preštudovať', 'pripravte': 'Pripraviť', + 'vyhodnotte': 'Vyhodnotiť', 'vyhodnnotte': 'Vyhodnotiť', } def goal_text(unit: str) -> tuple[str, bool]: + """Format only known goal verbs, keeping URLs and code byte-for-byte intact.""" + prefix='_ZP_LITERAL_' + while prefix in unit:prefix+='X' + literals={} + def protect(value): + key=prefix+str(len(literals))+'_';literals[key]=value + return key + text=re.sub(r'(`+)([\s\S]*?)\1',lambda m:protect(m[0]),unit) + text=re.sub(r'<[^>]+>',lambda m:protect(m[0]),text) + for url in sorted(text_urls(text),key=len,reverse=True): + text=text.replace(url,protect(url)) + result,is_goal=_goal_text(text) + for key,value in literals.items():result=result.replace(key,value) + return result,is_goal + + +def _goal_text(unit: str) -> tuple[str, bool]: """Only bounded grammatical substitutions; unknown prose stays verbatim.""" - if text_urls(unit) or '`' in unit: - # A word inside a URL/code literal must never be conjugated. Preserve - # technical excerpts exactly rather than changing their references. - return unit.strip(), False text = re.sub(r'^\s*(?:\d+[.)]|[-+*])\s+', '', unit.strip()) for original, replacement in GOAL_NOUNS: if re.match(re.escape(original) + r'\b', text, re.I): diff --git a/tools/answer_evaluation/render_answer.py b/tools/answer_evaluation/render_answer.py index e2dc034..c4c34da 100644 --- a/tools/answer_evaluation/render_answer.py +++ b/tools/answer_evaluation/render_answer.py @@ -19,7 +19,9 @@ def render_graph_answer(answer: dict, context: dict) -> dict: for person in row['people'])}) if values: shared_fields.add(field) - prefix='Spoločné ' if context.get('complete') is True and not context.get('truncated') else 'Doložené spoločné ' + full=context.get('complete') is True and not context.get('truncated') + prefix=(('Spoloční ' if full else 'Doložení spoloční ') if field=='document_author' else + ('Spoločné ' if full else 'Doložené spoločné ')) lines.append(prefix+label+': '+', '.join('**'+v+'**' for v in values)+'.') for row in context.get('shared_properties',[]): if row['field']==field and row['value'] in values: diff --git a/tools/answer_evaluation/verified_rag.py b/tools/answer_evaluation/verified_rag.py index b945307..8ea7a8c 100644 --- a/tools/answer_evaluation/verified_rag.py +++ b/tools/answer_evaluation/verified_rag.py @@ -8,7 +8,6 @@ from typing import Literal from pydantic import BaseModel, ConfigDict, Field -from .answer_format import Claim from .context_policy import answer_context_issues, citation_check, allowed_urls, normalized, text_urls from .rag_evidence import descriptive_text, rag_evidence_context, quote_sections, quote_matches_passage from .answer_coverage import answer_coverage, coverage_notices @@ -19,7 +18,16 @@ from .evidence_selection import (resolve_selection, EvidenceSelectionError, missing_required_evidence, overview_units) -class RagClaim(Claim): +class RagClaim(BaseModel): + # RAG facts carry source_id, not the graph's record_id/work_label fields. + model_config = ConfigDict(extra='forbid', strict=True) + person: str + title: str | None = None + year: int | None = None + start_year: int | None = None + keyword: str | None = None + category: str | None = None + document_author: str | None = None work_type: Literal['bachelor_thesis', 'diploma_thesis', 'doctoral_thesis', 'bachelor_project', 'diploma_project', 'team_project', 'research_project'] | None = None @@ -503,6 +511,9 @@ def generate_verified_rag(question: str, payload: dict, complete) -> dict: sent['content_passages'] = context['content_passages'] elif not context['requirements']['academic']: sent['context'] = payload.get('context', '') + elif context['requirements']['response_mode']=='claims': + sent['sources']=[{key:source[key] for key in ['source_id','source_url','title','document_path'] if key in source} + for source in context['sources']] if context['requirements']['response_mode'] == 'quotes': sent.pop('facts') if 'content_candidates' in context: @@ -528,6 +539,12 @@ def generate_verified_rag(question: str, payload: dict, complete) -> dict: 'Aplikácia konkrétne pomenuje chýbajúce informácie. no_answer tu nesmie odmietnuť ' 'aj dostupnú časť. Plánovaný experiment alebo vyhodnotenie označené phase=planned ' 'odpovedá na otázku, čo mali pripraviť; nie je to dosiahnutý výsledok.') + elif context['requirements']['response_mode']=='claims': + selection_policy=('\nPre tento dotaz je response_mode=claims: vyber požadované údaje z facts. ' + 'Každý claim MUSÍ mať citáciu v source_urls: použi source_url príslušného faktu alebo zdroja. ' + 'source_urls pri kladnej odpovedi nesmie byť prázdne. evidence_quotes=[] a evidence_ids=[]. ' + 'source_id ako S1 je len označenie podkladu; nevkladaj ho do claims. ' + 'Polia record_id a work_label patria iba grafovému nástroju, tu sa nepoužívajú.') messages = [{'role': 'system', 'content': PROMPT + selection_policy + '\nSchéma: ' + json.dumps(RagAnswer.model_json_schema(), ensure_ascii=False)}, {'role': 'user', 'content': json.dumps({'question': question, 'context': sent}, ensure_ascii=False)}] attempts = [] @@ -577,7 +594,10 @@ def generate_verified_rag(question: str, payload: dict, complete) -> dict: if context['requirements'].get('content') else '') + (' Použi evidence_ids z content_candidates a evidence_quotes=[]. Vyber všetky required=true; ' 'doslovné texty aj ich citácie dosadí aplikácia. Pri response_mode=quotes musí byť claims=[].' - if 'content_candidates' in context else '')}] + if 'content_candidates' in context else '') + + (' Ku každému claimu uveď vo source_urls jeho skutočnú wiki URL zo sources/facts. ' + 'Prázdne source_urls ani source_id=S1 nie sú citácia. Do claims nepridávaj record_id ani work_label.' + if context['requirements']['response_mode']=='claims' else '')}] return {'status': 'error', 'reason': 'answer_rejected', 'accepted_for_delivery': False, 'answer': 'Odpoveď neprešla kontrolou podkladov; neoverené údaje sa nezobrazia.', 'attempts': attempts, 'index_version': context['index_version']}