From 2eaba70083e70f0f7c0f0985ad5b6be8482dfa06 Mon Sep 17 00:00:00 2001 From: Nitsu45 Date: Sat, 17 Oct 2026 12:55:34 +0200 Subject: [PATCH] Adapt prompt probe schema to Gemini subset with full local validation --- docs/compact-investigation-probe.md | 49 ++++++++++++++----- src/viano_bug_analysis/minimal_prompt_test.py | 2 +- src/viano_bug_analysis/prompt_probe.py | 39 +++++++++++++-- tests/test_prompt_probe.py | 27 +++++++++- 4 files changed, 99 insertions(+), 18 deletions(-) diff --git a/docs/compact-investigation-probe.md b/docs/compact-investigation-probe.md index 3495df2..dd88134 100644 --- a/docs/compact-investigation-probe.md +++ b/docs/compact-investigation-probe.md @@ -4,30 +4,53 @@ .\.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 +abgebildete komplexe Referenz-/Bedingungsschemata werden lokal abgelehnt. +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: + +```powershell +.\.venv\Scripts\python.exe -m viano_bug_analysis.prompt_probe --run --output service-data/benchmarks/compact-investigation-20261017/result-schemafix-1.json +``` + +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. diff --git a/src/viano_bug_analysis/minimal_prompt_test.py b/src/viano_bug_analysis/minimal_prompt_test.py index 8dd1306..1f3a7a0 100644 --- a/src/viano_bug_analysis/minimal_prompt_test.py +++ b/src/viano_bug_analysis/minimal_prompt_test.py @@ -58,7 +58,7 @@ def measure(provider,case,limit,post=None): 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['payload']['generationConfig']['responseJsonSchema']).is_valid(json.loads(text)) + try: valid=Draft202012Validator(case.get('validation_schema',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 e452753..cd0067a 100644 --- a/src/viano_bug_analysis/prompt_probe.py +++ b/src/viano_bug_analysis/prompt_probe.py @@ -12,6 +12,37 @@ from .telemetry.safety import safe_code DEFAULT = Path('service-data/benchmarks/compact-investigation-20261017/request.json') +def gemini_schema(schema): + """Provider projection only; full original schema remains the local validator. + + Gemini's responseJsonSchema subset omits const and several validation + keywords. In particular, a const-only kind has no usable type after const + is ignored. Translate it rather than handing the internal contract through. + """ + allowed = {'type','format','title','description','enum','items','prefixItems', + 'minItems','maxItems','minimum','maximum','anyOf','oneOf', + 'properties','additionalProperties','required'} + if not isinstance(schema,dict): + raise LLMError('PROMPT_PROBE_SCHEMA_UNSUPPORTED') + if any(k in schema for k in ('$ref','$defs','allOf','not','if','then','else')): + raise LLMError('PROMPT_PROBE_SCHEMA_UNSUPPORTED') + result = {} + for key,value in schema.items(): + if key not in allowed: continue + if key == 'properties': result[key] = {k:gemini_schema(v) for k,v in value.items()} + elif key in {'items','additionalProperties'} and isinstance(value,dict): result[key] = gemini_schema(value) + elif key in {'anyOf','oneOf','prefixItems'}: result['anyOf' if key=='oneOf' else key] = [gemini_schema(v) for v in value] + else: result[key] = value + if 'const' in schema: + value=schema['const'] + if not isinstance(value,str): raise LLMError('PROMPT_PROBE_SCHEMA_UNSUPPORTED') + result['type']='string';result['enum']=[value] + if isinstance(result.get('type'),list): + types=result.pop('type') + result['anyOf']=[{'type':value} for value in types] + return result + + 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()): @@ -22,12 +53,12 @@ def prepare(provider,request): raise LLMError('PROMPT_PROBE_REQUEST') Draft202012Validator.check_schema(request['output_schema']) generation={'maxOutputTokens':provider.max_output_tokens,'responseMimeType':'application/json', - 'responseJsonSchema':request['output_schema']} + 'responseJsonSchema':gemini_schema(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} + 'payload':payload,'validation_schema':request['output_schema']} def main(argv=None): @@ -49,7 +80,9 @@ def main(argv=None): 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} + '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'} 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 390355b..0bbd160 100644 --- a/tests/test_prompt_probe.py +++ b/tests/test_prompt_probe.py @@ -16,7 +16,8 @@ def test_native_schema_sent_once_without_wrapper_or_tools(): 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']['responseJsonSchema']['properties']['query']=={'type':'string','enum':['database.schema']} + assert case['validation_schema']==r['output_schema'] assert payload['generationConfig']['thinkingConfig']=={'thinkingLevel':'low'} assert r==before @@ -34,3 +35,27 @@ def test_validates_the_prepared_schema_not_the_minimal_test_schema(): def test_rejects_invalid_envelopes(field,value): r=request();r[field]=value with pytest.raises(LLMError):prepare(provider(),r) + + +def test_gemini_projection_types_const_and_keeps_strict_local_validation(): + from viano_bug_analysis.prompt_probe import gemini_schema + from jsonschema import Draft202012Validator + r=request() + r['output_schema']['properties']['query']={'const':'database.schema','minLength':5,'pattern':'^database'} + original=copy.deepcopy(r) + c=prepare(provider(),r) + wire=c['payload']['generationConfig']['responseJsonSchema'] + Draft202012Validator.check_schema(wire) + assert wire['properties']['query']=={'type':'string','enum':['database.schema']} + assert r==original and c['validation_schema']==r['output_schema'] + assert gemini_schema({'type':['string','number','boolean','null']})=={ + 'anyOf':[{'type':v} for v in ['string','number','boolean','null']]} + + +def test_local_constraints_not_silently_dropped_from_response_check(): + r=request();r['output_schema']['properties']['query']={'type':'string','pattern':'^database'} + c=prepare(provider(),r) + def post(*args):return {'candidates':[{'finishReason':'STOP','content':{'parts':[{'text':'{"query":"invented"}'}]}}], + 'usageMetadata':{'promptTokenCount':200}} + result=measure(provider(),c,10000,post) + assert result['status']=='INVALID_JSON' and result['schema_valid'] is False