From e8d0602ed31ea3811cc1785bcb5325423898cc1d Mon Sep 17 00:00:00 2001 From: Nitsu45 Date: Sat, 17 Oct 2026 13:01:03 +0200 Subject: [PATCH] Allow isolated prompt probes with JSON mode and local schema validation --- docs/compact-investigation-probe.md | 74 ++++++++++++------- src/viano_bug_analysis/minimal_prompt_test.py | 4 +- src/viano_bug_analysis/prompt_probe.py | 22 ++++-- tests/test_prompt_probe.py | 16 ++++ 4 files changed, 78 insertions(+), 38 deletions(-) diff --git a/docs/compact-investigation-probe.md b/docs/compact-investigation-probe.md index fe744f3..2d8868b 100644 --- a/docs/compact-investigation-probe.md +++ b/docs/compact-investigation-probe.md @@ -4,45 +4,45 @@ .\.venv\Scripts\python.exe -m viano_bug_analysis.prompt_probe --run ``` -Ohne --run erfolgt nur eine lokale Prüfung. Standardinput ist +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 +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. +expliziter Cache und keinerlei Ausführung der vorgeschlagenen Recherche. Das vorhandene Gateway-Konfigurationsprofil bestimmt Modell, Denkstufe, -Ausgabelimit und Timeout. Produktionsprompts bleiben unverändert. +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. +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. +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. +(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. +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. +Fallakte. Ein Einzeltest untersucht Schrittwahl und Kontextgröße, nicht die +Qualität oder Gesamtkosten einer vollständigen Untersuchung. ## Korrektur nach HTTP 400 -Der erste kompakte Aufruf wurde mit HTTP 400 abgelehnt. Kein Tokenzähler wurde -zurückgegeben. Ein genauer Anbieterfehlertext wurde vom Transport nicht erhalten; -die exakte serverseitige Ursache bleibt daher unbestätigt. Im Schema fanden sich -const-only Diskriminatoren ohne type. Diese sind für den nativen Gemini-Subset -ungeeignet. Die Messhilfe übersetzt string-const in type:string plus enum, -oneOf in anyOf und primitive Typvereinigungen in anyOf. Nicht unterstützte -Längen-/Pattern-/Unique-Regeln werden ausschließlich lokal geprüft. Nicht +Der erste kompakte Aufruf wurde mit HTTP 400 abgelehnt. Kein Tokenzähler wurde +zurückgegeben. Ein genauer Anbieterfehlertext wurde vom Transport nicht erhalten; +die exakte serverseitige Ursache bleibt daher unbestätigt. Im Schema fanden sich +const-only Diskriminatoren ohne type. Diese sind für den nativen Gemini-Subset +ungeeignet. Die Messhilfe übersetzt string-const in type:string plus enum, +oneOf in anyOf und primitive Typvereinigungen in anyOf. Nicht unterstützte +Längen-/Pattern-/Unique-Regeln werden ausschließlich lokal geprüft. Nicht abgebildete komplexe Referenz-/Bedingungsschemata werden lokal abgelehnt. -Originalprompt und vollständiger lokaler Validierungsvertrag bleiben gleich. +Originalprompt und vollständiger lokaler Validierungsvertrag bleiben gleich. Gemini-Referenz: https://ai.google.dev/api/generate-content#v1beta.GenerationConfig Der bestehende fehlgeschlagene result.json bleibt erhalten. Neuer Versuch: @@ -52,21 +52,21 @@ Der bestehende fehlgeschlagene result.json bleibt erhalten. Neuer Versuch: ``` Ausgangskostenstand laut Nutzer jetzt 8,30 EUR. Der vorherige Anstieg um 0,01 EUR -ist als nachträglich sichtbare Kontodifferenz dokumentiert, nicht als exakter -Einzelaufruf-Abrechnungsbeleg. Die Korrektur wurde nur offline geprüft. +ist als nachträglich sichtbare Kontodifferenz dokumentiert, nicht als exakter +Einzelaufruf-Abrechnungsbeleg. Die Korrektur wurde nur offline geprüft. ## Anbieterdiagnose nach erneutem HTTP 400 Auch der erste Schemafix wurde von der API mit 400 abgewiesen. Die Ursache -ist weiterhin offen. Der Probe-Aufruf liest nun bei HTTP-Fehlern opt-in höchstens +ist weiterhin offen. Der Probe-Aufruf liest nun bei HTTP-Fehlern opt-in höchstens 8 KB des Anbieterfehlers und gibt nur begrenzte Status-/Nachrichten- und -Feldhinweise aus. API-Schlüssel, URLs und zitierte Werte werden bereinigt; -Rohkörper werden nicht gespeichert. Der normale Gemini-Produktionspfad behält +Feldhinweise aus. API-Schlüssel, URLs und zitierte Werte werden bereinigt; +Rohkörper werden nicht gespeichert. Der normale Gemini-Produktionspfad behält seine bisherigen kontrollierten Fehlercodes und verwirft weiterhin Rohfehler. Eine bereits vorhandene Ergebnisdatei erzeugt PROMPT_PROBE_OUTPUT_EXISTS und -keinen Netzaufruf. Auch ein fehlgeschlagener API-Versuch wird nicht überschrieben. -Der nächste bewusst einzelne Diagnoseversuch lautet: +keinen Netzaufruf. Auch ein fehlgeschlagener API-Versuch wird nicht überschrieben. +Der nächste bewusst einzelne Diagnoseversuch lautet: ```powershell .\.venv\Scripts\python.exe -m viano_bug_analysis.prompt_probe --run --output service-data/benchmarks/compact-investigation-20261017/result-diagnostic-1.json @@ -74,3 +74,21 @@ Der nächste bewusst einzelne Diagnoseversuch lautet: Dies ist keine weitere vermutete Schemakorrektur. Die konkrete Fehlermeldung muss erst vorliegen, bevor der eigentliche API-Vertrag weiter angepasst wird. + +## Isolierung ohne natives Antwortschema + +Google meldete auch mit Diagnosehilfe lediglich INVALID_ARGUMENT ohne Feldhinweis. +Die genaue Ursache ist damit nicht belegt. Ein neuer expliziter Modus umgeht die +native Schemaübergabe: --schema-mode prompt setzt nur responseMimeType auf +application/json und fügt den vollständigen lokalen Vertrag genau einmal in den +Systemprompt ein. Alle Antwortregeln werden weiterhin lokal geprüft. Fallkontext, +Aufgabe und Modelleinstellungen bleiben erhalten; der API-Input beträgt in diesem +Modus 10.925 Nachrichtenzeichen einschließlich Vertrag und Einleitung. + +```powershell +.\.venv\Scripts\python.exe -m viano_bug_analysis.prompt_probe --schema-mode prompt --run --output service-data/benchmarks/compact-investigation-20261017/result-jsonmode-1.json +``` + +Dies ist ein isolierender Versuch, keine bestätigte Behebung der HTTP-Ursache. +Keine automatische Wiederholung und kein automatischer Format-Fallback. Die +Metadaten kennzeichnen den Transportmodus, damit Messungen vergleichbar bleiben. diff --git a/src/viano_bug_analysis/minimal_prompt_test.py b/src/viano_bug_analysis/minimal_prompt_test.py index 1f3a7a0..8893413 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 'responseJsonSchema' in case['payload']['generationConfig'] and result['status']=='SUCCESS': - try: valid=Draft202012Validator(case.get('validation_schema',case['payload']['generationConfig']['responseJsonSchema'])).is_valid(json.loads(text)) + if ('validation_schema' in case or 'responseJsonSchema' in case['payload']['generationConfig']) and result['status']=='SUCCESS': + try: valid=Draft202012Validator(case['validation_schema'] if 'validation_schema' in case else 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 index e91830a..596772c 100644 --- a/src/viano_bug_analysis/prompt_probe.py +++ b/src/viano_bug_analysis/prompt_probe.py @@ -46,7 +46,7 @@ def gemini_schema(schema): return result -def prepare(provider,request): +def prepare(provider,request, *, schema_mode="native"): 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') @@ -55,12 +55,17 @@ def prepare(provider,request): 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':gemini_schema(request['output_schema'])} + if schema_mode not in {'native','prompt'}:raise LLMError('PROMPT_PROBE_SCHEMA_MODE') + generation={'maxOutputTokens':provider.max_output_tokens,'responseMimeType':'application/json'} + system_text=messages[0]['content'] + if schema_mode=='native': + generation['responseJsonSchema']=gemini_schema(request['output_schema']) + else: + system_text += '\nOutput JSON contract (validated locally):\n'+json.dumps(request['output_schema'],ensure_ascii=False,separators=(',',':')) if provider.reasoning_effort is not None:generation['thinkingConfig']={'thinkingLevel':provider.reasoning_effort} - payload={'systemInstruction':{'parts':[{'text':messages[0]['content']}]}, + payload={'systemInstruction':{'parts':[{'text':system_text}]}, '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), + return {'id':request['prompt_version'],'prompt_characters':len(system_text)+len(messages[1]['content']), 'payload':payload,'validation_schema':request['output_schema']} @@ -109,6 +114,7 @@ def main(argv=None): 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('--schema-mode',choices=['native','prompt'],default='native',help='Native schema or JSON mode with the full contract once in the prompt') 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) @@ -119,13 +125,13 @@ def main(argv=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) + request=json.loads(raw);case=prepare(provider,request,schema_mode=args.schema_mode) 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, - 'wire_schema_sha256':hashlib.sha256(json.dumps(case['payload']['generationConfig']['responseJsonSchema'],sort_keys=True,separators=(',',':')).encode()).hexdigest(), - 'schema_transport':'gemini_subset_v1_local_full_validation'} + 'wire_schema_sha256':hashlib.sha256(json.dumps(case['payload']['generationConfig'].get('responseJsonSchema'),sort_keys=True,separators=(',',':')).encode()).hexdigest(), + 'schema_transport':'gemini_subset_v1_local_full_validation' if args.schema_mode=='native' else 'json_mode_schema_once_in_prompt_local_full_validation'} if not args.run: print(json.dumps({'status':'PREPARED','network_used':False,**metadata, 'message_characters':case['prompt_characters']}));return 0 diff --git a/tests/test_prompt_probe.py b/tests/test_prompt_probe.py index cfcc135..410003b 100644 --- a/tests/test_prompt_probe.py +++ b/tests/test_prompt_probe.py @@ -105,3 +105,19 @@ def test_existing_output_is_named_error_without_network(tmp_path,monkeypatch,cap result=prompt_probe.main(['--run','--request',str(path),'--output',str(output)]) assert result==2 and 'PROMPT_PROBE_OUTPUT_EXISTS' in capsys.readouterr().out assert output.read_text()=='keep original' + + +def test_prompt_schema_mode_avoids_native_union_and_keeps_contract_once(): + import json + r=request();case=prepare(provider(),r,schema_mode='prompt') + payload=case['payload'] + assert 'responseJsonSchema' not in payload['generationConfig'] + assert payload['generationConfig']['responseMimeType']=='application/json' + contract=json.dumps(r['output_schema'],ensure_ascii=False,separators=(',',':')) + assert payload['systemInstruction']['parts'][0]['text'].count(contract)==1 + assert payload['contents'][0]['parts'][0]['text']==r['messages'][1]['content'] + assert case['validation_schema']==r['output_schema'] + def post(*args):return {'candidates':[{'finishReason':'STOP','content':{'parts':[{'text':'{"query":"database.schema"}'}]}}]} + assert measure(provider(),case,10000,post)['status']=='SUCCESS' + def bad(*args):return {'candidates':[{'finishReason':'STOP','content':{'parts':[{'text':'{"query":"invented"}'}]}}]} + assert measure(provider(),case,10000,bad)['status']=='INVALID_JSON'