diff --git a/README.md b/README.md index 3c628c3..a1c776d 100644 --- a/README.md +++ b/README.md @@ -1160,7 +1160,16 @@ 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.5.0) a over správanie v novom chate. +novými ZIPmi z `packages/claude/dist/` (verzia 1.5.1) a over správanie v novom chate. + +Predvolený výstup Claude nástrojov obsahuje overenú odpoveď, citácie, identitu +otázky/nástroja, stav doručenia a verziu dát. Technické údaje o výbere kandidátov, +orezaní retrievalu, `answer_coverage`, jednotlivé claims a pomocné počty sa do +prezentačného výsledku nekopírujú. Plný validovaný kontrakt zostáva v `/answer` +a internom klientovi; diagnostika je dostupná cez `--mode retrieve` a `--health`. +Samotný text odpovede sa nemení: konkrétne chýbajúce údaje aj upozornenia pri +skutočne skrátenom výpise zostávajú zachované. Neprítomnosť technických polí +neznamená úplnosť vyhľadania. ### Nahratie Skills do webového Claude diff --git a/packages/claude/client.py b/packages/claude/client.py index 748af74..1f6984a 100644 --- a/packages/claude/client.py +++ b/packages/claude/client.py @@ -231,6 +231,18 @@ def backend_health(config=None): return {**error_result(str(exc)), 'ready': False} +def answer_for_presentation(result: dict) -> dict: + """Keep approved prose/caveats intact; retrieval telemetry is not answer prose. + + Called after query_backend validates the complete backend contract. Full + metadata remains available there, in /answer and in diagnostic retrieval. + Never change an incomplete result into a claim of completeness. + """ + fields = {'status', 'reason', 'query', 'engine', 'output_kind', + 'accepted_for_delivery', 'answer', 'source_urls', 'index_version'} + return {key: value for key, value in result.items() if key in fields} + + def main(engine=None): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('query', nargs='?') @@ -242,6 +254,8 @@ def main(engine=None): args = parser.parse_args() result = backend_health() if args.health else query_backend(args.query, engine or args.engine, mode=args.mode, limit=args.limit) + if not args.health and args.mode == 'answer': + result = answer_for_presentation(result) print(json.dumps(result, ensure_ascii=False, separators=(',', ':'))) return 1 if result.get('status') == 'error' else 0 diff --git a/packages/claude/dist/packages.json b/packages/claude/dist/packages.json index e5fdc26..bcca32c 100644 --- a/packages/claude/dist/packages.json +++ b/packages/claude/dist/packages.json @@ -3,12 +3,12 @@ { "archive": "zpwiki-rag.zip", "name": "rag", - "sha256": "8e5ab92284ee6f53d10e468cb4edee6cfd354ae869583673cbb6aea8ea9ca437" + "sha256": "f439237795a770473bf5efd0760667f9ef75fa4882086133996d4a928a9d0252" }, { "archive": "zpwiki-graphrag.zip", "name": "graphrag", - "sha256": "99a9ea36b439d6b14edc35c2d4d0c1c2cf97139f78e8fd73b8ce2347d82b1092" + "sha256": "cd748dc0035bf932a72f6d178f986b18609728a3478fdf9a49ab6247a37a431d" } ], "schema_version": 1 diff --git a/packages/claude/dist/zpwiki-graphrag.zip b/packages/claude/dist/zpwiki-graphrag.zip index 63dd60f..609eafb 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 7f1361d..3c5d3af 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 c0d9457..c6f8574 100644 --- a/packages/claude/graphrag/SKILL.md +++ b/packages/claude/graphrag/SKILL.md @@ -34,15 +34,16 @@ keep all conditions belonging to each question together. 4. For `status=error` or a rejected answer, report unavailability without showing rejected text. For `ambiguous`, ask for clarification. For `no_answer`, say no supporting relationship was found; do not substitute unrelated people or global results. -5. Respect `complete`, `truncated`, `truncation_reasons` and `index_version`. - An incomplete or unknown-completeness listing is not exhaustive. An exact total is - permitted only when the aggregation itself is complete. Do not merge index versions. +5. The default verified-answer result contains presentation fields, not retrieval telemetry. + Preserve every specific limitation or missing-information notice already in `answer`. + Do not append commentary about backend checks, coverage labels or retrieval limits. + Absence of technical metadata never establishes an exhaustive list, uniqueness or a total. + Use only the checked totals already stated in `answer`. Do not merge `index_version` values. Presentation boundaries: - Use the current tool response for the current question. Do not append facts, records or explanations from previous chat answers. -- `complete` describes retrieval coverage, not universal truth or the existence of a person. - An incomplete listing can contain supported facts but cannot establish uniqueness or the +- An incomplete listing can contain supported facts but cannot establish uniqueness or the absence of other records. Preserve its caveat without dismissing it using earlier answers. - `document_author` identifies the source document's author. Do not infer thesis authorship, supervision, or a maintainer role from that metadata. diff --git a/packages/claude/mcp_server.py b/packages/claude/mcp_server.py index 33df4fd..d5a1006 100644 --- a/packages/claude/mcp_server.py +++ b/packages/claude/mcp_server.py @@ -11,7 +11,7 @@ from pathlib import Path from urllib.parse import urlsplit sys.path.insert(0, str(Path(__file__).resolve().parent)) -from client import Config, backend_health, query_backend +from client import Config, backend_health, query_backend, answer_for_presentation from mcp.server.fastmcp import FastMCP from mcp.server.transport_security import TransportSecuritySettings from mcp.types import CallToolResult, TextContent, ToolAnnotations @@ -38,11 +38,10 @@ ANSWER_PRESENTATION_POLICY = ( 'Do not add factual claims, guesses or commentary from earlier chat answers. ' 'document_author means author of the source document; it does not establish thesis authorship, ' 'supervision or a maintainer role. Do not guess those roles. ' - 'complete and truncated describe retrieval coverage, not the truth of each returned fact. ' - 'For RAG, answer_coverage separately describes the delivered answer scope. supported means ' - 'evidence-backed, not exhaustive. Do not append a generic incompleteness warning merely ' - 'because retrieval selected top candidates/results. Preserve the specific notices already ' - 'included in answer for limited lists or clipped source text. ' + 'The answer already includes the specific missing-information and completeness notices checked by the backend. ' + 'Do not append a paragraph about backend validation, partial coverage, candidate/result limits or retrieval diagnostics. ' + 'Do not infer that results are exhaustive from the absence of technical metadata. ' + 'Preserve every specific notice already included in answer for limited lists or clipped source text. ' 'An incomplete listing can contain supported facts, but cannot establish uniqueness or absence ' 'of other records. Retain its caveat; do not dismiss it using earlier answers. ' 'For no_answer, return only the supplied absence-of-evidence answer and any supplied citations. ' @@ -66,7 +65,7 @@ async def zpwiki_rag(query: str, limit: int = 5) -> CallToolResult: Present the accepted answer with its citations; add no guesses or history commentary. """ - return tool_result(await asyncio.to_thread(query_backend, query, 'rag', limit=limit)) + return tool_result(answer_for_presentation(await asyncio.to_thread(query_backend, query, 'rag', limit=limit))) async def zpwiki_graphrag(query: str, limit: int = 5) -> CallToolResult: @@ -74,7 +73,7 @@ async def zpwiki_graphrag(query: str, limit: int = 5) -> CallToolResult: Present the accepted answer with its citations; no_answer means no supporting evidence. """ - return tool_result(await asyncio.to_thread(query_backend, query, 'graphrag', limit=limit)) + return tool_result(answer_for_presentation(await asyncio.to_thread(query_backend, query, 'graphrag', limit=limit))) async def zpwiki_health() -> CallToolResult: diff --git a/packages/claude/rag/SKILL.md b/packages/claude/rag/SKILL.md index 650c9bf..5f46e1a 100644 --- a/packages/claude/rag/SKILL.md +++ b/packages/claude/rag/SKILL.md @@ -36,13 +36,12 @@ keep all conditions belonging to each question together. 4. For `status=error` or rejected delivery, explain that verified output is unavailable; never reproduce rejected text or invent a replacement. For `ambiguous`, ask the user to clarify. For `no_answer`, report the absence of evidence without claiming the fact is false. -5. Respect `complete`, `truncated`, `truncation_reasons` and `index_version`. - These describe retrieval, not whether the selected answer is supported. `answer_coverage` - separately describes the answer scope: `supported` means evidence-backed, never exhaustive. - Present the backend's specific notices unchanged. Do not append a generic incompleteness - warning to a supported fact or short description just because retrieval selected top results. - Never describe an incomplete or unknown-completeness result as an exhaustive list. - Do not combine different index versions as if they represented one snapshot. +5. The default verified-answer result contains presentation fields, not retrieval telemetry. + Missing aspects and actual limitations are already stated specifically in `answer`. + Preserve those notices unchanged. Do not append commentary about backend checks, + `partial`, candidate/result limits, excerpt limits or general incompleteness. + Absence of technical metadata never establishes an exhaustive list or uniqueness. + Do not combine different `index_version` values as if they represented one snapshot. Presentation boundaries: - Use the current tool response for the current question. Do not append factual material diff --git a/scripts/build_claude_packages.py b/scripts/build_claude_packages.py index 9a0042a..1ce0a96 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.5.0' +PACKAGE_VERSION = '1.5.1' def json_bytes(value): diff --git a/test/test_claude_packages.py b/test/test_claude_packages.py index 2114a87..c5ab605 100644 --- a/test/test_claude_packages.py +++ b/test/test_claude_packages.py @@ -141,6 +141,37 @@ def test_answer_coverage_is_forwarded_without_overwriting_retrieval_limits(backe assert 'presentation_instructions' not in result +@pytest.mark.parametrize('engine', ['rag', 'graphrag']) +@pytest.mark.parametrize('text', [ + 'V podkladoch chýbajú údaje o riešených problémoch.', + 'Doložené ciele práce. Časť podkladov k zadaniu bola skrátená; uvedené body nemusia tvoriť celé zadanie.', + 'Doložené práce. Výpis je neúplný. Výpis môžeš zúžiť uvedením osoby alebo roku.', +]) +def test_presentation_preserves_specific_caveats_without_retrieval_telemetry(backend, engine, text): + backend['checked'].update(answer=text, answer_coverage={ + 'scope':'fact', 'status':'partial', 'warnings':['partial_requested_aspects'], + 'limitations':['candidate_limit','result_limit','per_document_limit']}) + raw=client.query_backend('Práca?',engine) + original=copy.deepcopy(raw) + shown=client.answer_for_presentation(raw) + assert shown['answer']==text and shown['source_urls']==raw['source_urls'] + assert shown['query']==raw['query'] and shown['engine']==engine + assert shown['index_version']==raw['index_version'] and shown['accepted_for_delivery'] is True + hidden={'complete','truncated','truncation_reasons','answer_coverage','claims','count'} + assert not hidden.intersection(shown) + assert not any(token in json.dumps(shown) for token in ['candidate_limit','per_document_limit','partial_requested_aspects']) + # The full API/client contract still reports the actual incomplete retrieval. + assert raw==original and raw['complete'] is False and raw['truncated'] is True + assert raw['answer_coverage']['status']=='partial' + + +def test_presentation_cannot_turn_a_rejected_response_into_an_approved_answer(backend): + backend['checked'].update(status='error', answer='PRIVATE REJECTED ANSWER') + shown=client.answer_for_presentation(client.query_backend('Práca?','rag')) + assert shown['status']=='error' and shown['accepted_for_delivery'] is False + assert 'PRIVATE' not in json.dumps(shown) + + @pytest.mark.parametrize('coverage', [None, [], {'status': 'complete'}, {'scope': 'fact', 'status': 'supported', 'warnings': 'none', 'limitations': []}, {'scope': [], 'status': 'supported', 'warnings': [], 'limitations': []}, @@ -244,6 +275,13 @@ def test_zip_is_reproducible_and_runs_without_repository(tmp_path, backend, engi assert result.returncode == 0, result.stderr assert json.loads(result.stdout)['answer'] == backend['checked']['answer'] assert 'presentation_instructions' not in json.loads(result.stdout) + assert not {'complete','truncated','truncation_reasons','answer_coverage','claims'}.intersection(json.loads(result.stdout)) + diagnostic=subprocess.run([*command,'--mode','retrieve'],cwd=folder,env=environment, + capture_output=True,text=True,timeout=15) + assert diagnostic.returncode==0,diagnostic.stderr + raw=json.loads(diagnostic.stdout) + assert raw['output_kind']=='retrieval_context' and raw['requires_final_answer_verification'] is True + assert raw['complete'] is False and raw['truncated'] is True def test_committed_archives_match_current_sources(tmp_path): @@ -259,6 +297,8 @@ def test_mcp_stdio_initialization_discovery_call_and_rejection(backend): from mcp import ClientSession, StdioServerParameters from mcp.client.stdio import stdio_client from packages.claude.mcp_server import ANSWER_PRESENTATION_POLICY + backend['checked']['answer_coverage']={'scope':'fact','status':'partial', + 'warnings':['partial_requested_aspects'],'limitations':['candidate_limit','per_document_limit']} async def run(): params = StdioServerParameters(command=sys.executable, @@ -275,7 +315,12 @@ def test_mcp_stdio_initialization_discovery_call_and_rejection(backend): result = await session.call_tool('zpwiki_' + engine, {'query': 'Práca Evy Novej?'}) assert not result.isError assert result.structuredContent['answer'] == backend['checked']['answer'] + assert not {'complete','truncated','truncation_reasons','answer_coverage','claims'}.intersection(result.structuredContent) assert 'presentation_instructions' not in result.structuredContent + assert all(json.loads(block.text)==result.structuredContent + for block in result.content if block.type=='text') + assert 'candidate_limit' not in json.dumps(result.model_dump()) + assert 'partial_requested_aspects' not in json.dumps(result.model_dump()) assert all('presentation_instructions' not in json.loads(block.text) for block in result.content if block.type == 'text') rejected = await session.call_tool('zpwiki_rag', {'query': ''})