Add offline human reading view for case packages

This commit is contained in:
Nico committed 2026-10-16 23:33:16 +02:00
1 parent 2e91fde1b1
commit 22fe68c976
3 files changed
+290

No files matched your search

+39
View File
@@ -0,0 +1,39 @@
# Lokale Leseansicht einer Fallakte
Das ZIP bleibt das autoritative Transportpaket. Für die menschliche Durchsicht
erzeugt dieses Entwicklungswerkzeug eine temporäre Lesekopie mit einer
HTML-Einstiegsseite:
```powershell
.\.venv\Scripts\python.exe scripts/render_case_review.py <paket.zip> --output-dir <neuer-leseordner>
```
`LESEANSICHT.html` im Browser öffnen. Kein Server, keine Internetverbindung und
keine LLM-Abfrage erforderlich. Das Werkzeug überschreibt keinen bestehenden
Ordner und verändert das Originalpaket nicht.
Die Navigation trennt Fehlerbericht, Einordnung, aktuelle Hypothesen,
Pro-/Contra-/neutrale Befunde, Recherchebelege, ältere Arbeitsstände und
LLM-Aufrufe. Beleg-IDs verlinken die gespeicherten Abfragen und Ergebnisse.
Prompts, Antworten und Originaldateien stehen bei Bedarf eingeklappt bereit.
Intern abgelehnte Antworten werden anhand des Investigation-Zustands markiert,
auch wenn der reine LLM-Transport erfolgreich war.
Es handelt sich um eine Darstellung vorhandener Daten, nicht um eine neue
Diagnose oder eine automatische Übersetzung. Die Modelltexte bleiben in ihrer
Originalsprache. Fehlende finale Diagnosen werden ausdrücklich gekennzeichnet.
Code und untrusted Pakettexte werden als escapeter Text dargestellt. Es werden
weder JavaScript aus dem Paket noch externe Ressourcen eingebunden.
Die Datei enthält die wichtigen Lesebereiche selbst. Die zusätzlichen Links auf
technische Originaldateien benötigen die benachbarte entpackte Ordnerstruktur.
Die Lesekopie ist temporäres Testmaterial und zählt zum 20-GB-Testdatenbudget;
langfristig nur Ergebnis und Testbeschreibung archivieren und die Ansicht bei
Bedarf aus dem Ergebnispaket neu erzeugen.
Validiert: vier Tests zu HTML-Escaping, unverändertem Original/Lesekopien,
Belegnavigation, explizitem Suchumfang und Schutz gegen Überschreiben. Für
Bug 17 g114 wurden zusätzlich alle 605 internen Beleg-/Datei-/Navigationslinks
geprüft. Die visuelle Browserkontrolle war wegen eines nicht verfügbaren
Computer-Use-Runtimes nicht möglich; der Öffnungsauftrag wurde an den
integrierten Browser übergeben.
+194
View File
@@ -0,0 +1,194 @@
"""Render a validated case as a local human reading view, without LLM calls.
The output folder is a temporary inspection copy, not a new case package.
"""
import argparse
from collections import defaultdict
from html import escape
import json
from pathlib import Path
from urllib.parse import quote
from viano_bug_analysis.package import PackageReader
def e(value):
return escape(str(value), quote=True)
def details(title, body):
return '<details><summary>'+e(title)+'</summary>'+body+'</details>'
def raw(value):
return '<pre>'+e(json.dumps(value, ensure_ascii=False, indent=2))+'</pre>'
LABELS = {'text':'Aussage','summary':'Zusammenfassung','description':'Beschreibung',
'title':'Titel','reason':'Begründung','mechanism':'Vermuteter Mechanismus',
'expected':'Erwartung','observed':'Beobachtung','rationale':'Ansatz',
'confidence':'Confidence','scope':'Geltungsbereich','kind':'Art',
'coverage':'Abdeckung','truncated':'Gekürzt','items':'Ergebnisse',
'value':'Wert','name':'Name','resolved':'Erledigt'}
TECHNICAL = {'format','format_version','origin','provenance','source_ref','source_ids',
'source_refs','json_pointer','archive_ref','member_name','case_pointer','sha256',
'content_sha256','input_request_hash','llm_ref','id'}
def readable(value):
"""Render original values; no translation or semantic summarization."""
if value is None:
return '<span class="muted">Nicht angegeben</span>'
if isinstance(value, list):
if not value:
return '<p class="muted">Keine Einträge.</p>'
return '<ul>'+''.join('<li>'+readable(v)+'</li>' for v in value)+'</ul>'
if isinstance(value, dict):
parts = []
for key, item in value.items():
if key in TECHNICAL:
continue
parts.append('<div class="field"><span class="field-label">'+e(LABELS.get(key,key.replace('_',' ')))+'</span>'+readable(item)+'</div>')
return ''.join(parts) or '<p class="muted">Nur technische Metadaten; siehe Originaldaten.</p>'
return '<p>'+e(value)+'</p>'
def refs(ids):
return ' '.join('<a class="chip" href="#'+e(ref)+'">'+e(ref)+'</a>' for ref in ids)
def findings(brief):
result = []
for direction, title in [('support','Dafür'),('against','Dagegen'),('neutral','Neutrale Befunde')]:
items = [f for f in brief.get('findings',[]) if f['direction']==direction]
result.append('<div class="finding '+direction+'"><h4>'+title+'</h4>')
if not items:
result.append('<p class="muted">Kein Befund im aktuellen Arbeitsstand.</p>')
for f in items:
result.append('<p>'+e(f['text'])+'</p><p class="small">Geltungsbereich: '+e(f['scope'])+'</p>'+refs(f['evidence_refs']))
result.append('</div>')
return ''.join(result)
def evidence_body(content):
parts=['<p class="small">Abdeckung: '+e(content.get('coverage','nicht angegeben'))+
' · Geltungsbereich: '+e(content.get('scope','nicht angegeben'))+'</p>']
for capture in content.get('items',[]):
if not isinstance(capture,dict) or 'result' not in capture:
parts.append(readable(capture));continue
query=capture.get('query',{})
parts.append('<h4>Abfrage</h4>'+readable(query))
result=capture['result'];items=result.get('items',[])
parts.append('<h4>Ergebnis</h4>')
if not items:
parts.append('<p>Keine Treffer für diese Abfrage.</p>')
elif isinstance(items,list) and all(isinstance(v,dict) and 'text' in v and 'line' in v for v in items):
code='\n'.join(str(v['line']).rjust(5)+' '+v['text'] for v in items)
parts.append('<pre class="code">'+e(code)+'</pre>')
else:
parts.append(readable(items))
parts.append(details('Abdeckung, Herkunft und technische Details',raw(capture)))
return ''.join(parts)
def render(reader):
manifest=reader.manifest
def load(ref):return json.loads(reader.read_bytes(ref)) if ref else {}
art=manifest['artifacts'];case=load(art.get('case'));interpret=load(art.get('case_interpretation')).get('interpretation',{})
state=load(art.get('investigation_state'));accounts=load(art.get('budget_ledger')).get('state',{}).get('accounts',{})
index=load(art.get('evidence_index'));evidence={}
for item in index.get('entries',[]):
record=load(item['record_ref']);evidence[item['id']]={'record':record,'content':load(record['content_ref'])}
errors={n['llm_ref']:n['code'] for h in state.get('hypotheses',{}).values()
for n in h.get('notes',[]) if n['kind']=='invalid_response'}
titles=[r.get('title') for r in case.get('bug',{}).get('reports',[]) if r.get('title')]
title=titles[0] if titles else manifest['case_id']
confidence={'weakened':'Geschwächt','plausible':'Plausibel','unassessed':'Unbewertet',
'weak':'Schwach','supported':'Gestützt','strongly_supported':'Stark gestützt'}
status={'INVESTIGATING_HYPOTHESES':'Untersuchung läuft','OUTPUT_READY':'Ergebnis bereit',
'BLOCKED':'Blockiert','FAILED_TO_ANALYZE':'Analyse ohne belastbare Diagnose'}
parts=['<header><div class="eyebrow">VIANO · FALLAKTE</div><h1>'+e(manifest['case_id'])+'</h1><p class="subtitle">'+e(title)+'</p>',
'<p><span class="badge">'+e(status.get(manifest['state'],manifest['state']))+'</span> Generation '+str(manifest['package_generation'])+' · Analyse-Revision '+str(manifest['analysis_revision'])+'</p></header>',
'<section id="overview"><h2>Auf einen Blick</h2><div class="stats">',
'<article><strong>'+str(len(state.get('hypotheses',{})))+'</strong> Hypothesen</article>',
'<article><strong>'+str(len(evidence))+'</strong> Recherchebelege</article>',
'<article><strong>'+str(len(errors))+'</strong> verworfene Antworten</article></div>',
'<p class="notice">Dies ist eine Leseansicht der gespeicherten Fallakte. Modellbewertungen sind keine unabhängig bestätigten Fakten. Die Originaltexte bleiben in ihrer gespeicherten Sprache.</p>']
if not art.get('diagnosis'):
parts.append('<p>Eine finale Diagnose und ein Abschlussauftrag liegen in diesem Paket noch nicht vor.</p>')
else:parts.append('<h3>Diagnose</h3>'+readable(load(art['diagnosis'])))
parts.append('</section><section id="case"><h2>Was wurde gemeldet?</h2>'+readable(case.get('bug'))+
'<h3>Menschliche Beobachtungen</h3>'+readable(case.get('human_observations'))+'</section>')
parts.append('<section id="interpretation"><h2>Wie wurde der Fall eingeordnet?</h2>')
for key,label in [('interpretation_summary','Einordnung'),('observed_behavior','Beobachtetes Verhalten'),
('expected_behavior','Erwartetes Verhalten'),('behavior_gap','Abweichung'),('unknowns','Offene Punkte')]:
if key in interpret:parts.append('<h3>'+label+'</h3>'+readable(interpret[key]))
parts.append(details('Vollständige Interpretation',raw(interpret))+'</section><section id="hypotheses"><h2>Hypothesen und Erkenntnisstand</h2>')
for hid,h in state.get('hypotheses',{}).items():
p=h['proposal'];a=accounts.get(hid,{})
parts.append('<article class="hypothesis" id="'+e(hid)+'"><h3>'+e(hid)+' · '+e(p['title'])+'</h3>'+ '<span class="badge">'+e(confidence.get(h['confidence'],h['confidence']))+'</span><span class="small"> Status: '+e(h['disposition'])+' · Restbudget: '+e(a.get('remaining','?'))+'</span><h4>Vermuteter Mechanismus</h4><p>'+e(p['mechanism'])+'</p>')
doc=load(h.get('working_brief_ref'));b=doc.get('brief')
if b:
parts.append(findings(b)+'<h4>Unsicherheiten</h4>'+readable(b['uncertainties'])+'<h4>Nächste Prüffrage</h4><p>'+e(b['next_question'])+'</p>')
else:parts.append('<p class="muted">Noch kein strukturierter Arbeitsstand gespeichert.</p>')
parts.append('<h4>Offene Fragen</h4>'+readable([q for q in h['questions'] if not q['resolved']]))
parts.append(details('Ansatz und vollständiger Hypothesenzustand',raw(h))+'</article>')
parts.append('</section><section id="evidence"><h2>Was wurde tatsächlich recherchiert?</h2><p>Beleg-Links aus den Hypothesen führen hierher. Jeder Beleg zeigt seine eigene Abfrage und Reichweite.</p>')
for eid,entry in evidence.items():
content=entry['content'];captures=content.get('items',[])
queries=[v.get('query',{}) for v in captures if isinstance(v,dict)]
kinds=', '.join(dict.fromkeys(q.get('kind','') for q in queries))
parts.append('<article id="'+e(eid)+'">'+details(eid+' · '+entry['record'].get('hypothesis_id','')+' · '+kinds,evidence_body(content))+'</article>')
parts.append('</section><section id="history"><h2>Wie hat sich die Bewertung verändert?</h2><p>Ältere Arbeitsstände bleiben zur Nachvollziehbarkeit aufklappbar.</p>')
for name in reader.files:
if '/briefs/' in name and name.startswith('analysis/investigation/'):
b=load(name)
parts.append(details(b['hypothesis_id']+' · Arbeitsstand '+str(b['sequence']),findings(b['brief'])+'<h4>Begründete Änderungen</h4>'+readable(b['brief']['changes'])))
parts.append('</section><section id="calls"><h2>LLM-Aufrufe und Ablehnungen</h2><p>Die gespeicherten Prompts sind vollständig verfügbar, aber zunächst eingeklappt. Sie entsprechen dem kanonischen Gateway-Input; ein API-Cache kann die Übertragung aufteilen.</p>')
for name in reader.files:
if not (name.startswith('llm/operations/') and '/round-' in name and name.endswith('.json')):continue
call=load(name);req=load(call['request_ref']);meta=call.get('outcome',{}).get('metadata',{})
error=errors.get(name) or call.get('code')
heading=call.get('operation','')+' · '+('Abgelehnt: '+error if error else 'Antwort angenommen')
body='<p class="small">'+e(req.get('prompt_version',''))+' · Input: '+e(meta.get('input_tokens','?'))+' · Output inkl. Thinking: '+e(meta.get('output_tokens','?'))+' · Cache: '+e(meta.get('cached_input_tokens','?'))+'</p>'
if error:body+='<p class="notice">'+e(error)+'</p>'
for message in req.get('messages',[]):body+=details('Prompt: '+message['role'],'<pre>'+e(message['content'])+'</pre>')
body+=details('Gespeicherte Antwort und Metadaten',raw(call.get('outcome',{})))
parts.append(details(heading,body))
parts.append('</section><section id="files"><h2>Technische Originaldateien</h2><p>Nur bei Bedarf: Die Verknüpfungen öffnen die byteidentischen Lesekopien. Die Ordnerstruktur ist keine notwendige Leseroute mehr.</p>')
groups=defaultdict(list)
for name in reader.files:groups[name.split('/')[0]].append(name)
for group,names in sorted(groups.items()):
body='<ul>'+''.join('<li><a href="'+quote(n,safe='/')+'">'+e(n)+'</a></li>' for n in names)+'</ul>'
parts.append(details(group+' · '+str(len(names))+' Dateien',body))
parts.append('</section>')
nav=''.join('<a href="#'+anchor+'">'+label+'</a>' for anchor,label in [('overview','Überblick'),('case','Fehlerbericht'),('interpretation','Einordnung'),('hypotheses','Hypothesen'),('evidence','Recherchebelege'),('history','Verlauf'),('calls','LLM-Aufrufe'),('files','Originaldateien')])
css='''*{box-sizing:border-box}html{scroll-behavior:smooth;scroll-padding-top:24px}body{margin:0;background:#f3f5f7;color:#202d3d;font:16px/1.65 system-ui,Segoe UI,sans-serif}aside{position:fixed;width:230px;inset:0 auto 0 0;padding:36px 24px;background:#172b3c;color:white}aside b{display:block;margin-bottom:30px}aside a{display:block;color:#d7e6ed;text-decoration:none;padding:10px 12px;border-radius:6px}aside a:hover{background:#294555}main{max-width:1200px;margin-left:230px;padding:42px 48px 80px}header{padding:0 10px 26px}.eyebrow{letter-spacing:.16em;color:#537581;font-size:12px;font-weight:700}h1{font-size:40px;line-height:1.15;margin:14px 0}.subtitle{font-size:22px;color:#536574}h2{font-size:25px;margin:0 0 20px}h3{font-size:20px;line-height:1.4;margin:20px 0 12px}h4{font-size:15px;margin:16px 0 6px}p{margin:8px 0 12px;white-space:pre-wrap;overflow-wrap:anywhere}section{background:white;border:1px solid #e0e5ea;border-radius:12px;padding:28px;margin-bottom:24px}.stats{display:flex;gap:12px;margin-bottom:20px}.stats article{background:#eef4f6;border-radius:8px;padding:16px;flex:1}.stats strong{display:block;font-size:30px;line-height:1.2}.badge{display:inline-block;border-radius:20px;background:#e4f1ed;color:#1f6552;padding:4px 12px;font-size:13px;font-weight:650;margin-right:12px}.small,.muted{font-size:13px;color:#607181}.notice{background:#fff6df;border-left:3px solid #d6a449;padding:12px 16px;font-size:14px}.hypothesis{padding:8px 0 28px;border-bottom:1px solid #e2e8ef}.finding{padding:8px 16px;margin:12px 0;border-left:3px solid #a4b6c5;background:#f5f8fa}.finding.against{border-color:#c67d64;background:#fff8f4}.finding.support{border-color:#5e9c87;background:#f1f8f5}.chip{display:inline-block;padding:2px 8px;background:#e6edf4;border-radius:4px;text-decoration:none;color:#305975;font-size:12px;margin:2px}details{margin:12px 0;border:1px solid #e0e6eb;border-radius:7px;padding:12px 16px}summary{cursor:pointer;font-weight:600;overflow-wrap:anywhere}details[open]>summary{margin-bottom:16px}pre{white-space:pre-wrap;overflow-wrap:anywhere;background:#f0f3f6;padding:16px;font:12px/1.6 Consolas,monospace;max-height:650px;overflow:auto}.code{white-space:pre;overflow:auto}.field{margin:12px 0}.field-label{display:block;font-weight:650;color:#4b6172;font-size:13px}li{margin:6px 0}a{color:#246b91}article:target{outline:3px solid #78abc5;outline-offset:5px}@media(max-width:850px){aside{position:static;width:auto;padding:16px}aside b{margin-bottom:10px}aside a{display:inline-block}main{margin:0;padding:24px 16px}.stats{flex-wrap:wrap}section{padding:20px}}@media print{aside{display:none}main{margin:0;padding:0}details{break-inside:avoid}}'''
return '<!doctype html><html lang="de"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>'+e(manifest['case_id'])+' · Fallakte</title><style>'+css+'</style><aside><b>VianoBugAnalysis<br>Fallakte lesen</b>'+nav+'</aside><main>'+''.join(parts)+'</main></html>'
def create(package, destination):
destination=Path(destination).resolve()
if destination.exists():
raise ValueError('Use a new inspection directory; existing files are never overwritten')
with PackageReader(package) as reader:
if 'LESEANSICHT.html' in reader.files:
raise ValueError('Package already contains reserved view filename')
html=render(reader)
destination.mkdir(parents=True)
for name in reader.files:
target=(destination/name).resolve()
if not target.is_relative_to(destination):raise ValueError('Unsafe destination')
target.parent.mkdir(parents=True,exist_ok=True)
target.write_bytes(reader.read_bytes(name))
output=destination/'LESEANSICHT.html'
output.write_text(html,encoding='utf8')
return output
if __name__=='__main__':
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('package',type=Path)
parser.add_argument('--output-dir',type=Path,required=True)
args=parser.parse_args()
print(create(args.package,args.output_dir))
+57
View File
@@ -0,0 +1,57 @@
import importlib.util
import json
from pathlib import Path
import pytest
from viano_bug_analysis.package import PackageReader
spec=importlib.util.spec_from_file_location('case_review',Path(__file__).parents[1]/'scripts'/'render_case_review.py')
review=importlib.util.module_from_spec(spec)
spec.loader.exec_module(review)
def test_local_review_escapes_untrusted_text_and_preserves_all_files(make_package,manifest,tmp_path):
manifest['artifacts']['case']='case/case.json'
data={'bug':{'reports':[{'title':'<script>alert(1)</script>',
'summary':'Tester observation, not a diagnosis.'}]}}
package=make_package(document=manifest,entries={'case/case.json':json.dumps(data),
'unknown/preserve.txt':'untouched'})
original=package.read_bytes()
output=review.create(package,tmp_path/'view')
html=output.read_text(encoding='utf8')
assert '<script>alert(1)</script>' not in html
assert '&lt;script&gt;alert(1)&lt;/script&gt;' in html
assert 'Tester observation, not a diagnosis.' in html
assert 'noch nicht vor' in html # Do not imply completion.
assert package.read_bytes()==original
with PackageReader(package) as reader:
for name in reader.files:assert (output.parent/name).read_bytes()==reader.read_bytes(name)
with pytest.raises(ValueError,match='never overwritten'):
review.create(package,output.parent)
def test_counterevidence_links_and_code_are_readable_without_executing_markup():
b={'findings':[{'id':'B0001','direction':'against','text':'Hook exists.',
'scope':'Pinned source only.','evidence_refs':['E000001']}]}
html=review.findings(b)
assert 'Dagegen' in html and 'href="#E000001"' in html
content={'scope':'source','coverage':'selected_lines','items':[
{'query':{'kind':'source.read'},'result':{'items':[{'line':5,'text':'<img src=x onerror=alert(1)>'}]}}]}
rendered=review.evidence_body(content)
assert '&lt;img' in rendered and '<img' not in rendered
assert '5 &lt;img' in rendered
def test_empty_query_is_explicitly_scoped_not_general_absence():
html=review.evidence_body({'scope':'entry 5','coverage':'complete_filtered_rows',
'items':[{'query':{'entry':5},'result':{'items':[]}}]})
assert 'entry 5' in html and 'Keine Treffer für diese Abfrage.' in html
def test_reserved_filename_never_overwrites_package_payload(make_package,tmp_path):
p=make_package(entries={'LESEANSICHT.html':'original'})
dest=tmp_path/'review'
with pytest.raises(ValueError,match='reserved'):
review.create(p,dest)
assert not dest.exists()