Add readable case details and completed results browser
This commit is contained in:
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
@@ -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
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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
|
||||
@@ -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']}
|
||||
|
||||
@@ -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/')&¤t?.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();
|
||||
@@ -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}}
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in new issue
Block a user