Add readable case details and completed results browser

This commit is contained in:
Nico committed 2026-10-21 23:05:13 +02:00
1 parent cbb69a2115
commit c478518425
12 files changed
+542 -12

No files matched your search

@@ -0,0 +1,29 @@
# Falldetailansicht und Ergebnisliste – 2026-10-21
Ziel: Erhaltene Fallakten direkt im Browser lesen, ohne ZIP-Verzeichnisse manuell
zu durchsuchen. Keine Änderung an Analyseprompts, Budgets oder Architekturvorgaben.
Implementierung: projektgebundene Leseendpunkte, Ergebnisliste mit Suche und
Projektfilter, Revisionsauswahl, Beobachtungen, Interpretation, Hypothesen mit
Confidence und Befunden, Diagnose, Coding-/Human-Dev-Aufträge, API-Patchfeedback,
Sperrhinweise und vollständiger aktueller ZIP-Download.
Prüfungen:
- `pytest tests/test_dashboard.py -q`: 18 bestanden. Authentifizierung, Projektscope,
vollständiger ZIP-Download, ungültige Revisionen und wiedereröffnete Fallakte:
alte Interpretation bleibt in Revision 1, erscheint nicht in Revision 2.
- Zusammen mit `tests/test_worker.py`: 36 Tests bestanden.
- `node tests/dashboard-smoke.mjs`: PASS im echten unsichtbaren Edge-Browser,
Desktop und 390-Pixel-Mobilansicht, keine horizontalen Überläufe oder JS-Fehler.
Ergebnisnavigation, Coding-/Human-Aufträge und Datenentfernung bei Abmeldung.
- Browserdaten sind rein synthetisch; ihre 35.300 Tokens sind Anzeigeprüfwerte,
keine realen Modellmessungen. Keine kostenpflichtigen LLM-/DB-Anfragen.
- Vor Testbeginn: Test-Workspaces 16.939.129.428 Bytes, zusätzlich kleines
Fehlerkorpus und temporäre pytest-Daten deutlich unter 20 GB insgesamt.
Browser-/Server-Testverzeichnisse werden nach geprüften Pfaden entfernt.
Grenzen: Sperrhinweise sind Information, keine administrative Freigabeoberfläche.
Das Belegverzeichnis ersetzt nicht die vollständigen Recherchepayloads im ZIP.
Große Darstellungen werden gekennzeichnet gekürzt; Paketinhalt bleibt unverändert.
Die Revisionshistorie benötigt keine aufbewahrten älteren ZIP-Generationen.
+32 -3
View File
@@ -70,8 +70,35 @@ Die Oberfläche bietet ein einfaches Formular für neue wowdiag-/wowsrc-Aufträg
für abgeschlossene ZIP-Akten mit Testerfeedback. Es verwendet dieselbe Multipart-
API und denselben Idempotenzmechanismus wie externe Programme. Bei unveränderten
Eingaben verwendet eine Wiederholung denselben Schlüssel. Anhänge und Fallakten
werden nicht clientseitig bearbeitet. Vollständige Falldetailansichten und eine
Bedienoberfläche für administrative Worker-Eingriffe bleiben weitere Ausbauschritte.
werden nicht clientseitig bearbeitet. Administrative Worker-Eingriffe bleiben ein
weiterer Ausbauschritt.
## Fallakte und Ergebnisse
Fallnamen verlinken auf eine lesbare Detailansicht mit Testerbeobachtungen,
Interpretation, Hypothesen, Confidence, stützenden und widersprechenden Befunden,
Diagnose sowie Coding- und Human-Dev-Auftrag. Befunde bleiben ausdrücklich
KI-Bewertungen mit Belegreferenzen. Das Belegverzeichnis zeigt Herkunft und
Abdeckungsinformationen; die vollständigen Recherchepayloads stehen im ZIP.
Ein expliziter Auftrag „Kein Auftrag“ wird von einem noch nicht erstellten Dokument
unterschieden. Die separate Ergebnisliste enthält abgeschlossene und zur
menschlichen Prüfung ausgeleitete Fälle, mit Projektfilter und Suche.
Die Revisionsauswahl liest akzeptierte Artefakte aus der Checkpointhistorie der
aktuellen vollständigen Akte. Ergebnisse neuerer Revisionen erscheinen nicht in
älteren Ansichten. Über die Übergabe-API erfasste Patch-Rückmeldungen werden bis
zur gewählten Revision dargestellt. Bei gesperrten Fällen erscheinen Sperrgrund
und passende Betreiberhinweise; die Ansicht setzt keine Budgets zurück und hebt
keine Sperren auf.
`GET /v1/cases/{key}?revision=N` liefert diese projektgebundene Ansicht.
`GET /v1/cases/{key}/package` lädt die vollständige aktuelle Akte einschließlich
aller erhaltenen Revisionen herunter, auch als Zwischenstand eines laufenden Falls.
Beide Endpunkte benötigen denselben Bearer-Token und verwenden `no-store`.
Der Schlüssel stammt aus der Übersicht, nicht aus einem lokalen Dateipfad.
Große Anzeigeinhalte werden begrenzt und als gekürzt gekennzeichnet. Der Download
bleibt vollständig; die Anzeige verändert keine Paketgeneration und löst keine
LLM-Aufrufe aus.
## Datenschutz und Betrieb
@@ -97,7 +124,9 @@ node tests/dashboard-smoke.mjs
Der Browser-Smoke-Test verwendet einen lokalen temporären API-Server, einen
unsichtbaren Edge-Browser und ausdrücklich künstliche Fall-/Verbrauchsdaten. Er
prüft Desktop, Mobilansicht, Projektfilter, Formularumschaltung, Abmeldung,
Tokenanzeige, Fortschritt und Browserfehler. Unter Windows wird der installierte
Tokenanzeige, Fortschritt, Ergebnisliste, Falldetails, Aufträge und Browserfehler.
Die API-Tests prüfen zusätzlich Projektzugriff, ZIP-Download und Revisionsisolation.
Unter Windows wird der installierte
Edge erwartet; über `BROWSER_PATH` lässt sich ein anderes Chromium angeben.
Die Testserver/-browser werden beendet, ihre erzeugten temporären Verzeichnisse
nach Pfadprüfung entfernt. Screenshots als Prüfergebnis liegen unter
+32 -2
View File
@@ -18,12 +18,16 @@ from .config import APIError
from .contracts import schema
from .service import IntakeService
from .dashboard import Dashboard
from .cases import Cases
from viano_bug_analysis.lifecycle.errors import LifecycleError
from viano_bug_analysis.package import PackageReadError
from importlib.resources import files
def create_app(config):
service = IntakeService(config)
dashboard = Dashboard(service)
cases = Cases(service)
tokens = config.tokens()
service.recover()
active = 0
@@ -55,14 +59,39 @@ def create_app(config):
response.headers['Cache-Control'] = 'no-store'
return response
async def case_detail(request):
client = authenticate(request)
revision = request.query_params.get('revision')
if revision is not None:
if not revision.isascii() or not revision.isdigit() or len(revision) > 7:
raise APIError('API_REVISION_INVALID', 422)
revision = int(revision)
try:
detail = await run_in_threadpool(cases.detail, request.path_params['handle'], client, revision)
except (LifecycleError, PackageReadError, ValueError, TypeError, KeyError):
raise APIError('API_CASE_UNAVAILABLE', 409) from None
response = JSONResponse(detail)
response.headers['Cache-Control'] = 'no-store'
return response
async def case_package(request):
client = authenticate(request)
try:
path = await run_in_threadpool(cases.head, request.path_params['handle'], client)
except (LifecycleError, PackageReadError, ValueError, TypeError, KeyError):
raise APIError('API_CASE_UNAVAILABLE', 409) from None
response = FileResponse(path, media_type='application/zip', filename='case.zip')
response.headers['Cache-Control'] = 'no-store'
return response
async def web(request):
from starlette.responses import Response
name = request.path_params.get('asset', 'index.html')
if name not in {'index.html', 'dashboard.js', 'dashboard.css'}:
if name not in {'index.html', 'dashboard.js', 'dashboard.css', 'case-detail.js'}:
raise APIError('API_NOT_FOUND', 404)
data = files('viano_bug_analysis.web').joinpath(name).read_bytes()
response = Response(data, media_type={'index.html': 'text/html', 'dashboard.js': 'text/javascript',
'dashboard.css': 'text/css'}[name])
'dashboard.css': 'text/css', 'case-detail.js':'text/javascript'}[name])
response.headers.update({'Content-Security-Policy': "default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'",
'X-Content-Type-Options': 'nosniff', 'Referrer-Policy': 'no-referrer', 'Cache-Control': 'no-store'})
return response
@@ -136,6 +165,7 @@ def create_app(config):
app = Starlette(routes=[Route('/', web), Route('/assets/{asset}', web),
Route('/v1/overview', overview), Route('/healthz', health), Route('/v1/profiles', profiles),
Route('/v1/cases/{handle}', case_detail), Route('/v1/cases/{handle}/package', case_package),
Route('/v1/schema', contract), Route('/v1/submissions', submit, methods=['POST']),
Route('/v1/submissions/{submission}', status), Route('/v1/submissions/{submission}/package', package)],
exception_handlers={APIError: error, HTTPException: http_error, OSError: storage_error})
+228
View File
@@ -0,0 +1,228 @@
"""Read-only, project-scoped semantic views of complete portable case history."""
import re
import json
from viano_bug_analysis.lifecycle.checkpoints import checkpoint_chain
from viano_bug_analysis.lifecycle.storage import key
from viano_bug_analysis.package import PackageReader
from .config import APIError
from .dashboard import read_json, FINISHED
def case_key(case_id, package_id):
return key(case_id) + '.' + key(package_id)
def select(document, fields):
return {name: document[name] for name in fields if name in document}
def matches(doc, manifest, revision):
identity = doc.get('case', doc)
return (isinstance(identity, dict) and identity.get('analysis_revision') == revision
and identity.get('case_id', manifest['case_id']) == manifest['case_id']
and identity.get('package_id', manifest['package_id']) == manifest['package_id'])
def references(reader, chain, revision):
mapping = {
'case-built': {'case':'case_ref'},
'case-interpreted': {'case_interpretation':'interpretation_ref'},
'hypotheses-built': {'hypotheses':'hypotheses_ref'},
'investigation-state': {'investigation_state':'state_ref'},
'evidence-stored': {'evidence_index':'index_ref'},
'diagnosis-complete': {'diagnosis':'diagnosis_ref', 'failure_report':'failure_ref'},
'change-request-complete': {'change_request':'change_request_ref', 'decision':'decision_ref', 'human':'human_ref'},
}
result = {}
for _, cp in reversed(chain):
if cp['analysis_revision'] != revision:
continue
details = cp['details']
for pointer, name in mapping.get(details.get('operation'), {}).items():
if details.get(name):
result[pointer] = details[name]
# Newest pointers are also useful while a stage is in progress. Revision
# checks prevent old retained artifacts from masquerading as new results.
for pointer in ('case', 'case_interpretation', 'hypotheses', 'investigation_state', 'diagnosis', 'change_request', 'failure_report', 'evidence_index'):
ref = reader.manifest['artifacts'].get(pointer)
if ref and matches(read_json(reader, ref), reader.manifest, revision):
result[pointer] = ref
return result
def bounded(value, limit, depth=0):
"""Bound display excerpts, never the underlying case; report truncation."""
if depth > 8 or limit[1] <= 0:
limit[0] = True
return [] if isinstance(value, list) else {} if isinstance(value, dict) else '…'
if isinstance(value, str):
data = value.encode('utf-8')
maximum = min(48000, limit[1])
limit[1] -= min(len(data), maximum)
if len(value) > 12000 or len(data) > maximum:
limit[0] = True
return data[:maximum].decode('utf-8', errors='ignore')[:12000] + '…'
return value
if isinstance(value, list):
if len(value) > 100:
limit[0] = True
return [bounded(v, limit, depth+1) for v in value[:100]]
if isinstance(value, dict):
if len(value) > 100:
limit[0] = True
result = {}
for k,v in list(value.items())[:100]:
limit[1] -= len(k.encode('utf-8')) + 8
result[k] = bounded(v, limit, depth+1)
return result
return value
def hypotheses_view(reader, docs):
state = docs.get('investigation_state', {}).get('hypotheses', {})
proposals = {row['id']: row for row in docs.get('hypotheses', {}).get('hypotheses', [])}
result = []
for hid in sorted(set(state) | set(proposals), key=lambda s: int(s[1:]) if s[1:].isdigit() else 0):
current, initial = state.get(hid, {}), proposals.get(hid, {})
proposal = current.get('proposal', initial.get('proposal', {}))
brief_doc = read_json(reader, current.get('working_brief_ref'))
brief = brief_doc.get('brief', {})
if brief_doc and (not matches(brief_doc, reader.manifest, docs['investigation_state']['analysis_revision'])
or brief_doc.get('hypothesis_id') != hid):
brief = {}
# Findings are model interpretations with evidence references, not new observations.
findings = brief.get('findings', [])
review = next((n['causal_review'] for n in reversed(current.get('notes', [])) if 'causal_review' in n), {})
result.append({'id':hid, 'title':proposal.get('title', hid),
'confidence':current.get('confidence', initial.get('confidence', 'unassessed')),
'disposition':current.get('disposition', initial.get('status', 'open')),
'proposal': select(proposal, ('mechanism','rationale','expected_evidence','falsifying_evidence','open_questions')),
'support':[select(f, ('text','scope','evidence_refs')) for f in findings if f.get('direction') == 'support'],
'against':[select(f, ('text','scope','evidence_refs')) for f in findings if f.get('direction') == 'against'],
'review':select(review, ('effect','reason','support','against')),
'uncertainties':brief.get('uncertainties', []),
'questions':[select(q, ('id','text','resolved')) for q in current.get('questions', [])],
'evidence_ids':current.get('evidence_refs', []), 'iterations':current.get('iterations', 0)})
return result
def next_actions(state, reason, worker):
if state in FINISHED:
return [{'kind':'download','text':'Die Analyse ist abgeschlossen. Aufträge und Fallakte an die nächste Instanz übergeben.'},
{'kind':'return','text':'Nach gescheitertem Patch oder neuen Beobachtungen die unveränderte Akte mit Rückmeldung übergeben.'}]
help_ = {
'AWAITING_TEST_FEEDBACK': 'Die angeforderte Testerantwort muss über den vorgesehenen Feedbackablauf bestätigt werden. Keine neue Analyse-Revision eröffnen.',
'RESEARCH_UNAVAILABLE': 'Datenbank-/Repository-Dienst prüfen. Nach Behebung ist eine gezielte Betreiberfreigabe über den Worker möglich.',
'INVALID_LLM_RESPONSES': 'Die Antworten wurden fachlich oder technisch zurückgewiesen. Fehlerdiagnostik prüfen; keine Budgets zurücksetzen.',
'LLM_CONFIGURATION': 'Providerkonfiguration und Zugangsdaten durch den Betreiber prüfen.',
'LLM_UNAVAILABLE': 'Provider-Erreichbarkeit und ausgeschöpfte Wiederholungen prüfen. Eine neue Versuchskennung ist keine automatische Fehlerbehebung.',
}
if state == 'BLOCKED':
return [{'kind':'operator','text':help_.get(reason, 'Sperrgrund durch den Betreiber prüfen. Der Fall wird nicht unkontrolliert erneut gestartet.')}]
if worker.get('status') == 'ATTENTION':
return [{'kind':'operator','text':'Der Worker benötigt eine Betreiberprüfung. Nach Behebung nur die zulässige lokale Freigabe verwenden.'}]
return [{'kind':'wait','text':'Die Analyse läuft oder wartet auf den nächsten Worker-Schritt.'}]
class Cases:
def __init__(self, service):
self.service = service
def head(self, handle, client):
if not re.fullmatch(r'[0-9a-f]{64}\.[0-9a-f]{64}', handle):
raise APIError('API_NOT_FOUND', 404)
case_hash, package_hash = handle.split('.')
folder = self.service.workspace.cases / case_hash / package_hash
if (not folder.is_dir() or folder.is_symlink() or folder.parent.is_symlink()
or getattr(folder.lstat(), 'st_file_attributes', 0) & 0x400
or getattr(folder.parent.lstat(), 'st_file_attributes', 0) & 0x400):
raise APIError('API_NOT_FOUND', 404)
paths = sorted(folder.glob('g*.zip'), key=lambda p: int(p.stem[1:]))
if not paths or paths[-1].is_symlink() or getattr(paths[-1].lstat(), 'st_file_attributes', 0) & 0x400:
raise APIError('API_NOT_FOUND', 404)
with PackageReader(paths[-1]) as reader:
manifest = reader.manifest
if (manifest['project'].get('id') not in client.projects
or case_key(manifest['case_id'], manifest['package_id']) != handle):
raise APIError('API_NOT_FOUND', 404)
return self.service.store.latest(manifest['case_id'], manifest['package_id'])
def detail(self, handle, client, requested_revision=None):
path = self.head(handle, client)
with PackageReader(path) as reader:
m = reader.manifest
chain = checkpoint_chain(reader)
revisions = {}
for _, cp in reversed(chain):
revisions[cp['analysis_revision']] = {'revision':cp['analysis_revision'], 'state':cp['state'], 'updated_at':cp['created_at']}
revisions.setdefault(m['analysis_revision'], {'revision':m['analysis_revision'],'state':m['state'],'updated_at':m['updated_at']})
revision = m['analysis_revision'] if requested_revision is None else requested_revision
if revision not in revisions:
raise APIError('API_REVISION_NOT_FOUND', 404)
refs = references(reader, chain, revision)
docs = {p:read_json(reader, ref) for p,ref in refs.items() if p != 'human'}
docs = {p:d for p,d in docs.items() if p == 'decision' or matches(d, m, revision)}
case = docs.get('case', {})
reports = [select(r, ('title','summary')) for r in case.get('bug', {}).get('reports', [])]
observations = case.get('human_observations', {})
if not case and revision == m['analysis_revision']:
index = read_json(reader, m['artifacts'].get('case_inputs'))
source_reports = [read_json(reader, ref) for ref in index.get('reports', [])[:100]]
reports = [select(r, ('title','summary')) for r in source_reports]
observations = {name:[item for report in source_reports for item in report.get(name, [])]
for name in ('observations','tester_hypotheses')}
interpretation = docs.get('case_interpretation', {}).get('interpretation', {})
coding = docs.get('change_request', {})
human = docs.get('decision', {}).get('human_dev')
human_text = None
if not human and refs.get('human') in reader.files:
if reader._archive.getinfo(refs['human']).file_size <= 512*1024:
human_text = reader.read_bytes(refs['human']).decode('utf-8')
evidence = []
for entry in docs.get('evidence_index', {}).get('entries', [])[:100]:
record = read_json(reader, entry.get('record_ref'))
if not matches(record, m, revision):
continue
content = read_json(reader, record.get('content_ref'))
evidence.append(select(record, ('id','hypothesis_id','operation','duplicate_of')) |
select(content, ('kind','coverage','scope')))
feedback = []
for _, cp in reversed(chain):
d = cp['details']
if d.get('operation') != 'api-case-return' or cp['analysis_revision'] > revision:
continue
request = read_json(reader, f"intake/submissions/{d['submission_id']}/request.json")
value = request.get('metadata', {}).get('feedback')
if value:
feedback.append({'revision':cp['analysis_revision'],'received_at':cp['created_at'],
**select(value, ('outcome','summary','observations','patch_id','tested_build'))})
selected = next((cp for _, cp in chain if cp['analysis_revision'] == revision), None)
selected_state = revisions[revision]['state']
reason = selected['routing'].get('reason') if selected else m['routing'].get('reason')
worker = {}
ledger = self.service.workspace.runtime / 'worker/cases' / (key(m['case_id']+':'+m['package_id'])+'.json')
if ledger.is_file():
try:
value = json.loads(ledger.read_bytes())
except (OSError, ValueError):
value = {}
if value.get('analysis_revision') == revision:
worker = select(value, ('status','code','retry_not_before'))
result = {'key':handle, 'case_id':m['case_id'],'package_id':m['package_id'],
'project_id':m['project'].get('id'),'revision':revision,'current_revision':m['analysis_revision'],
'package_generation':m['package_generation'],'state':selected_state, 'reason':reason,
'revisions':sorted(revisions.values(), key=lambda r:r['revision'], reverse=True),
'reports':reports, 'observations':observations.get('observations', []),
'tester_assumptions':observations.get('tester_hypotheses', []),
'expected_behavior':case.get('expected_behavior', {}),
'interpretation':interpretation,'hypotheses':hypotheses_view(reader,docs),
'evidence':evidence, 'diagnosis':docs.get('diagnosis', {}).get('diagnosis'),
'review':select(docs.get('failure_report', {}), ('reason','remaining_questions','recommended_human_actions')),
'coding':select(coding, ('assignment','human_dev_assigned','request','diagnostic_caveats')),
'human':human, 'human_text':human_text, 'feedback':feedback, 'worker':worker,
'actions':next_actions(selected_state,reason,worker)}
limited=[len(docs.get('evidence_index', {}).get('entries', [])) > 100, 2*1024**2]
result=bounded(result,limited)
result['truncated']=limited[0]
return result
+1
View File
@@ -100,6 +100,7 @@ def case_view(reader):
calls.append({'bucket': bucket if bucket in LABELS else 'unknown', 'phase': phase(call, prompt),
'category': category, 'day': cp['created_at'][:10], **usage})
return {k: manifest[k] for k in ('case_id', 'package_id', 'analysis_revision', 'package_generation', 'state')} | {
'key':key(manifest['case_id']) + '.' + key(manifest['package_id']),
'project_id': manifest['project'].get('id'), 'title': title,
'progress': round(sum(complete) / len(complete) * 100), 'calls': calls,
'updated_at': manifest['updated_at']}
+114
View File
@@ -0,0 +1,114 @@
'use strict';
let selectedCase=null, detailLoading=false, detailHash=null;
const words={observed_behavior:'Beobachtetes Verhalten',expected_behavior:'Erwartetes Verhalten',tester_assumptions:'Testerannahmen',confirmed_technical_facts:'Bestätigte technische Fakten',behavior_gap:'Abweichung',first_known_divergence:'Erste bekannte Abweichung',failure_stage:'Fehlerstelle',contradictions:'Widersprüche',unknowns:'Offene Fragen',relevant_entities:'Relevante Entitäten',investigation_direction:'Untersuchungsrichtung',interpretation_summary:'Einordnung',described_behavior:'Beschriebenes Verhalten',provisional_behavior_gap:'Vorläufige Abweichung',mechanism:'Vermuteter Mechanismus',rationale:'Begründung',expected_evidence:'Erwartete Belege',falsifying_evidence:'Mögliche Gegenbelege',open_questions:'Offene Fragen',text:'Aussage',scope:'Geltungsbereich',reason:'Begründung',status:'Status',confidence:'Confidence',selected_hypothesis:'Gewählte Hypothese',root_cause:'Fehlerursache',evaluations:'Bewertungen der Hypothesen',remaining_uncertainty:'Verbleibende Unsicherheit',recommended_human_actions:'Empfohlene menschliche Maßnahmen',decision_basis:'Entscheidungsgrundlage',causal_claim:'Kausale Aussage',supporting_evidence:'Stützende Belege',counter_evidence:'Gegenbelege',scope_limits:'Geltungsgrenzen',title:'Aufgabe',instructions:'Anweisungen',expected_results:'Erwartetes Ergebnis',problem_summary:'Problem',required_change:'Erforderliche Änderung',implementation_intent:'Umsetzungsziel',affected_scope:'Betroffener Bereich',implementation_guidance:'Hinweise zur Umsetzung',validation_requirements:'Validierung',constraints:'Vorgaben',preserve:'Beibehalten',forbidden_changes:'Unzulässige Änderungen',do_not_fix_by:'Zu vermeidende Lösungswege',missing_information:'Fehlende Informationen',placeholders:'Platzhalter',diagnostic_caveats:'Diagnostische Einschränkungen',coverage:'Abdeckung',kind:'Art',operation:'Werkzeug',duplicate_of:'Bereits erfasster Beleg',hypothesis_id:'Hypothese',resolved:'Beantwortet',author:'Beobachter',patch_id:'Patch',tested_build:'Getesteter Build',summary:'Zusammenfassung',observations:'Beobachtungen',outcome:'Rückmeldung',entity_id:'Entität',required:'Erforderlich',description:'Beschreibung',value:'Wert',column:'Spalte',table:'Tabelle',source_ref:'Quelle',evidence_refs:'Belegreferenzen',source_ids:'Quellen',interpretation_ids:'Interpretationsreferenzen'};
const values={unassessed:'Unbewertet',weak:'Schwach',plausible:'Plausibel',supported:'Gestützt',strongly_supported:'Stark gestützt',weakened:'Geschwächt',confirmed:'Bestätigt',inconclusive:'Nicht abschließend geklärt',assigned:'Auftrag vorhanden',none:'Kein Auftrag',ready:'Bereit',ready_with_placeholders:'Vorlage mit Platzhaltern',refuted:'Verworfen',finished:'Abgeschlossen',open:'Offen',investigation_finished:'Untersuchung abgeschlossen',patch_failed:'Patch behebt den Fehler nicht',validation_failed:'Validierung fehlgeschlagen',new_evidence:'Neue Belege',review_feedback:'Review-Rückmeldung'};
function friendly(value){return typeof value==='string'?(values[value]||value):value===true?'Ja':value===false?'Nein':String(value);}
Object.assign(words,{completeness:'Vollständigkeit',statements:'Aussagen',observed_at:'Beobachtet am',received_at:'Eingegangen am',revision:'Revision',effect:'Wirkung',support:'Dafür',against:'Dagegen'});
Object.assign(values,{missing:'Nicht erfasst',complete:'Vollständig',partial:'Teilweise erfasst'});
function semantic(value,depth=0){
if(value===null||value===undefined)return text('p','Nicht angegeben.','hint');
if(Array.isArray(value)){
if(!value.length)return text('p','Keine Angaben.','hint');
const list=text('div','','semantic-list');value.forEach(v=>{const item=text('div','','semantic-item');item.append(semantic(v,depth+1));list.append(item);});return list;
}
if(typeof value!=='object')return text('p',friendly(value),'semantic-text');
if(typeof value.text==='string'){
const block=text('div','','statement');block.append(text('p',value.text,'semantic-text'));
for(const field of ['scope','author','observed_at'])if(value[field])block.append(text('small',`${words[field]||field}: ${value[field]}`));
for(const field of ['evidence_refs','source_ids'])if(value[field]?.length)block.append(text('div',value[field].join(' · '),'reference-list'));
return block;
}
const dl=text('dl','','semantic-fields');Object.entries(value).forEach(([key,child])=>{
if(key==='provenance'||key==='origin')return;
dl.append(text('dt',words[key]||key.replaceAll('_',' ')));const dd=document.createElement('dd');dd.append(semantic(child,depth+1));dl.append(dd);
});return dl;
}
function panel(title,content,expanded=true){
const card=text('details','','panel detail-panel');card.open=expanded;card.append(text('summary',title));
const body=text('div','','detail-body');body.append(content);card.append(body);return card;
}
function renderArchive(){
if(!overview)return;
const project=$('result-project').value,search=$('result-search').value.trim().toLocaleLowerCase('de-DE');
const list=overview.cases.filter(c=>finished.has(c.state)&&(!project||project===c.project_id)&&(!search||`${c.case_id} ${c.title}`.toLocaleLowerCase('de-DE').includes(search))).sort((a,b)=>b.updated_at.localeCompare(a.updated_at));
const body=$('result-rows');body.replaceChildren();
list.forEach(c=>{const row=document.createElement('tr'),name=document.createElement('td'),link=text('a',c.case_id,'case-link');link.href=`#case/${c.key}`;
name.append(link,text('small',c.title));row.append(name,text('td',c.project_id),text('td',({OUTPUT_READY:'Ergebnis bereit',HANDED_OFF:'Übergeben',FAILED_TO_ANALYZE:'Menschliches Review'})[c.state]),text('td',String(c.analysis_revision)),text('td',new Date(c.updated_at).toLocaleString('de-DE')));body.append(row);});
if(!list.length){const row=document.createElement('tr'),cell=text('td','Keine abgeschlossenen Fälle für diese Auswahl.','empty');cell.colSpan=5;row.append(cell);body.append(row);}
}
function clearDetails(){selectedCase=null;detailHash=null;detailLoading=false;$('result-search').value='';}
async function downloadCase(data,button){
const generation=session,credential=token;button.disabled=true;error();
try{const response=await fetch(`/v1/cases/${data.key}/package`,{headers:{Authorization:`Bearer ${credential}`},cache:'no-store'});
if(!response.ok){const issue=await response.json();if(response.status===401&&credential===token)logout();throw new Error(issue.code||`HTTP ${response.status}`);}
const blob=await response.blob();if(generation!==session)return;
const url=URL.createObjectURL(blob),link=document.createElement('a');link.href=url;link.download=`${data.case_id.replace(/[^A-Za-z0-9_.-]/g,'_')}-Fallakte.zip`;document.body.append(link);link.click();link.remove();setTimeout(()=>URL.revokeObjectURL(url),30000);
}catch(e){if(generation===session)error(`Download fehlgeschlagen: ${e.message}`);}finally{button.disabled=false;}
}
function renderDetail(data){
const root=$('case-detail');root.replaceChildren();
const head=text('section','','panel detail-header'),top=text('div','','section-head'),info=document.createElement('div');
info.append(text('div',`${data.project_id} · ${data.case_id}`,'eyebrow'));
const title=data.reports.find(r=>r.title)?.title||data.case_id;info.append(text('h2',title));
info.append(text('p',`${names[data.state]||({OUTPUT_READY:'Ergebnis bereit',HANDED_OFF:'Übergeben',FAILED_TO_ANALYZE:'Menschliches Review'})[data.state]||data.state} · Revision ${data.revision}`));
const controls=text('div','','detail-controls'),label=text('label','Analyse-Revision'),select=document.createElement('select');select.id='revision-select';select.setAttribute('aria-label','Analyse-Revision');
data.revisions.forEach(r=>select.add(new Option(`Revision ${r.revision}${r.revision===data.current_revision?' · aktuell':''}`,r.revision)));
select.value=String(data.revision);select.addEventListener('change',()=>location.hash=`#case/${data.key}/revision/${select.value}`);label.append(select);controls.append(label);
const download=text('button','Fallakte herunterladen');download.type='button';download.addEventListener('click',()=>downloadCase(data,download));controls.append(download);
top.append(info,controls);head.append(top,text('p','Der Download enthält die vollständige aktuelle Akte einschließlich aller erhaltenen Revisionen.','hint'));
if(data.revision!==data.current_revision)head.append(text('p','Historische Ansicht: Ergebnisse anderer Revisionen werden nicht beigemischt.','history-note'));
if(data.truncated)head.append(text('p','Die Anzeige ist gekürzt. Die vollständigen Inhalte bleiben in der herunterladbaren Fallakte erhalten.','history-note'));
root.append(head);
const actions=text('div','','next-actions');
if(data.reason)actions.append(text('p',`Sperr-/Reviewgrund: ${data.reason}`));
if(data.worker.code)actions.append(text('p',`Workerhinweis: ${data.worker.code}`));
data.actions.forEach(a=>actions.append(text('p',a.text)));root.append(panel('Zustand und nächste Schritte',actions));
root.append(panel('Testerbeobachtungen',semantic({observations:data.observations,tester_assumptions:data.tester_assumptions,expected_behavior:data.expected_behavior})));
root.append(panel('Caseinterpretation',Object.keys(data.interpretation).length?semantic(data.interpretation):text('p','Für diese Revision liegt noch keine akzeptierte Interpretation vor.','hint'),false));
const hypotheses=text('div','','hypothesis-list');
data.hypotheses.forEach(h=>{const card=text('article','','hypothesis-card'),line=text('div','','hypothesis-heading');line.append(text('h3',`${h.id} · ${h.title}`),text('span',`Confidence: ${friendly(h.confidence)}`,'badge'));
card.append(line,text('p',`Status: ${friendly(h.disposition)} · Untersuchungsschritte: ${h.iterations}`,'hint'),semantic(h.proposal));
const evidence=text('div','','evidence-columns');for(const [key,title] of [['support','Befunde dafür'],['against','Befunde dagegen']]){const column=document.createElement('div');column.append(text('h4',title),semantic(h[key]));evidence.append(column);}card.append(evidence,text('p','Diese Befunde sind KI-Bewertungen der referenzierten Belege, keine zusätzlichen Messungen.','hint'));
if(Object.keys(h.review).length)card.append(panel('Letzte kausale Bewertung',semantic(h.review),false));
if(h.uncertainties.length)card.append(panel('Offene Unsicherheiten',semantic(h.uncertainties),false));
if(h.evidence_ids.length)card.append(text('p',`Belege: ${h.evidence_ids.join(' · ')}`,'reference-list'));
hypotheses.append(card);});
if(!data.hypotheses.length)hypotheses.append(text('p','Noch keine akzeptierten Hypothesen in dieser Revision.','hint'));
root.append(panel('Hypothesen und Bewertung',hypotheses));
root.append(panel('Belegverzeichnis',data.evidence.length?semantic(data.evidence):text('p','Noch keine erfassten Belege.','hint'),false));
root.append(panel('Diagnose',data.diagnosis?semantic(data.diagnosis):text('p','Noch keine finale Diagnose in dieser Revision.','hint')));
if(Object.keys(data.review).length)root.append(panel('Menschliches Review',semantic(data.review)));
const coding=document.createElement('div');
if(!Object.keys(data.coding).length)coding.append(text('p','Für diese Revision wurde noch kein Codingauftragsdokument erstellt.','hint'));
else{coding.append(text('p',data.coding.assignment==='none'?'Kein Codingauftrag vorhanden.':'Codingauftrag vorhanden.','assignment-status'));coding.append(semantic(data.coding.request||{}));if(data.coding.diagnostic_caveats)coding.append(panel('Diagnostische Einschränkungen',semantic(data.coding.diagnostic_caveats),false));}
root.append(panel('Codingauftrag',coding));
const human=document.createElement('div');
if(data.human&&['none','assigned'].includes(data.human.status)){human.append(text('p',data.human.status==='none'?'Kein Human-Dev-Auftrag vorhanden.':'Human-Dev-Auftrag vorhanden.','assignment-status'),semantic(data.human));}
else if(data.human_text)human.append(text('pre',data.human_text,'human-document'));
else human.append(text('p','Für diese Revision liegt noch kein Human-Dev-Auftragsdokument vor.','hint'));
root.append(panel('Human-Dev-Auftrag',human));
root.append(panel('Patch-Rückmeldungen und neue Beobachtungen',data.feedback.length?semantic(data.feedback):text('p','Bis zu dieser Revision liegen keine über die Übergabe-API eingereichten Rückmeldungen vor.','hint'),false));
}
async function loadDetail(hash){
const match=hash.match(/^#case\/([a-f0-9]{64}\.[a-f0-9]{64})(?:\/revision\/([0-9]+))?$/);if(!match||!token)return;
const generation=session;detailLoading=true;detailHash=hash;
if(!selectedCase||selectedCase.key!==match[1]||String(selectedCase.revision)!==match[2])$('case-detail').replaceChildren(text('p','Fallakte wird geladen …','panel'));
try{const data=await api(`/v1/cases/${match[1]}${match[2]!==undefined?'?revision='+match[2]:''}`);
if(generation!==session||location.hash!==hash)return;selectedCase=data;renderDetail(data);
}catch(e){if(generation===session&&location.hash===hash){$('case-detail').replaceChildren(text('p',`Fallakte nicht verfügbar: ${e.message}`,'panel'));}}
finally{if(generation===session&&detailHash===hash)detailLoading=false;}
}
function syncDetail(){
if(!token||!location.hash.startsWith('#case/'))return;
const current=overview?.cases.find(c=>c.key===selectedCase?.key);
if(!detailLoading&&(!selectedCase||detailHash!==location.hash||(!location.hash.includes('/revision/')&&current?.package_generation!==selectedCase.package_generation)))loadDetail(location.hash);
}
function route(){
const detail=location.hash.startsWith('#case/'),results=location.hash==='#results',submissionPage=location.hash==='#submit';
$('overview-page').hidden=detail||results||submissionPage;$('results').hidden=!results;$('case-detail').hidden=!detail;$('submit').hidden=detail||results;
document.querySelector('header h1').textContent=detail?'Die Fallakte.':results?'Abgeschlossene Fälle.':submissionPage?'Auftrag übergeben.':'Analyse im Blick.';
document.querySelectorAll('nav a').forEach(a=>a.classList.toggle('selected',detail?a.hash==='#cases':a.hash===(results?'#results':submissionPage?'#submit':location.hash==='#cases'?'#cases':'#overview')));
if(detail)loadDetail(location.hash);if(results)renderArchive();
}
$('result-project').addEventListener('change',renderArchive);$('result-search').addEventListener('input',renderArchive);
window.addEventListener('hashchange',route);route();
+2
View File
@@ -1,3 +1,5 @@
:root{--bg:#f4f5f8;--ink:#202536;--muted:#727a8c;--line:#e7e9f0;--accent:#5364de;--orange:#e9954c}*{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;background:var(--bg);color:var(--ink);font:14px/1.55 system-ui,-apple-system,Segoe UI,sans-serif}.rail{position:fixed;inset:0 auto 0 0;width:210px;background:#202637;color:#a9b1c6;padding:36px 24px;display:flex;flex-direction:column}.brand{color:white;font-size:30px;font-weight:800;letter-spacing:-2px;text-decoration:none}.brand span{color:#a6afff}.brand small{display:block;font-size:12px;letter-spacing:2px;color:#9aa4bf;font-weight:500}nav{display:grid;gap:10px;margin-top:58px}nav a{color:#aeb7cc;text-decoration:none;padding:12px;border-radius:8px}nav a:hover,nav .selected{background:#323b56;color:white}.rail-bottom{margin-top:auto;font-size:10px;letter-spacing:1px;color:#7f8aa5}.rail-bottom span{font-size:12px;color:#c2cae0;letter-spacing:0}main{margin-left:210px;padding:42px 42px 30px;max-width:1660px}header{display:flex;align-items:center;justify-content:space-between;margin-bottom:30px}h1{font-size:36px;letter-spacing:-1.4px;line-height:1.2;margin:8px 0}h2{font-size:19px;letter-spacing:-.4px;margin:4px 0}p{color:var(--muted);margin:6px 0 12px}.eyebrow{font-size:10px;letter-spacing:1.8px;font-weight:750;color:#8b93a7}.live{font-size:12px;color:var(--muted);display:flex;align-items:center;gap:8px}.dot{width:7px;height:7px;background:#8e98ad;border-radius:50%}.connected .dot{background:#3db28a}.panel{background:#fff;border:1px solid var(--line);border-radius:14px;padding:25px;margin-bottom:24px;box-shadow:0 3px 14px #17243c03}.section-head{display:flex;align-items:center;justify-content:space-between;gap:15px;margin-bottom:22px}.stats{display:grid;grid-template-columns:repeat(4,1fr);gap:18px;margin-bottom:24px}.stats article{background:#fff;border:1px solid var(--line);padding:22px;border-radius:12px}.stats span{color:var(--muted);font-size:12px}.stats strong{display:block;font-size:30px;font-weight:650;letter-spacing:-1px;margin:8px 0}.stats small{color:#969daf;font-size:10px}.badge{background:#eef0fd;color:#6572cb;border-radius:20px;padding:5px 12px;font-size:11px}.worker{border-top:1px solid var(--line);padding:18px 0}.worker:first-child{border-top:0;padding-top:0}.worker-top{display:flex;align-items:center;gap:9px;font-weight:600}.worker-top i{width:8px;height:8px;background:#42b98e;border-radius:50%}.worker-case{display:flex;justify-content:space-between;gap:15px;margin:12px 0 8px;color:#646d82}.progress{height:8px;background:#edf0f7;border-radius:8px;overflow:hidden}.progress-fill{height:100%;background:var(--accent);border-radius:8px;transition:width .5s}.hint{font-size:11px;color:#929aab;margin:14px 0 0}.charts{display:grid;grid-template-columns:1fr 1fr;gap:24px}.charts .panel{min-width:0}.pie-layout{display:grid;grid-template-columns:minmax(170px,1fr) 1fr;align-items:center;gap:20px}canvas{display:block;width:100%;max-width:250px;height:auto;margin:auto}.legend{padding:0;margin:0;list-style:none}.legend li{display:grid;grid-template-columns:9px 1fr auto;gap:8px;font-size:11px;align-items:center;margin:10px 0}.swatch{height:8px;width:8px;border-radius:2px}.legend b{font-size:11px;font-weight:600}.keys{display:flex;gap:20px;color:var(--muted);font-size:11px;margin-bottom:18px}.keys span:before{content:'';display:inline-block;width:8px;height:8px;background:#8e9aed;border-radius:2px;margin-right:6px}.keys .output-key:before{background:#303f96}.bar-row{margin:14px 0}.bar-label{display:flex;justify-content:space-between;font-size:11px;margin-bottom:5px;color:#68728a}.bar-track{height:12px;background:#f1f3f8;border-radius:3px;display:flex;overflow:hidden}.bar-input{background:#8e9aed}.bar-output{background:#303f96}.bar-row.correction .bar-input{background:#f2c397}.bar-row.correction .bar-output{background:var(--orange)}table{width:100%;border-collapse:collapse;font-size:12px;text-align:left}th{color:#929aaa;font-size:10px;letter-spacing:.5px;font-weight:600;padding:12px}td{padding:15px 12px;border-top:1px solid var(--line)}td small{display:block;color:var(--muted)}td .progress{width:110px;height:5px}label{display:block;color:#788297;font-size:11px;margin-bottom:14px}input,select,textarea{display:block;width:100%;font:inherit;color:var(--ink);border:1px solid #dfe3ed;background:white;border-radius:7px;padding:10px 12px;margin-top:5px}input:focus,textarea:focus,select:focus{outline:2px solid #aab3fa;outline-offset:1px}input[type=file]{font-size:11px;padding:8px}.filter{display:flex;align-items:center;gap:9px;margin:0}.filter select{width:auto;max-width:240px;margin:0}.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:0 22px}button{font:inherit;background:var(--accent);color:white;border:0;border-radius:7px;padding:11px 18px;cursor:pointer}button:hover{background:#4654be}button:disabled{opacity:.5;cursor:wait}#logout{font-size:11px;padding:6px 10px;margin-top:12px;background:#353f58}.inline{display:flex;gap:10px}.inline input{margin:0}.Line truncated
.worker-case span:last-child{white-space:nowrap;flex-shrink:0}
.case-link{color:#4f61c7;font-weight:650;text-decoration:none}.case-link:hover{text-decoration:underline}.detail-panel>summary{font-size:18px;font-weight:650;cursor:pointer}.detail-body{margin-top:20px}.semantic-text{color:#45516a;white-space:pre-wrap;overflow-wrap:anywhere;margin:5px 0 12px}.semantic-fields{display:grid;grid-template-columns:minmax(130px,190px) minmax(0,1fr);gap:12px 22px;margin:0}.semantic-fields dt{font-size:12px;color:#7c8599;font-weight:600}.semantic-fields dd{margin:0;min-width:0}.semantic-fields .semantic-fields{grid-template-columns:130px minmax(0,1fr)}.semantic-list{display:grid;gap:10px}.semantic-item{border-left:2px solid #e6eaf6;padding-left:13px}.statement small{display:block;color:#9299a8}.reference-list{color:#7785ae;font-size:11px;overflow-wrap:anywhere}.hypothesis-card{border:1px solid #e6e9f1;border-radius:9px;padding:20px;margin-bottom:16px}.hypothesis-heading{display:flex;align-items:center;justify-content:space-between;gap:12px}.hypothesis-heading h3{font-size:16px;margin:0}.hypothesis-heading .badge{flex-shrink:0}.hypothesis-card .detail-panel{margin:16px 0 0;padding:14px;box-shadow:none}.hypothesis-card .detail-panel>summary{font-size:13px}.evidence-columns{display:grid;grid-template-columns:1fr 1fr;gap:26px;margin-top:18px}.evidence-columns h4{font-size:12px;color:#68758c}.evidence-columns>div{background:#f7f8fc;padding:16px;border-radius:7px;min-width:0}.detail-controls{display:flex;align-items:center;gap:16px}.detail-controls label{margin:0;min-width:170px}.detail-controls button{margin-top:20px}.history-note{background:#f1f3fd;color:#6472ae;padding:12px;border-radius:6px;font-size:12px}.assignment-status{font-weight:650;color:#34436a}.human-document{white-space:pre-wrap;overflow-wrap:anywhere;font:13px/1.7 system-ui}.next-actions p{color:#5f6d85}#case-detail .panel{scroll-margin-top:20px}@media(max-width:900px){.semantic-fields,.semantic-fields .semantic-fields{grid-template-columns:1fr;gap:4px}.semantic-fields dd{margin-bottom:12px}.detail-controls{flex-wrap:wrap}}@media(max-width:700px){.hypothesis-heading{align-items:flex-start;flex-direction:column}.evidence-columns{grid-template-columns:1fr}.detail-controls{width:100%}.detail-controls button{margin-top:0}.hypothesis-card{padding:14px}.rail{flex-wrap:wrap}.rail nav{flex-wrap:wrap}}
+9 -5
View File
@@ -24,7 +24,8 @@ function logout() {
$('logout').hidden=true; $('connection').textContent='Nicht verbunden'; document.body.classList.remove('connected');
$('updated').textContent='Warte auf Verbindung'; overview=null;projects=[];submission=null;
$('submit-form').reset();$('receipt').textContent='';$('new-files').hidden=false;$('return-files').hidden=true;
['workers','case-rows','bars','legend','project','project-filter'].forEach(id=>$(id).replaceChildren());
['workers','case-rows','bars','legend','project','project-filter','result-project','result-rows','case-detail'].forEach(id=>$(id).replaceChildren());
if(typeof clearDetails==='function')clearDetails();
['active-count','token-count','correction-count','finished-count'].forEach(id=>$(id).textContent='—');
$('pie').getContext('2d').clearRect(0,0,360,360);
}
@@ -94,9 +95,12 @@ function render() {
$('coverage').textContent=`${buckets.reduce((n,b)=>n+b.calls,0)} erfasste Aufrufe. ${missing?`${missing} Aufrufe mit fehlenden Tokenmesswerten; die Diagramme zeigen nur bekannte Werte.`:'Alle erfassten Aufrufe mit vollständigen Eingabe-/Ausgabewerten.'}${unassigned?.calls?` ${unassigned.calls} ältere Aufrufe ohne sichere Korrekturzuordnung.`:''}`;
const root=$('case-rows');root.replaceChildren();
active.forEach(c=>{const row=document.createElement('tr'), title=document.createElement('td');
title.append(text('strong',c.case_id),text('small',c.title));row.append(title,text('td',c.project_id),text('td',names[c.state]||c.state),text('td',String(c.analysis_revision)));
const link=text('a',c.case_id,'case-link');link.href=`#case/${c.key}`;
title.append(link,text('small',c.title));row.append(title,text('td',c.project_id),text('td',names[c.state]||c.state),text('td',String(c.analysis_revision)));
const cell=document.createElement('td');cell.append(progress(c.progress,`Fortschritt ${c.case_id}`));row.append(cell);root.append(row);});
if(!active.length){const row=document.createElement('tr'),cell=text('td','Keine offenen Fälle.','empty');cell.colSpan=5;row.append(cell);root.append(row);}
if(typeof renderArchive==='function')renderArchive();
if(typeof syncDetail==='function')syncDetail();
}
async function refresh() {
clearTimeout(timer); if(!token)return;const generation=session;
@@ -108,10 +112,10 @@ async function refresh() {
$('login-form').addEventListener('submit',async e=>{
e.preventDefault();const generation=++session;token=$('token').value.trim();error();
try {const response=await api('/v1/profiles');if(generation!==session)return;projects=response.projects;
$('project').replaceChildren();$('project-filter').replaceChildren(new Option('Alle freigegebenen Projekte',''));
projects.forEach(p=>{$('project').add(new Option(p.project_id,p.project_id));$('project-filter').add(new Option(p.project_id,p.project_id));});
$('project').replaceChildren();$('project-filter').replaceChildren(new Option('Alle freigegebenen Projekte',''));$('result-project').replaceChildren(new Option('Alle freigegebenen Projekte',''));
projects.forEach(p=>{$('project').add(new Option(p.project_id,p.project_id));$('project-filter').add(new Option(p.project_id,p.project_id));$('result-project').add(new Option(p.project_id,p.project_id));});
$('login').hidden=true;$('content').hidden=false;$('logout').hidden=false;$('token').value='';
$('connection').textContent='Verbunden';document.body.classList.add('connected');await refresh();
$('connection').textContent='Verbunden';document.body.classList.add('connected');if(typeof route==='function')route();await refresh();
} catch(e) {if(generation===session){token='';error(`Verbindung fehlgeschlagen: ${e.message}`);}else if(!token)error('Verbindung fehlgeschlagen: API-Zugangsschlüssel prüfen.');}
});
$('logout').addEventListener('click',logout);$('project-filter').addEventListener('change',render);
+9 -2
View File
@@ -2,10 +2,11 @@
<html lang="de">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>BugAnalysis · Übersicht</title><link rel="stylesheet" href="/assets/dashboard.css">
<script src="/assets/dashboard.js" defer></script></head>
<script src="/assets/dashboard.js" defer></script>
<script src="/assets/case-detail.js" defer></script></head>
<body>
<aside class="rail"><a class="brand" href="/">V<span>BA</span><small>BugAnalysis</small></a>
<nav aria-label="Navigation"><a href="#overview" class="selected">Übersicht</a><a href="#cases">Fallakten</a><a href="#submit">Auftrag übergeben</a></nav>
<nav aria-label="Navigation"><a href="#overview" class="selected">Übersicht</a><a href="#cases">Laufende Fälle</a><a href="#results">Ergebnisse</a><a href="#submit">Auftrag übergeben</a></nav>
<div class="rail-bottom">ANALYSEBETRIEB<br><span id="connection">Nicht verbunden</span><button id="logout" type="button" hidden>Abmelden</button></div></aside>
<main id="overview">
<header><div><div class="eyebrow">BETRIEBSÜBERSICHT</div><h1>Analyse im Blick.</h1><p>Aktuelle Arbeit und messbarer Verbrauch.</p></div><div class="live"><span class="dot"></span><span id="updated">Warte auf Verbindung</span></div></header>
@@ -13,6 +14,7 @@
<form id="login-form"><label for="token">API-Zugangsschlüssel</label><div class="inline"><input id="token" type="password" autocomplete="off" required placeholder="Zugangsschlüssel"><button type="submit">Verbinden</button></div></form></section>
<p id="error" class="error" role="alert" hidden></p>
<div id="content" hidden>
<div id="overview-page">
<section class="stats" aria-label="Kennzahlen">
<article><span>Aktive Fälle</span><strong id="active-count">—</strong><small>Noch nicht abgeschlossen</small></article>
<article><span>Erfasste Tokens</span><strong id="token-count">—</strong><small>Eingabe + Ausgabe inkl. Denken</small></article>
@@ -28,6 +30,11 @@
<div id="bars" class="bars" aria-label="Tokenverbrauch nach Phase"></div><p class="hint" id="coverage"></p></article></section>
<section class="panel" id="cases"><div class="section-head"><div><div class="eyebrow">FALLAKTEN</div><h2>Aktuelle Untersuchungen</h2></div><label class="filter">Projekt <select id="project-filter"><option value="">Alle freigegebenen Projekte</option></select></label></div>
<div class="table-wrap"><table><thead><tr><th>Fall</th><th>Projekt</th><th>Zustand</th><th>Revision</th><th>Fortschritt</th></tr></thead><tbody id="case-rows"></tbody></table></div></section>
</div>
<section class="panel" id="results" hidden><div class="section-head"><div><div class="eyebrow">ERGEBNISSE</div><h2>Abgeschlossene Fälle</h2><p>Diagnosen, Aufträge und Fälle für menschliches Review.</p></div></div>
<div class="form-grid"><label>Projekt<select id="result-project"><option value="">Alle freigegebenen Projekte</option></select></label><label>Fall suchen<input id="result-search" type="search" placeholder="Bug-ID oder Fallname"></label></div>
<div class="table-wrap"><table><thead><tr><th>Fall</th><th>Projekt</th><th>Ergebnis</th><th>Revision</th><th>Aktualisiert</th></tr></thead><tbody id="result-rows"></tbody></table></div></section>
<section id="case-detail" aria-live="polite" hidden></section>
<section class="panel" id="submit"><div class="section-head"><div><div class="eyebrow">EINGANG</div><h2>Auftrag übergeben</h2><p>BugAnalysis erstellt und ergänzt die Fallakte.</p></div></div>
<form id="submit-form"><div class="form-grid"><label>Auftragstyp<select id="kind"><option value="new_case">Neue Analyse</option><option value="case_return">Abgeschlossene Akte zurückgeben</option></select></label>
<label>Projekt<select id="project" required></select></label><label>Bug-ID<input id="case-id" required placeholder="BUG-000020" pattern="[A-Za-z0-9][A-Za-z0-9_.-]{0,127}"></label><label>Fallname / Rückmeldung<input id="title" required maxlength="240"></label></div>
+11
View File
@@ -43,10 +43,21 @@ try {
await writeFile(path.join(output,'mobile.png'),Buffer.from((await send('Page.captureScreenshot',{format:'png'})).data,'base64'));
await evaluate("document.getElementById('project-filter').value='viano-core';document.getElementById('project-filter').dispatchEvent(new Event('change')); ");
if(await evaluate("document.getElementById('token-count').textContent")!=='35.300')throw new Error('Project aggregation differs');
await evaluate("location.hash='#results'");
await until(()=>evaluate("!document.getElementById('results').hidden&&document.querySelector('#result-rows a')"));
await evaluate("document.querySelector('#result-rows a').click()");
await until(()=>evaluate("document.getElementById('case-detail').textContent.includes('SQL-Vorlage mit Platzhaltern')"));
if(!await evaluate("document.getElementById('case-detail').textContent.includes('Passende Spawndaten ermitteln')"))throw new Error('Human assignment absent');
if(await evaluate('document.documentElement.scrollWidth>innerWidth'))throw new Error('Mobile detail overflow');
await writeFile(path.join(output,'case-mobile.png'),Buffer.from((await send('Page.captureScreenshot',{format:'png'})).data,'base64'));
await send('Emulation.setDeviceMetricsOverride',{width:1440,height:1200,deviceScaleFactor:1,mobile:false});await pause(200);
await writeFile(path.join(output,'case-desktop.png'),Buffer.from((await send('Page.captureScreenshot',{format:'png'})).data,'base64'));
await evaluate("location.hash='#overview'");await pause(100);
await evaluate("document.getElementById('kind').value='case_return';document.getElementById('kind').dispatchEvent(new Event('change'));");
if(!await evaluate("document.getElementById('new-files').hidden&&!document.getElementById('return-files').hidden"))throw new Error('Return form not selected');
await evaluate("document.getElementById('logout').click()");
if(!await evaluate("document.getElementById('content').hidden&&!document.getElementById('login').hidden&&document.getElementById('token-count').textContent==='—'"))throw new Error('Logout retained visible case data');
if(!await evaluate("document.getElementById('case-detail').childElementCount===0&&document.getElementById('result-rows').childElementCount===0"))throw new Error('Logout retained detail data');
if(errors.filter(e=>!e.includes('404')).length)throw new Error(errors.join('\n'));
console.log(JSON.stringify({status:'PASS',...state,console_errors:errors.filter(e=>!e.includes('404'))}));
} finally {
+54
View File
@@ -144,3 +144,57 @@ def test_overview_scope_assets_progress_and_completed_case_hidden(case_setup, mo
monkeypatch.setattr(module,'read_status',corrupted)
response=client.get('/v1/overview',headers=auth)
assert response.status_code==200 and response.json()['workers']==[]
def test_case_details_scope_download_and_revision(case_setup, monkeypatch):
from viano_bug_analysis.lifecycle.storage import json_bytes
from io import BytesIO
from zipfile import ZipFile
from viano_bug_analysis.api.cases import case_key
store,builder,*_=case_setup
builder.build('case-A','stable-package')
monkeypatch.setenv('DETAIL_TOKEN','details_'+'x'*40)
config=APIConfig(store.workspace.root,(Project('p','wow','vcore'),Project('other','wow','vcore')),
(Client('allowed','DETAIL_TOKEN',('p',)),))
app=create_app(config);client=TestClient(app)
url='/v1/cases/'+case_key('case-A','stable-package')
auth={'Authorization':'Bearer details_'+'x'*40}
assert client.get(url).status_code==401
response=client.get(url,headers=auth)
assert response.status_code==200
data=response.json()
assert data['reports'][0]['title']=='Generic problem'
assert data['observations'][0]['text']=='The switch remains off'
assert data['diagnosis'] is None and data['coding']=={}
assert str(store.workspace.root) not in response.text
assert client.get(url+'?revision=999',headers=auth).status_code==404
assert client.get(url+'?revision=../1',headers=auth).status_code==422
download=client.get(url+'/package',headers=auth)
assert download.status_code==200 and download.headers['cache-control']=='no-store'
with ZipFile(BytesIO(download.content)) as archive:
assert archive.testzip() is None
from viano_bug_analysis.api.cases import Cases
with pytest.raises(Exception) as error:
Cases(app.state.intake).detail(case_key('case-A','stable-package'),Client('stranger','DETAIL_TOKEN',('other',)))
assert error.value.status==404
store.advance('case-A','stable-package','INTERPRETING_CASE',step_id='detail:interpret',
artifacts={'case_interpretation':'detail-interpretation.json'},
additions={'detail-interpretation.json':json_bytes({'analysis_revision':1,'interpretation':{'observed_behavior':'Old accepted interpretation'}})},
details={'operation':'case-interpreted','interpretation_ref':'detail-interpretation.json'})
for state in ('GENERATING_HYPOTHESES','INVESTIGATING_HYPOTHESES','BUILDING_DIAGNOSIS','OUTPUT_READY','HANDED_OFF','REOPENED'):
store.advance('case-A','stable-package',state,step_id='detail:'+state)
current=client.get(url,headers=auth).json()
historical=client.get(url+'?revision=1',headers=auth).json()
assert current['revision']==2 and current['interpretation']=={}
assert historical['revision']==1 and historical['state']=='HANDED_OFF'
assert historical['interpretation']['observed_behavior']=='Old accepted interpretation'
def test_historical_artifacts_are_not_replaced_by_latest_revision():
from viano_bug_analysis.api.cases import references
docs={'old':{'analysis_revision':1},'new':{'analysis_revision':2}}
reader=Reader(docs,{'case_id':'c','package_id':'p','artifacts':{'diagnosis':'new'}})
chain=[('newcp',{'analysis_revision':2,'details':{'operation':'diagnosis-complete','diagnosis_ref':'new'}}),
('oldcp',{'analysis_revision':1,'details':{'operation':'diagnosis-complete','diagnosis_ref':'old'}})]
assert references(reader,chain,1)['diagnosis']=='old'
assert references(reader,chain,2)['diagnosis']=='new'
+21
View File
@@ -37,6 +37,15 @@ def seed(root, case_id, title, completed=False):
ref=pointer+'.json';store.advance(*identity,state,step_id=state,
artifacts={pointer:ref},additions={ref:json_bytes({'analysis_revision':1})})
store.advance(*identity,'INVESTIGATING_HYPOTHESES',step_id='investigate')
hypothesis={'id':'H1','proposal':{'title':'Synthetische Arbeitshypothese','mechanism':'Die erwartete Aktion bleibt aus.'},
'confidence':'supported','disposition':'open','iterations':2,'evidence_refs':['E000001'],'working_brief_ref':'brief.json'}
store.advance(*identity,'INVESTIGATING_HYPOTHESES',step_id='fixture-state',
artifacts={'investigation_state':'state.json'},details={'operation':'investigation-state','state_ref':'state.json'},
additions={'state.json':json_bytes({'analysis_revision':1,'hypotheses':{'H1':hypothesis}}),
'brief.json':json_bytes({'analysis_revision':1,'hypothesis_id':'H1','brief':{'findings':[
{'direction':'support','text':'Die Beobachtung passt zur Hypothese.','evidence_refs':['E000001']},
{'direction':'against','text':'Der vorhandene Handler spricht dagegen.','evidence_refs':['E000002']}],
'uncertainties':['Die Laufzeitkonfiguration ist noch ungeklärt.']}})})
measured = [
('BUILDING_CASE','entity_resolution_plan_v4','regular',2500,500),
('INTERPRETING_CASE','case_interpretation_v1','regular',2000,500),
@@ -59,6 +68,18 @@ def seed(root, case_id, title, completed=False):
additions={ref:json_bytes(call),request:json_bytes({'prompt_version':prompt})})
if completed:
store.advance(*identity,'BUILDING_DIAGNOSIS',step_id='diagnosis')
documents={
'diagnosis.json':{'analysis_revision':1,'diagnosis':{'status':'confirmed','root_cause':'Synthetische Diagnose für die Oberflächenprüfung','confidence':'strongly_supported'}},
'coding.json':{'analysis_revision':1,'assignment':'assigned','human_dev_assigned':True,
'request':{'required_change':'SQL-Vorlage mit Platzhaltern erstellen.','placeholders':['Passende Koordinaten ergänzen.']}},
'decision.json':{'human_dev':{'status':'assigned','summary':'Passende Spawndaten ermitteln und prüfen.'}},
}
store.advance(*identity,'BUILDING_DIAGNOSIS',step_id='diagnosis-document',
details={'operation':'diagnosis-complete','diagnosis_ref':'diagnosis.json'},
artifacts={'diagnosis':'diagnosis.json'},additions={ref:json_bytes(doc) for ref,doc in documents.items()})
store.advance(*identity,'BUILDING_DIAGNOSIS',step_id='handoff-document',
details={'operation':'change-request-complete','change_request_ref':'coding.json','decision_ref':'decision.json'},
artifacts={'change_request':'coding.json'})
store.advance(*identity,'OUTPUT_READY',step_id='done')
return identity