diff --git a/docs/compact-investigation-probe.md b/docs/compact-investigation-probe.md new file mode 100644 index 0000000..3495df2 --- /dev/null +++ b/docs/compact-investigation-probe.md @@ -0,0 +1,33 @@ +# Einzelmessung des kompakten Investigation-Prompts + +```powershell +.\.venv\Scripts\python.exe -m viano_bug_analysis.prompt_probe --run +``` + +Ohne --run erfolgt nur eine lokale Prüfung. Standardinput ist +service-data/benchmarks/compact-investigation-20261017/request.json. +Standardoutput ist result.json im selben Ordner. Ein vorhandenes Ergebnis +verhindert unbeabsichtigte Wiederholungen; für eine bewusst neue Messung +--output mit einem neuen Dateinamen verwenden. + +Genau ein nativer Gemini-Aufruf, keine Wiederholung, kein Failover, kein +expliziter Cache und keinerlei Ausführung der vorgeschlagenen Recherche. +Das vorhandene Gateway-Konfigurationsprofil bestimmt Modell, Denkstufe, +Ausgabelimit und Timeout. Produktionsprompts bleiben unverändert. + +Instruktionen und Kontext werden als System-/User-Nachricht übertragen; +das Ausgabeschema wird ausschließlich im nativen generationConfig gesetzt. +Erfasst werden Eingabe-, Kandidaten-, Denk-, Cache- und Gesamttokens sowie +Laufzeit, Modellversion, JSON-Schemavalidität und der Hash der vorbereiteten +Anfrage. Eine passende JSON-Struktur allein beweist keine fachliche Qualität. + +Der vorbereitete Prompt umfasst 8.396 Nachrichtenzeichen plus 2.486 Zeichen +minifiziertes Ausgabeschema, zusammen 10.882. Das sind etwa 29 mal 375 Zeichen +(des englischen Minimaltests). Formatierung der lesbaren PROMPT.txt zählt +nicht als zusätzlicher API-Input. Keine Tokenschätzung ersetzt die API-Messung. + +Testgrundlage ist der erste H1-Kontext von Bug 20 vor jeder Investigation- +Recherche; die Vorbereitung enthält keine später festgestellten Spawndaten. +Messartefakte und Provenienz liegen im Benchmarkordner, nicht in der produktiven +Fallakte. Ein Einzeltest untersucht Schrittwahl und Kontextgröße, nicht die +Qualität oder Gesamtkosten einer vollständigen Untersuchung. diff --git a/src/viano_bug_analysis/minimal_prompt_test.py b/src/viano_bug_analysis/minimal_prompt_test.py index 0605d8d..8dd1306 100644 --- a/src/viano_bug_analysis/minimal_prompt_test.py +++ b/src/viano_bug_analysis/minimal_prompt_test.py @@ -57,8 +57,8 @@ def measure(provider,case,limit,post=None): text=''.join(part['text'] for part in candidate.get('content',{}).get('parts',[]) if 'text' in part and not part.get('thought')) result.update(answer=text,finish_reason=candidate.get('finishReason'),model=envelope.get('modelVersion',provider.model)) result['status']='SUCCESS' if candidate.get('finishReason')=='STOP' and text else 'INCOMPLETE' - if case['id'].endswith('_json') and result['status']=='SUCCESS': - try: valid=Draft202012Validator(SCHEMA).is_valid(json.loads(text)) + if 'responseJsonSchema' in case['payload']['generationConfig'] and result['status']=='SUCCESS': + try: valid=Draft202012Validator(case['payload']['generationConfig']['responseJsonSchema']).is_valid(json.loads(text)) except ValueError: valid=False result['schema_valid']=valid if not valid: result['status']='INVALID_JSON' diff --git a/src/viano_bug_analysis/prompt_probe.py b/src/viano_bug_analysis/prompt_probe.py new file mode 100644 index 0000000..e452753 --- /dev/null +++ b/src/viano_bug_analysis/prompt_probe.py @@ -0,0 +1,69 @@ +"""Measure one prepared Gemini request without executing research tools.""" +import argparse +import hashlib +import json +from pathlib import Path +from jsonschema import Draft202012Validator +from .llm import GatewayConfig, LLMError +from .llm.gateway import PRIVACY +from .minimal_prompt_test import measure +from .telemetry.safety import safe_code + +DEFAULT = Path('service-data/benchmarks/compact-investigation-20261017/request.json') + + +def prepare(provider,request): + if (set(request)!={'prompt_version','messages','output_schema'} or + not isinstance(request['prompt_version'],str) or not request['prompt_version'].strip()): + raise LLMError('PROMPT_PROBE_REQUEST') + messages=request['messages'] + if (not isinstance(messages,list) or [m.get('role') for m in messages if isinstance(m,dict)]!=['system','user'] or + any(set(m)!={'role','content'} or not isinstance(m['content'],str) or not m['content'].strip() for m in messages)): + raise LLMError('PROMPT_PROBE_REQUEST') + Draft202012Validator.check_schema(request['output_schema']) + generation={'maxOutputTokens':provider.max_output_tokens,'responseMimeType':'application/json', + 'responseJsonSchema':request['output_schema']} + if provider.reasoning_effort is not None:generation['thinkingConfig']={'thinkingLevel':provider.reasoning_effort} + payload={'systemInstruction':{'parts':[{'text':messages[0]['content']}]}, + 'contents':[{'role':'user','parts':[{'text':messages[1]['content']}]}],'generationConfig':generation} + return {'id':request['prompt_version'],'prompt_characters':sum(len(m['content']) for m in messages), + 'payload':payload} + + +def main(argv=None): + p=argparse.ArgumentParser(description=__doc__) + p.add_argument('--request',type=Path,default=DEFAULT) + p.add_argument('--llm-config',default='config/llm.working-brief.local.toml') + p.add_argument('--provider') + p.add_argument('--output',type=Path) + p.add_argument('--run',action='store_true',help='Make exactly one potentially billable API call') + args=p.parse_args(argv) + try: + config=GatewayConfig.load(args.llm_config) + providers=[v for v in config.providers if v.enabled and v.privacy in PRIVACY[config.privacy_profile]] + provider=next((v for v in providers if v.id==args.provider),None) if args.provider else next(iter(providers),None) + if provider is None or provider.adapter!='gemini':raise LLMError('PROMPT_PROBE_PROVIDER') + raw=args.request.read_bytes() + if len(raw)>config.max_request_bytes:raise LLMError('LLM_REQUEST_LIMIT') + request=json.loads(raw);case=prepare(provider,request) + if len(json.dumps(case['payload'],ensure_ascii=False).encode('utf-8'))>config.max_request_bytes:raise LLMError('LLM_REQUEST_LIMIT') + metadata={'request_sha256':hashlib.sha256(raw).hexdigest(),'provider':provider.id,'model':provider.model, + 'reasoning_effort':provider.reasoning_effort,'max_output_tokens':provider.max_output_tokens, + 'timeout_seconds':provider.timeout_seconds,'explicit_cache':False,'research_queries_executed':0} + if not args.run: + print(json.dumps({'status':'PREPARED','network_used':False,**metadata, + 'message_characters':case['prompt_characters']}));return 0 + output=args.output or args.request.with_name('result.json') + # Exclusive reservation prevents accidental duplicate paid execution. + with output.open('x',encoding='utf-8') as f:json.dump({'status':'RUNNING',**metadata},f) + result={**measure(provider,case,config.max_response_bytes),**metadata} + output.write_text(json.dumps(result,ensure_ascii=False,indent=2)+'\n',encoding='utf-8') + print(json.dumps(result,ensure_ascii=True)) + return 0 if result['status']=='SUCCESS' else 2 + except KeyboardInterrupt: + print(json.dumps({'status':'INTERRUPTED'}));return 130 + except Exception as error: + print(json.dumps({'status':'ERROR','code':safe_code(getattr(error,'code',None))}));return 2 + + +if __name__=='__main__':raise SystemExit(main()) diff --git a/tests/test_prompt_probe.py b/tests/test_prompt_probe.py new file mode 100644 index 0000000..390355b --- /dev/null +++ b/tests/test_prompt_probe.py @@ -0,0 +1,36 @@ +import copy +import pytest +from viano_bug_analysis.prompt_probe import prepare +from viano_bug_analysis.minimal_prompt_test import measure +from viano_bug_analysis.llm import LLMError +from test_minimal_prompt_test import provider + + +def request():return {'prompt_version':'fixture','messages':[{'role':'system','content':'Test instructions'}, + {'role':'user','content':'Test context'}], 'output_schema':{'type':'object','required':['query'], + 'properties':{'query':{'const':'database.schema'}},'additionalProperties':False}} + + +def test_native_schema_sent_once_without_wrapper_or_tools(): + r=request();before=copy.deepcopy(r);case=prepare(provider(),r) + payload=case['payload'] + assert set(payload)=={'systemInstruction','contents','generationConfig'} + assert payload['contents'][0]['parts'][0]['text']=='Test context' + assert payload['generationConfig']['responseJsonSchema']==r['output_schema'] + assert payload['generationConfig']['thinkingConfig']=={'thinkingLevel':'low'} + assert r==before + + +def test_validates_the_prepared_schema_not_the_minimal_test_schema(): + case=prepare(provider(),request()) + def post(*args):return {'candidates':[{'finishReason':'STOP','content':{'parts':[{'text':'{"query":"database.schema"}'}]}}], + 'usageMetadata':{'promptTokenCount':200,'candidatesTokenCount':8,'thoughtsTokenCount':30}} + result=measure(provider(),case,10000,post) + assert result['status']=='SUCCESS' and result['schema_valid'] + assert result['usage']['input_tokens']==200 + + +@pytest.mark.parametrize('field,value',[('messages',[]),('messages',[{'role':'user','content':'x'}])]) +def test_rejects_invalid_envelopes(field,value): + r=request();r[field]=value + with pytest.raises(LLMError):prepare(provider(),r)