Otázky a formulácie

This commit is contained in:
Ján Pták 2026-11-04 01:54:59 +01:00
parent 151eca3571
commit dd487d0c05
17 changed files with 244 additions and 19 deletions

View File

@ -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. 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 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 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 ### 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. 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é. 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 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 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. tokenovému rozpočtu. Zadanie a zaznamenaný stav sa kontrolujú a zobrazujú oddelene.

View File

@ -194,6 +194,7 @@ def verified_answer(request: AnswerRequest) -> dict:
if (result.get('accepted_for_delivery') is not True or if (result.get('accepted_for_delivery') is not True or
result.get('status') not in {'ok', 'no_answer', 'ambiguous'}): result.get('status') not in {'ok', 'no_answer', 'ambiguous'}):
result = public_failure(result) result = public_failure(result)
result={**result,'query':request.query.strip(),'engine':request.engine}
if not request.include_diagnostics: if not request.include_diagnostics:
result.pop('attempts', None) result.pop('attempts', None)
result.pop('retrieval_context', None) result.pop('retrieval_context', None)

View File

@ -6,6 +6,7 @@ from dataclasses import dataclass
import ipaddress import ipaddress
import json import json
import os import os
import re
from pathlib import Path from pathlib import Path
import stat import stat
import urllib.error import urllib.error
@ -85,6 +86,8 @@ def error_result(reason):
'unauthorized':'Prístup nebol autorizovaný. Skontroluj prihlásenie konektora a jeho konfiguráciu.', 'unauthorized':'Prístup nebol autorizovaný. Skontroluj prihlásenie konektora a jeho konfiguráciu.',
'forbidden':'Pre tento nástroj nemáš potrebné oprávnenie.', '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.', '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.', '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. ' 'answer_rejected':'Odpoveď sa nepodarilo spoľahlivo overiť podľa podkladov. '
'Spresni osobu, typ práce alebo rok; neoverený text sa nezobrazí.', '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.""" """Return only approved answer fields, or explicitly marked retrieval context."""
try: try:
if (engine not in ENGINES or mode not in {'answer', 'retrieve'} or type(limit) is not int 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') 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() config = config or Config.load()
endpoint = '/answer' if mode == 'answer' else '/' + engine endpoint = '/answer' if mode == 'answer' else '/' + engine
body = {'query': query.strip(), 'limit': limit} 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) validate_metadata(value)
fields = {'status', 'reason', 'index_version', 'complete', 'truncated', 'truncation_reasons'} fields = {'status', 'reason', 'index_version', 'complete', 'truncated', 'truncation_reasons'}
if mode == 'answer': 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: if value.get('accepted_for_delivery') is not True:
raise ClientError('answer_rejected') raise ClientError('answer_rejected')
if (not isinstance(value.get('answer'), str) or not value['answer'].strip() 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 or any(not isinstance(coverage.get(key), list) or
any(not isinstance(item, str) for item in coverage[key]) for key in ['warnings','limitations'])): any(not isinstance(item, str) for item in coverage[key]) for key in ['warnings','limitations'])):
raise ClientError('invalid_backend_response') 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: else:
if not isinstance(value.get('sources'), list): if not isinstance(value.get('sources'), list):
raise ClientError('invalid_backend_response') raise ClientError('invalid_backend_response')

View File

@ -3,12 +3,12 @@
{ {
"archive": "zpwiki-rag.zip", "archive": "zpwiki-rag.zip",
"name": "rag", "name": "rag",
"sha256": "09d3e6b196b61f6c025eb86ccd1773b913b37660163a995ad1a2acfa6d947a0c" "sha256": "0c4b1a65d7ec97e89505000c7f60239ae220098ba11dc8a6ae9fca8be396f151"
}, },
{ {
"archive": "zpwiki-graphrag.zip", "archive": "zpwiki-graphrag.zip",
"name": "graphrag", "name": "graphrag",
"sha256": "cfcb60213e103d40c2adbc6c7d4133751120148415836cfffa7935c020167e95" "sha256": "cbfd609ec66c24a03e4c75b33e5a92438a1131c25019df8f2ea522bc917ba5e3"
} }
], ],
"schema_version": 1 "schema_version": 1

Binary file not shown.

Binary file not shown.

View File

@ -10,6 +10,15 @@ For `/graphrag <question>`, send the text after the leading `/graphrag` as the q
to `zpwiki_graphrag`. Preserve every condition. If no question follows, ask for one. 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`. 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. 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, 2. Otherwise, with securely configured backend access in a Python execution environment,
run `python scripts/query.py "the complete question"` relative to this directory. run `python scripts/query.py "the complete question"` relative to this directory.

View File

@ -19,6 +19,15 @@ from starlette.responses import JSONResponse
# Host-model rules belong to MCP initialization and Skills, not tool-result data. # 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 = ( ANSWER_PRESENTATION_POLICY = (
'For this question, present the current accepted answer unchanged, including its sources and caveats. ' '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. ' '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): def create_server(**kwargs):
server = FastMCP('ZP Wiki', instructions=( server = FastMCP('ZP Wiki', instructions=(
'Use zpwiki_rag for text and individual facts; zpwiki_graphrag for relationships and counts. ' '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): for handler in (zpwiki_rag, zpwiki_graphrag, zpwiki_health):
server.add_tool(handler, annotations=ToolAnnotations( server.add_tool(handler, annotations=ToolAnnotations(
readOnlyHint=True, destructiveHint=False, openWorldHint=True)) readOnlyHint=True, destructiveHint=False, openWorldHint=True))

View File

@ -10,6 +10,15 @@ For `/rag <question>`, send the text after the leading `/rag` as the question to
`zpwiki_rag`. Preserve every condition. If no question follows, ask for one. `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`. 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. 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 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. `python scripts/query.py "the complete question"` relative to this skill's directory.

View File

@ -11,7 +11,7 @@ import zipfile
ROOT = Path(__file__).resolve().parents[1] ROOT = Path(__file__).resolve().parents[1]
BASE = ROOT / 'packages/claude' BASE = ROOT / 'packages/claude'
ENGINES = ('rag', 'graphrag') ENGINES = ('rag', 'graphrag')
PACKAGE_VERSION = '1.3.0' PACKAGE_VERSION = '1.4.0'
def json_bytes(value): def json_bytes(value):

View File

@ -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 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 <a href="/vyhodnnotte">odkazom</a>.')
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 '<a href="/vyhodnnotte">' 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(): def test_assignment_heading_does_not_keep_imperative_style():
lines = content_lines(['*Zadanie diplomovej práce*:', '1. Vypracujte prehľad metód.', lines = content_lines(['*Zadanie diplomovej práce*:', '1. Vypracujte prehľad metód.',
'2. Implementujte systém.'], 'Ján Holp', 'diploma_thesis') '2. Implementujte systém.'], 'Ján Holp', 'diploma_thesis')

View File

@ -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 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.', @pytest.mark.parametrize('literal,expected', [
'Implementujte parser pre `navrhnite a implementujte`.']) ('Použite https://example.test/pouzite/vypracujte.',
def test_grammar_does_not_change_urls_or_code_literals(literal): 'Použiť https://example.test/pouzite/vypracujte.'),
assert goal_text(literal)==(literal,False) ('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 @pytest.fixture

View File

@ -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())

View File

@ -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'] 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): def test_two_bad_attempts_never_return_unverified_model_text(payload, answer):
answer['claims'][0]['year'] = 2021 answer['claims'][0]['year'] = 2021
calls = [] calls = []

View File

@ -107,15 +107,29 @@ GOAL_VERBS = {
'natrénujte': 'Natrénovať', 'optimalizujte': 'Optimalizovať', 'natrénujte': 'Natrénovať', 'optimalizujte': 'Optimalizovať',
'identifikujte': 'Identifikovať', 'preštudujte': 'Preštudovať', 'identifikujte': 'Identifikovať', 'preštudujte': 'Preštudovať',
'pripravte': 'Pripraviť', 'pripravte': 'Pripraviť',
'vyhodnotte': 'Vyhodnotiť', 'vyhodnnotte': 'Vyhodnotiť',
} }
def goal_text(unit: str) -> tuple[str, bool]: 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.""" """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()) text = re.sub(r'^\s*(?:\d+[.)]|[-+*])\s+', '', unit.strip())
for original, replacement in GOAL_NOUNS: for original, replacement in GOAL_NOUNS:
if re.match(re.escape(original) + r'\b', text, re.I): if re.match(re.escape(original) + r'\b', text, re.I):

View File

@ -19,7 +19,9 @@ def render_graph_answer(answer: dict, context: dict) -> dict:
for person in row['people'])}) for person in row['people'])})
if values: if values:
shared_fields.add(field) 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)+'.') lines.append(prefix+label+': '+', '.join('**'+v+'**' for v in values)+'.')
for row in context.get('shared_properties',[]): for row in context.get('shared_properties',[]):
if row['field']==field and row['value'] in values: if row['field']==field and row['value'] in values:

View File

@ -8,7 +8,6 @@ from typing import Literal
from pydantic import BaseModel, ConfigDict, Field 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 .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 .rag_evidence import descriptive_text, rag_evidence_context, quote_sections, quote_matches_passage
from .answer_coverage import answer_coverage, coverage_notices from .answer_coverage import answer_coverage, coverage_notices
@ -19,7 +18,16 @@ from .evidence_selection import (resolve_selection, EvidenceSelectionError,
missing_required_evidence, overview_units) 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', work_type: Literal['bachelor_thesis', 'diploma_thesis', 'doctoral_thesis',
'bachelor_project', 'diploma_project', 'team_project', 'research_project'] | None = None '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'] sent['content_passages'] = context['content_passages']
elif not context['requirements']['academic']: elif not context['requirements']['academic']:
sent['context'] = payload.get('context', '') 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': if context['requirements']['response_mode'] == 'quotes':
sent.pop('facts') sent.pop('facts')
if 'content_candidates' in context: 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ť ' '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 ' '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.') '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)}, 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)}] {'role': 'user', 'content': json.dumps({'question': question, 'context': sent}, ensure_ascii=False)}]
attempts = [] attempts = []
@ -577,7 +594,10 @@ def generate_verified_rag(question: str, payload: dict, complete) -> dict:
if context['requirements'].get('content') else '') if context['requirements'].get('content') else '')
+ (' Použi evidence_ids z content_candidates a evidence_quotes=[]. Vyber všetky required=true; ' + (' 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=[].' '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, return {'status': 'error', 'reason': 'answer_rejected', 'accepted_for_delivery': False,
'answer': 'Odpoveď neprešla kontrolou podkladov; neoverené údaje sa nezobrazia.', 'answer': 'Odpoveď neprešla kontrolou podkladov; neoverené údaje sa nezobrazia.',
'attempts': attempts, 'index_version': context['index_version']} 'attempts': attempts, 'index_version': context['index_version']}