Add web overview with worker progress and separate correction token charts
This commit is contained in:
1 parent
012a8d8e91
commit
58fc00601e
18 files changed
+804
-2
No files matched your search
@@ -15,6 +15,8 @@ Authenticated submission of new cases and completed-case feedback is documented
|
||||
external callers do not need to implement the internal package format.
|
||||
The [continuous analysis worker](docs/analysis-worker.md) consumes that inbox,
|
||||
routes cases to configured projects and resumes checkpointed analysis after restart.
|
||||
The [web console](docs/web-console.md) shows active workers, case progress and token
|
||||
charts, with corrections accounted for separately, and accepts new/return submissions.
|
||||
|
||||
Requires Python 3.11 or newer.
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ Weitere Projekte benötigen passende Adapter/Profile, keine separat gepflegten F
|
||||
| `GET /healthz` | Minimale öffentliche Erreichbarkeitsprüfung |
|
||||
| `GET /v1/profiles` | Für diesen Client freigegebene Projekte/Profile |
|
||||
| `GET /v1/schema` | Öffentliches JSON-Schema der Übergabemetadaten |
|
||||
| `GET /v1/overview` | Projektbezogene Worker-/Fallübersicht und Tokenstatistik |
|
||||
| `POST /v1/submissions` | Multipart-Upload, `201` bei neuer Einreihung, `200` bei Wiederholung |
|
||||
| `GET /v1/submissions/{id}` | Quittung und aktueller verwalteter Zustand |
|
||||
| `GET /v1/submissions/{id}/package` | Abgeschlossene Akte herunterladen; sonst `409` |
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# Webübersicht – lokale Prüfung vom 2026-10-21
|
||||
|
||||
Ausgangspunkt `012a8d8`. Keine kostenpflichtigen Modellanfragen und kein echter
|
||||
Analyseworker gegen Nutzeraufträge gestartet.
|
||||
|
||||
224 automatisierte Tests bestanden: Dashboard/Zuordnung, Worker/Intake,
|
||||
Antwortreparatur, Response-Recovery, Gateway, Fehlerdiagnostik und Cache.
|
||||
Die Verbrauchszuordnung ergänzt ein optionales Feld im gespeicherten LLM-Aufruf;
|
||||
vorhandene Aufrufe bleiben unverändert und bestehende Schemas kompatibel.
|
||||
|
||||
Geprüft wurden insbesondere:
|
||||
|
||||
- Zugriffsschutz und Projektfreigaben des Overview-Endpunkts;
|
||||
- Fallname, Fortschritt nach abgeschlossenen Stufen und Ausblenden abgeschlossener Fälle;
|
||||
- keine Anzeige alter Worker-Snapshots als lebender Worker;
|
||||
- regulärer erster Aufruf und zusätzliche Korrektur als disjunkte Kategorien;
|
||||
- abgeleitete Dokumente und doppelte Checkpoint-Verweise nicht erneut als Aufrufe gezählt;
|
||||
- Denktokens nicht erneut auf die Ausgabe addiert;
|
||||
- unbekannte Messwerte/Zuordnungen kenntlich gemacht;
|
||||
- Auslieferung lokaler Assets mit Content Security Policy.
|
||||
|
||||
Zusätzlich realer Browser-Smoke-Test mit unsichtbarem Edge und synthetischer
|
||||
temporärer API: Desktop 1440×1200 und Mobil 390×1200, keine horizontale Überbreite,
|
||||
keine JavaScript-/CSP-Konsolenfehler. Projektfilter liefert dieselbe Summe,
|
||||
Rückgabeformular schaltet korrekt um, Abmeldung entfernt angezeigte Daten. Kein
|
||||
Token wurde im Local Storage abgelegt. Die erste Wiederholung des Browsertests
|
||||
traf eine zu frühe Testinteraktion vor Ausführung des defer-Scripts; die
|
||||
Wartebedingung wurde auf vollständige Dokument-/Scriptbereitschaft korrigiert.
|
||||
|
||||
Die Vorschau zeigte genau einen aktiven Worker/Fall bei 57 %, einen bereits
|
||||
abgeschlossenen Fall ausschließlich in der Verbrauchsbilanz und 37.800 synthetische
|
||||
Tokens mit 20,6 % Korrekturanteil. Diese Werte sind **UI-Fixtures, keine gemessenen
|
||||
Analyse- oder Abrechnungskosten**. Screenshots liegen als lokale Prüfergebnisse
|
||||
unter `service-data/ui-preview/desktop.png` und `mobile.png`. Vollständige
|
||||
Testpakete und Browserprofile werden nicht als dauerhaftes Ergebnis archiviert.
|
||||
|
||||
Speicheraudit vor den Tests: 16.939.129.428 Bytes Trial-Workspaces,
|
||||
2.471.041 Bytes Fehlerkorpus und 11.156.187 Bytes vorhandene pytest-Temporärdaten;
|
||||
die neuen synthetischen Tests/Screenshots sind klein und bleiben innerhalb der
|
||||
20-GB-Grenze. Temporäre Vorschauakten wurden entfernt.
|
||||
@@ -0,0 +1,101 @@
|
||||
# Webübersicht
|
||||
|
||||
Die Betriebsübersicht wird durch die bestehende Übergabe-API ausgeliefert:
|
||||
|
||||
```powershell
|
||||
.\.venv\Scripts\python.exe -m viano_bug_analysis.api serve --config config/api.local.toml
|
||||
```
|
||||
|
||||
Anschließend `http://127.0.0.1:8080/` öffnen und mit einem freigegebenen **API-Token**
|
||||
anmelden. Dies ist nicht der Gemini-Schlüssel. Der Token bleibt ausschließlich im
|
||||
Arbeitsspeicher des Tabs, nicht in Cookies, Local Storage oder URLs. Für entfernte
|
||||
Zugriffe HTTPS verwenden. Der Analyseworker läuft unabhängig davon wie unter
|
||||
[Analyseworker](analysis-worker.md) beschrieben. Das Öffnen der Übersicht löst
|
||||
keine Modellanfragen aus.
|
||||
|
||||
## Worker und Fortschritt
|
||||
|
||||
Die Startseite listet lebende Worker und darunter den jeweils bearbeiteten Fall
|
||||
mit seinem Namen und einem Ladebalken. Der bestehende Dienst erlaubt einen Worker
|
||||
je Workspace; die Darstellung ist bereits als Liste aufgebaut. Die Einführung
|
||||
eines parallelen Worker-Pools ist damit nicht verbunden.
|
||||
|
||||
Der Balken folgt abgeschlossenen Arbeitsschritten, nicht vergangener Zeit. Seine
|
||||
sieben Anteile entsprechen intern Aufnahme, Fallaufbau, Interpretation,
|
||||
Hypothesenbildung, Untersuchung, Diagnose und Codingauftrag. Die Oberfläche führt
|
||||
diese Stufen nicht einzeln auf. Der Codingauftrag ist die letzte Stufe, auch wenn
|
||||
sein Dokument fachlich erklärt, dass kein Codingauftrag benötigt wird. Bei
|
||||
gemeinsamer Interpretation/Hypothesengenerierung können zwei Anteile gleichzeitig
|
||||
fertig werden. Eine lang laufende Untersuchung füllt den Balken nicht künstlich.
|
||||
|
||||
Der aktive Fall verschwindet bei `OUTPUT_READY`, `HANDED_OFF` oder
|
||||
`FAILED_TO_ANALYZE` aus der Workeransicht und der Tabelle laufender Fälle. Er bleibt
|
||||
in der Verbrauchsstatistik enthalten. Wartende Fälle bleiben in der Fallübersicht,
|
||||
belegen aber keinen arbeitenden Worker. Ein alter Dienstsnapshot ohne lebenden
|
||||
Telemetrie-Heartbeat wird nicht als aktiver Worker dargestellt.
|
||||
|
||||
## Verbrauch
|
||||
|
||||
- Kreisdiagramm: bekannte Eingabe- und Ausgabetokens je Phase; zusätzliche
|
||||
Korrektur-/Reparatur-/Wiederholungsaufrufe haben ein eigenes orangefarbenes Segment.
|
||||
- Balkendiagramm: Eingabe und Ausgabe einschließlich Denken je Kategorie.
|
||||
- Der erste reguläre Aufruf bleibt bei seiner Phase, auch wenn sein Ergebnis später
|
||||
zurückgewiesen wurde. Seine Korrektur wird ausschließlich dem Korrektursegment
|
||||
zugeordnet. Abgeleitete, deterministisch reparierte Antwortdokumente sind keine
|
||||
zusätzlichen Anbieteraufrufe und werden nicht erneut gezählt.
|
||||
- Denktokens sind bereits Bestandteil der Ausgabetokens. Sie werden weder zum
|
||||
Kreisdiagramm noch zum Gesamtverbrauch noch einmal hinzuaddiert.
|
||||
- Neue LLM-Runden speichern ihre Verbrauchszuordnung deterministisch aus der
|
||||
Operationskennung. Das verändert keine Prompts, Modellparameter oder Budgets.
|
||||
Bereits gespeicherte Aufrufe bleiben unverändert. Bei älteren Akten wird nur
|
||||
eindeutig erkennbare Korrekturinformation übernommen; sonst erscheint der Anteil
|
||||
als „Nicht zugeordnet“.
|
||||
- Fehlende Messwerte bleiben unbekannt. Die Diagramme summieren nur bekannte Werte
|
||||
und weisen unvollständige Aufrufe aus. Bei vollständig fehlender Tokennutzung
|
||||
lässt sich deren Größe nicht als Kreisanteil darstellen.
|
||||
|
||||
Der Messumfang umfasst **alle erhaltenen, für den Client freigegebenen Akten und
|
||||
deren Revisionen** im konfigurierten Workspace. Er ist keine Google-Abrechnung und
|
||||
keine lebenslange Statistik bereits gelöschter Akten. Dauerhafte Testbilanzen unter
|
||||
`docs/test-results/token-usage/` bleiben davon unabhängig. Ein Projektfilter wendet
|
||||
sich auf Fallliste, Worker und Diagramme gleichzeitig an. Gemeinsame Planungsaufrufe
|
||||
werden einmal als Fallplanung gezählt, nicht zusätzlich als Hypothesengenerierung.
|
||||
|
||||
## Übergabe
|
||||
|
||||
Die Oberfläche bietet ein einfaches Formular für neue wowdiag-/wowsrc-Aufträge und
|
||||
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.
|
||||
|
||||
## Datenschutz und Betrieb
|
||||
|
||||
`GET /v1/overview` benötigt einen Bearer-Token und liefert nur freigegebene Projekte.
|
||||
Prompts, vollständige Modellantworten, lokale Pfade und Zugangsdaten werden nicht
|
||||
an den Browser übergeben. Fallnamen werden als Text dargestellt. Die Seite lädt
|
||||
keine externen Fonts, Chartbibliotheken oder Analysedienste; Diagramme werden lokal
|
||||
mit Canvas/CSS gezeichnet. Eine Content Security Policy begrenzt Ressourcen auf
|
||||
denselben Ursprung. Abmeldung entfernt dargestellte Daten und Formularinhalte;
|
||||
verzögert eintreffende Antworten aus einer vorherigen Anmeldung werden ignoriert.
|
||||
|
||||
Die Ansicht aktualisiert sich alle vier Sekunden. Validierte Darstellungen werden
|
||||
für unveränderte Paketgenerationen im API-Prozess zwischengespeichert; dies ist
|
||||
eine Anzeigeoptimierung, kein fachlicher Zustand und kein Analysecache.
|
||||
|
||||
## Lokale Prüfung
|
||||
|
||||
```powershell
|
||||
.\.venv\Scripts\python.exe -m pytest tests/test_dashboard.py -q
|
||||
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
|
||||
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
|
||||
`service-data/ui-preview/`. Es werden keine LLM- oder Datenbankanbieter aufgerufen.
|
||||
@@ -21,6 +21,7 @@ where = ["src"]
|
||||
[tool.setuptools.package-data]
|
||||
"viano_bug_analysis.schemas" = ["*.json"]
|
||||
"viano_bug_analysis.llm.prompts" = ["*.txt"]
|
||||
"viano_bug_analysis.web" = ["*.html", "*.css", "*.js"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
|
||||
@@ -17,10 +17,13 @@ from starlette.formparsers import MultiPartParser, MultiPartException
|
||||
from .config import APIError
|
||||
from .contracts import schema
|
||||
from .service import IntakeService
|
||||
from .dashboard import Dashboard
|
||||
from importlib.resources import files
|
||||
|
||||
|
||||
def create_app(config):
|
||||
service = IntakeService(config)
|
||||
dashboard = Dashboard(service)
|
||||
tokens = config.tokens()
|
||||
service.recover()
|
||||
active = 0
|
||||
@@ -46,6 +49,24 @@ def create_app(config):
|
||||
authenticate(request)
|
||||
return JSONResponse(schema())
|
||||
|
||||
async def overview(request):
|
||||
client = authenticate(request)
|
||||
response = JSONResponse(await run_in_threadpool(dashboard.overview, client))
|
||||
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'}:
|
||||
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])
|
||||
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
|
||||
|
||||
async def status(request):
|
||||
client = authenticate(request)
|
||||
return JSONResponse(await run_in_threadpool(service.status, request.path_params['submission'], client))
|
||||
@@ -113,7 +134,8 @@ def create_app(config):
|
||||
async def storage_error(request, exc):
|
||||
return JSONResponse({'status': 'ERROR', 'code': 'API_STORAGE_ERROR'}, status_code=503)
|
||||
|
||||
app = Starlette(routes=[Route('/healthz', health), Route('/v1/profiles', profiles),
|
||||
app = Starlette(routes=[Route('/', web), Route('/assets/{asset}', web),
|
||||
Route('/v1/overview', overview), Route('/healthz', health), Route('/v1/profiles', profiles),
|
||||
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,189 @@
|
||||
"""Scoped read models. No prompts, raw responses, credentials or host paths leave here."""
|
||||
import json
|
||||
import threading
|
||||
from datetime import datetime, timezone
|
||||
|
||||
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 viano_bug_analysis.telemetry.inspection import read_status
|
||||
from viano_bug_analysis.telemetry.config import TelemetryError
|
||||
|
||||
FINISHED = {'OUTPUT_READY', 'HANDED_OFF', 'FAILED_TO_ANALYZE'}
|
||||
LABELS = {'building': 'Fallaufbau', 'planning': 'Fallplanung', 'hypotheses': 'Hypothesen',
|
||||
'investigation': 'Untersuchung', 'diagnosis': 'Diagnose', 'coding': 'Codingauftrag',
|
||||
'correction': 'Korrekturen / Wiederholungen', 'unknown': 'Nicht zugeordnet'}
|
||||
|
||||
|
||||
def read_json(reader, ref, limit=8*1024**2):
|
||||
if not ref or ref not in reader.files or reader._archive.getinfo(ref).file_size > limit:
|
||||
return {}
|
||||
value = json.loads(reader.read_bytes(ref))
|
||||
return value if isinstance(value, dict) else {}
|
||||
|
||||
|
||||
def phase(call, prompt):
|
||||
if prompt.startswith('change_request'):
|
||||
return 'coding'
|
||||
return {'BUILDING_CASE': 'building', 'INTERPRETING_CASE': 'planning',
|
||||
'GENERATING_HYPOTHESES': 'hypotheses', 'INVESTIGATING_HYPOTHESES': 'investigation',
|
||||
'BUILDING_DIAGNOSIS': 'diagnosis'}.get(call['phase'], 'unknown')
|
||||
|
||||
|
||||
def historical_classification(call, request):
|
||||
prompt = request.get('prompt_version', '')
|
||||
if call['rounds'] > 1 or 'repair' in prompt:
|
||||
return 'correction'
|
||||
for message in request.get('messages', []):
|
||||
try:
|
||||
data = json.loads(message.get('content', ''))
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
if not isinstance(data, dict):
|
||||
continue
|
||||
if data.get('task') == 'correct_rejected_output' or data.get('validation_feedback'):
|
||||
return 'correction'
|
||||
context = data.get('input', data)
|
||||
if isinstance(context, dict):
|
||||
if any(context.get(k) for k in ('response_repair', 'validation_feedback', 'correction_feedback')):
|
||||
return 'correction'
|
||||
if call['phase'] == 'INVESTIGATING_HYPOTHESES' and type(context.get('attempt')) is int:
|
||||
return 'correction' if context['attempt'] > 0 else 'regular'
|
||||
# Older records are not silently counted as regular when attribution is unclear.
|
||||
return 'unknown'
|
||||
|
||||
|
||||
def case_view(reader):
|
||||
manifest = reader.manifest
|
||||
revision = manifest['analysis_revision']
|
||||
artifacts = manifest['artifacts']
|
||||
docs = {p: read_json(reader, artifacts.get(p)) for p in (
|
||||
'case', 'case_interpretation', 'hypotheses', 'investigation_state', 'diagnosis', 'change_request')}
|
||||
def current(pointer):
|
||||
doc = docs[pointer]
|
||||
return doc.get('case', doc).get('analysis_revision') == revision
|
||||
chain = checkpoint_chain(reader)
|
||||
complete = [manifest['state'] != 'NEW', current('case'), current('case_interpretation'),
|
||||
current('hypotheses'), (current('investigation_state') and docs['investigation_state'].get('completed') is True)
|
||||
or manifest['state'] == 'BUILDING_DIAGNOSIS' or manifest['state'] in FINISHED,
|
||||
current('diagnosis'), current('change_request')]
|
||||
title = manifest['case_id']
|
||||
reports = docs['case'].get('bug', {}).get('reports', [])
|
||||
if not reports:
|
||||
index = read_json(reader, artifacts.get('case_inputs'))
|
||||
reports = [read_json(reader, ref) for ref in index.get('reports', [])[:20]]
|
||||
for report in reports:
|
||||
if isinstance(report.get('title'), str) and report['title'].strip():
|
||||
title = report['title'][:240]
|
||||
break
|
||||
calls = []
|
||||
seen = set()
|
||||
for _, cp in reversed(chain):
|
||||
detail = cp['details']
|
||||
ref = detail.get('result_ref')
|
||||
if detail.get('operation') != 'llm-round' or not ref or ref in seen:
|
||||
continue
|
||||
seen.add(ref)
|
||||
call = read_json(reader, ref, 16*1024**2)
|
||||
if not call or call.get('derived'):
|
||||
continue # derived repaired documents are not additional provider calls
|
||||
outcome = call.get('outcome', {})
|
||||
meta = outcome.get('metadata', outcome.get('diagnostics', {}))
|
||||
request = read_json(reader, call.get('request_ref'), 8*1024**2)
|
||||
prompt = meta.get('prompt_version', request.get('prompt_version', ''))
|
||||
category = outcome.get('usage_classification') or historical_classification(call, request)
|
||||
bucket = phase(call, prompt) if category == 'regular' else category
|
||||
usage = {}
|
||||
for field in ('input_tokens', 'output_tokens', 'reasoning_tokens', 'cached_input_tokens'):
|
||||
value = meta.get(field)
|
||||
usage[field] = value if type(value) is int and value >= 0 else None
|
||||
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')} | {
|
||||
'project_id': manifest['project'].get('id'), 'title': title,
|
||||
'progress': round(sum(complete) / len(complete) * 100), 'calls': calls,
|
||||
'updated_at': manifest['updated_at']}
|
||||
|
||||
|
||||
def aggregate(cases):
|
||||
buckets = {name: {'id': name, 'label': label, 'input_tokens': 0, 'output_tokens': 0,
|
||||
'reasoning_tokens': 0, 'cached_input_tokens': 0, 'calls': 0, 'unknown_usage': 0}
|
||||
for name, label in LABELS.items()}
|
||||
days = {}
|
||||
for case in cases:
|
||||
for call in case['calls']:
|
||||
bucket = buckets[call['bucket']]
|
||||
bucket['calls'] += 1
|
||||
bucket['unknown_usage'] += int(call['input_tokens'] is None or call['output_tokens'] is None)
|
||||
day = days.setdefault(call['day'], {'day': call['day'], 'regular': 0, 'correction': 0, 'unknown': 0})
|
||||
total = (call['input_tokens'] or 0) + (call['output_tokens'] or 0)
|
||||
day[call['category'] if call['category'] in {'regular', 'correction'} else 'unknown'] += total
|
||||
for field in ('input_tokens', 'output_tokens', 'reasoning_tokens', 'cached_input_tokens'):
|
||||
bucket[field] += call[field] or 0
|
||||
return {'buckets': list(buckets.values()), 'days': sorted(days.values(), key=lambda d: d['day']),
|
||||
'scope': 'retained_cases_all_revisions', 'reasoning_included_in_output': True,
|
||||
'unknown_usage_calls': sum(b['unknown_usage'] for b in buckets.values())}
|
||||
|
||||
|
||||
class Dashboard:
|
||||
def __init__(self, service):
|
||||
self.service = service
|
||||
self.lock = threading.Lock()
|
||||
self.cache = {}
|
||||
|
||||
def overview(self, client):
|
||||
with self.lock:
|
||||
views, errors, present = [], 0, set()
|
||||
for folder in self.service.workspace.cases.glob('*/*'):
|
||||
try:
|
||||
if folder.is_symlink() or folder.parent.is_symlink():
|
||||
continue
|
||||
paths = sorted(folder.glob('g*.zip'), key=lambda p: int(p.stem[1:]))
|
||||
if not paths:
|
||||
continue
|
||||
path = paths[-1]
|
||||
if path.is_symlink():
|
||||
continue
|
||||
stat = path.stat()
|
||||
signature = (str(path), stat.st_size, stat.st_mtime_ns)
|
||||
present.add(str(folder))
|
||||
cached = self.cache.get(str(folder))
|
||||
if not cached or cached[0] != signature:
|
||||
with PackageReader(path) as reader:
|
||||
m = reader.manifest
|
||||
if folder.name != key(m['package_id']) or folder.parent.name != key(m['case_id']):
|
||||
continue
|
||||
view = case_view(reader)
|
||||
self.cache[str(folder)] = signature, view
|
||||
else:
|
||||
view = cached[1]
|
||||
if view['project_id'] in client.projects:
|
||||
views.append(view)
|
||||
except Exception:
|
||||
errors += 1
|
||||
self.cache = {k: v for k, v in self.cache.items() if k in present}
|
||||
worker = self._worker(views)
|
||||
cases = [{k: v for k, v in view.items() if k != 'calls'} | {'usage': aggregate([view])['buckets']}
|
||||
for view in views]
|
||||
return {'workers': worker, 'cases': cases, 'usage': aggregate(views),
|
||||
'read_errors': errors, 'observed_at': datetime.now(timezone.utc).isoformat()}
|
||||
|
||||
def _worker(self, cases):
|
||||
try:
|
||||
path = self.service.workspace.runtime / 'worker/status.json'
|
||||
status = json.loads(path.read_bytes())
|
||||
if status.get('status') == 'STOPPED':
|
||||
return []
|
||||
telemetry = read_status(self.service.workspace.root)
|
||||
if (telemetry.get('observer') != 'LIVE' or not status.get('service_id')
|
||||
or status['service_id'] != telemetry.get('service_id')):
|
||||
return [] # never present an old snapshot as a live worker
|
||||
identity = status.get('case')
|
||||
case = next((c for c in cases if identity and c['case_id'] == identity.get('case_id')
|
||||
and c['package_id'] == identity.get('package_id')), None)
|
||||
if identity and (not case or case['state'] in FINISHED):
|
||||
return []
|
||||
return [{'id': telemetry.get('service_id', 'analysis-worker'), 'name': 'Analyseworker 1',
|
||||
'busy': bool(case), 'case': {k: v for k, v in case.items() if k != 'calls'} if case else None}]
|
||||
except (OSError, ValueError, TypeError, AttributeError, TelemetryError):
|
||||
return []
|
||||
@@ -0,0 +1,16 @@
|
||||
"""Classify additional repair calls without moving the first rejected call."""
|
||||
import re
|
||||
|
||||
|
||||
def classification(operation, rounds, prompt):
|
||||
if rounds > 1 or any(word in operation for word in (':answer-repair:', ':retry:', 'correction-')) or 'repair' in prompt:
|
||||
return 'correction'
|
||||
for pattern, first in ((r'^investigation:v1:[^:]+:[0-9]+:([0-9]+):', 0),
|
||||
(r'^investigation-startup-review:v1:([0-9]+):', 0),
|
||||
(r'^entity-resolution:v1:[^:]+:([0-9]+)$', 1),
|
||||
(r'^(?:diagnosis|change-request):v1:.*?attempt:([0-9]+)', 1)):
|
||||
if match := re.search(pattern, operation):
|
||||
return 'correction' if int(match[1]) > first else 'regular'
|
||||
if re.fullmatch(r'(?:case-interpretation|hypotheses):v1:default', operation):
|
||||
return 'regular'
|
||||
return 'unknown'
|
||||
@@ -173,6 +173,8 @@ class CaseLLMRunner:
|
||||
record = {"format": "viano-llm-call", "format_version": 1,
|
||||
"case_id": case_id, "package_id": package_id, "analysis_revision": manifest["analysis_revision"],
|
||||
**details, "outcome": outcome}
|
||||
from .accounting import classification
|
||||
record['outcome']['usage_classification'] = classification(operation_id, rounds, request['prompt_version'])
|
||||
if phase == State.INVESTIGATING_HYPOTHESES:
|
||||
from .investigation_size import characters, TARGET, LIMIT
|
||||
record['outcome']['prompt_size'] = {
|
||||
|
||||
@@ -85,6 +85,7 @@
|
||||
"outcome": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"usage_classification": {"enum": ["regular", "correction", "unknown"]},
|
||||
"http_status": {
|
||||
"type": "integer",
|
||||
"minimum": 100,
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Local web console assets; no external analytics, fonts or chart dependencies."""
|
||||
@@ -0,0 +1,3 @@
|
||||
: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}
|
||||
@@ -0,0 +1,140 @@
|
||||
'use strict';
|
||||
const $ = id => document.getElementById(id);
|
||||
const colors = ['#6977db','#8d9ae7','#9e83c9','#50a994','#548ba9','#344779','#e9954c','#b6bdca'];
|
||||
const names = {NEW:'Eingang',VALIDATED:'Angenommen',BUILDING_CASE:'Fallaufbau',INTERPRETING_CASE:'Fallplanung',GENERATING_HYPOTHESES:'Hypothesen',INVESTIGATING_HYPOTHESES:'Untersuchung',BUILDING_DIAGNOSIS:'Abschluss',BLOCKED:'Wartet',REOPENED:'Wiedereröffnet'};
|
||||
const finished = new Set(['OUTPUT_READY','FAILED_TO_ANALYZE','HANDED_OFF']);
|
||||
const number = n => new Intl.NumberFormat('de-DE').format(n);
|
||||
let token = '', timer = null, overview = null, projects = [], sending = false, submission = null, session = 0;
|
||||
const text = (tag, value, cls) => { const e=document.createElement(tag); e.textContent=value; if(cls)e.className=cls; return e; };
|
||||
function error(message='') { $('error').hidden=!message; $('error').textContent=message; }
|
||||
async function api(path, options={}) {
|
||||
const credential=token;
|
||||
const response=await fetch(path,{...options,headers:{...options.headers,Authorization:`Bearer ${credential}`},cache:'no-store'});
|
||||
const data=await response.json();
|
||||
if(!response.ok) { if(response.status===401 && credential===token) logout(); throw new Error(data.code || `HTTP ${response.status}`); }
|
||||
return data;
|
||||
}
|
||||
function progress(value, label) {
|
||||
const outer=text('div','','progress'); outer.setAttribute('role','progressbar');
|
||||
outer.setAttribute('aria-label',label); outer.setAttribute('aria-valuemin','0'); outer.setAttribute('aria-valuemax','100'); outer.setAttribute('aria-valuenow',String(value));
|
||||
const inner=text('div','','progress-fill'); inner.style.width=`${value}%`; outer.append(inner); return outer;
|
||||
}
|
||||
function logout() {
|
||||
session++;token=''; clearTimeout(timer); $('token').value=''; $('content').hidden=true; $('login').hidden=false;
|
||||
$('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());
|
||||
['active-count','token-count','correction-count','finished-count'].forEach(id=>$(id).textContent='—');
|
||||
$('pie').getContext('2d').clearRect(0,0,360,360);
|
||||
}
|
||||
function renderWorkers(workers, project) {
|
||||
const root=$('workers'); root.replaceChildren();
|
||||
const visible=workers.filter(w=>!project || (w.case && w.case.project_id===project));
|
||||
$('worker-count').textContent=`${visible.length} online`;
|
||||
if(!visible.length)root.append(text('div','Kein aktiver Worker für diese Auswahl.','empty'));
|
||||
visible.forEach(worker=>{
|
||||
const row=text('article','','worker'); const head=text('div','','worker-top');
|
||||
head.append(document.createElement('i'),text('span',worker.name)); row.append(head);
|
||||
if(worker.case) {
|
||||
const c=worker.case, line=text('div','','worker-case');
|
||||
line.append(text('span',`${c.case_id} · ${c.title}`),text('span',`${c.progress} %`));
|
||||
row.append(line,progress(c.progress,`Fortschritt ${c.case_id}`));
|
||||
} else row.append(text('p','Bereit für den nächsten Auftrag.','hint'));
|
||||
root.append(row);
|
||||
});
|
||||
}
|
||||
function sumBuckets(cases) {
|
||||
// Per-project aggregation comes from server-filtered individual usage summaries.
|
||||
return cases.reduce((map,c)=>{
|
||||
(c.usage || []).forEach(b=>{const a=map.get(b.id)||{...b,input_tokens:0,output_tokens:0,calls:0,unknown_usage:0};
|
||||
['input_tokens','output_tokens','calls','unknown_usage'].forEach(k=>a[k]+=b[k]||0);map.set(b.id,a);});return map;
|
||||
},new Map());
|
||||
}
|
||||
function renderPie(buckets) {
|
||||
const canvas=$('pie'), ctx=canvas.getContext('2d'), size=360;
|
||||
ctx.clearRect(0,0,size,size); const total=buckets.reduce((n,b)=>n+b.input_tokens+b.output_tokens,0);
|
||||
const legend=$('legend'); legend.replaceChildren(); let angle=-Math.PI/2;
|
||||
if(!total) {ctx.beginPath();ctx.arc(180,180,140,0,Math.PI*2);ctx.fillStyle='#edf0f7';ctx.fill();}
|
||||
buckets.forEach((b,i)=>{
|
||||
const count=b.input_tokens+b.output_tokens; if(!count&&!b.calls)return;
|
||||
const color=colors[i%colors.length]; if(total&&count) {const end=angle+count/total*Math.PI*2;
|
||||
ctx.beginPath();ctx.moveTo(180,180);ctx.arc(180,180,140,angle,end);ctx.closePath();ctx.fillStyle=color;ctx.fill();angle=end;}
|
||||
const item=document.createElement('li'), swatch=text('span','','swatch');swatch.style.background=color;
|
||||
const name=text('span',b.label), val=text('b',total?`${(count/total*100).toFixed(1).replace('.',',')} %`:'—');
|
||||
item.title=`${b.label}: ${number(count)} Tokens, ${b.calls} Aufrufe${b.unknown_usage?' · Messwerte unvollständig':''}`;
|
||||
item.append(swatch,name,val);legend.append(item);
|
||||
});
|
||||
canvas.setAttribute('aria-label',total?'Tokenanteile: '+buckets.filter(b=>b.input_tokens+b.output_tokens).map(b=>`${b.label}: ${number(b.input_tokens+b.output_tokens)}`).join(', '):'Noch keine gemessene Tokennutzung');
|
||||
}
|
||||
function renderBars(buckets) {
|
||||
const root=$('bars');root.replaceChildren(); const max=Math.max(1,...buckets.map(b=>b.input_tokens+b.output_tokens));
|
||||
buckets.filter(b=>b.calls).forEach(b=>{
|
||||
const row=text('div','',`bar-row ${b.id}`), label=text('div','','bar-label');
|
||||
label.append(text('span',b.label),text('span',number(b.input_tokens+b.output_tokens)));
|
||||
const track=text('div','','bar-track'), input=text('div','','bar-input'), output=text('div','','bar-output');
|
||||
input.style.width=`${b.input_tokens/max*100}%`;output.style.width=`${b.output_tokens/max*100}%`;
|
||||
row.title=`Eingabe: ${number(b.input_tokens)} · Ausgabe inkl. Denken: ${number(b.output_tokens)}`;
|
||||
track.append(input,output);row.append(label,track);root.append(row);
|
||||
});
|
||||
if(!root.children.length)root.append(text('div','Noch keine gemessene Tokennutzung.','empty'));
|
||||
}
|
||||
function render() {
|
||||
if(!overview)return;
|
||||
const project=$('project-filter').value;
|
||||
const cases=overview.cases.filter(c=>!project || c.project_id===project), active=cases.filter(c=>!finished.has(c.state));
|
||||
const buckets=project?[...sumBuckets(cases).values()]:overview.usage.buckets;
|
||||
const total=buckets.reduce((n,b)=>n+b.input_tokens+b.output_tokens,0), corrections=buckets.find(b=>b.id==='correction');
|
||||
const corrected=corrections?corrections.input_tokens+corrections.output_tokens:0;
|
||||
$('active-count').textContent=number(active.length);$('finished-count').textContent=number(cases.length-active.length);
|
||||
$('token-count').textContent=number(total);$('correction-count').textContent=total?`${(corrected/total*100).toFixed(1).replace('.',',')} %`:'—';
|
||||
renderWorkers(overview.workers,project);renderPie(buckets);renderBars(buckets);
|
||||
const missing=buckets.reduce((n,b)=>n+b.unknown_usage,0), unassigned=buckets.find(b=>b.id==='unknown');
|
||||
$('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 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);}
|
||||
}
|
||||
async function refresh() {
|
||||
clearTimeout(timer); if(!token)return;const generation=session;
|
||||
try {const response=await api('/v1/overview');if(generation!==session)return;overview=response;render();$('updated').textContent=`Aktualisiert ${new Date().toLocaleTimeString('de-DE')}`;
|
||||
error(overview.read_errors?'Einige Akten konnten nicht gelesen werden. Die Verbrauchsdaten können unvollständig sein.':'');
|
||||
} catch(e) {if(generation===session)error(`Aktualisierung fehlgeschlagen: ${e.message}`);}
|
||||
if(token && generation===session)timer=setTimeout(refresh,4000);
|
||||
}
|
||||
$('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));});
|
||||
$('login').hidden=true;$('content').hidden=false;$('logout').hidden=false;$('token').value='';
|
||||
$('connection').textContent='Verbunden';document.body.classList.add('connected');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);
|
||||
$('kind').addEventListener('change',()=>{$('new-files').hidden=$('kind').value!=='new_case';$('return-files').hidden=$('kind').value!=='case_return';});
|
||||
$('submit-form').addEventListener('submit',async e=>{
|
||||
e.preventDefault();if(sending)return;error();
|
||||
const project=projects.find(p=>p.project_id===$('project').value), kind=$('kind').value;
|
||||
if(!project)return;
|
||||
const observations=$('observations').value.split('\n').map(v=>v.trim()).filter(Boolean);
|
||||
if(!observations.length)return error('Bitte mindestens eine Beobachtung angeben.');
|
||||
const metadata={kind,project_id:project.project_id,profile_id:project.profile_id,case_id:$('case-id').value.trim()};
|
||||
const form=new FormData(), chosen=[];
|
||||
function append(role, files){for(const file of files){form.append(role,file);chosen.push([role,file.name,file.size,file.lastModified]);}}
|
||||
if(kind==='new_case') {if(!$('wowdiag').files.length)return error('Bitte das wowdiag-Archiv auswählen.');
|
||||
metadata.report={title:$('title').value.trim(),observations};append('wowdiag',$('wowdiag').files);append('wowsrc',$('wowsrc').files);
|
||||
} else {if(!$('case-package').files.length)return error('Bitte die abgeschlossene ZIP-Akte auswählen.');
|
||||
metadata.feedback={outcome:$('outcome').value,summary:$('title').value.trim(),observations};append('case_package',$('case-package').files);}
|
||||
append('attachments',$('attachments').files);form.append('metadata',JSON.stringify(metadata));
|
||||
const signature=JSON.stringify([metadata,chosen]);
|
||||
if(!submission || submission.signature!==signature)submission={signature,key:crypto.randomUUID()};
|
||||
const generation=session;sending=true;$('send').disabled=true;$('receipt').textContent='Auftrag wird übertragen …';
|
||||
try {const receipt=await api('/v1/submissions',{method:'POST',headers:{'Idempotency-Key':submission.key},body:form});
|
||||
if(generation!==session)return;
|
||||
$('receipt').textContent=`Angenommen: ${receipt.case_id} · ${receipt.status} · Auftrags-ID ${receipt.submission_id}`;await refresh();
|
||||
} catch(e) {if(generation===session){$('receipt').textContent='';error(`Übergabe fehlgeschlagen: ${e.message}. Bei unveränderten Eingaben wird derselbe Idempotenzschlüssel wiederverwendet.`);}}
|
||||
finally{sending=false;$('send').disabled=false;}
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
<!doctype html>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<section class="login panel" id="login"><div><h2>Mit BugAnalysis verbinden</h2><p>API-Zugangsschlüssel eingeben. Er bleibt nur im Arbeitsspeicher dieses Tabs.</p></div>
|
||||
<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>
|
||||
<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>
|
||||
<article><span>Korrekturanteil</span><strong id="correction-count">—</strong><small>Zusätzliche Korrekturen und Wiederholungen</small></article>
|
||||
<article><span>Abgeschlossene Fälle</span><strong id="finished-count">—</strong><small>Outbox, Review und übergebene Akten</small></article>
|
||||
</section>
|
||||
<section class="panel workers-panel"><div class="section-head"><div><div class="eyebrow">LIVE</div><h2>Aktive Worker</h2></div><span id="worker-count" class="badge">0 online</span></div>
|
||||
<div id="workers" aria-live="polite"></div><p class="hint">Der Balken zeigt abgeschlossene Stufen bis zum Codingauftrag, keine verbleibende Laufzeit.</p></section>
|
||||
<section class="charts"><article class="panel"><div class="section-head"><div><div class="eyebrow">VERTEILUNG</div><h2>Wofür Tokens verbraucht werden</h2></div></div>
|
||||
<div class="pie-layout"><canvas id="pie" width="360" height="360" role="img" aria-label="Tokenanteile nach Phase und Korrekturen"></canvas><ul id="legend" class="legend"></ul></div>
|
||||
<p class="hint">Korrekturen werden ausschließlich im eigenen Segment gezählt. Der erste reguläre Aufruf bleibt bei seiner Phase.</p></article>
|
||||
<article class="panel"><div class="section-head"><div><div class="eyebrow">PHASENVERGLEICH</div><h2>Eingabe und Ausgabe</h2></div></div><div class="keys"><span class="input-key">Eingabe</span><span class="output-key">Ausgabe inkl. Denken</span></div>
|
||||
<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>
|
||||
<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>
|
||||
<label>Beobachtungen (eine pro Zeile)<textarea id="observations" required rows="3"></textarea></label>
|
||||
<div class="form-grid" id="new-files"><label>wowdiag<input id="wowdiag" type="file"></label><label>wowsrc (optional)<input id="wowsrc" type="file"></label></div>
|
||||
<div id="return-files" hidden><label>Abgeschlossene Case-ZIP<input id="case-package" type="file" accept=".zip"></label><label>Ergebnis<select id="outcome"><option value="patch_failed">Patch behebt den Fehler nicht</option><option value="validation_failed">Validierung fehlgeschlagen</option><option value="new_evidence">Neue Belege</option><option value="review_feedback">Review-Rückmeldung</option></select></label></div>
|
||||
<label>Zusätzliche Anhänge<input id="attachments" type="file" multiple></label><button id="send" type="submit">Auftrag einreichen</button><p id="receipt" role="status"></p></form></section>
|
||||
<footer>Verbrauch aller erhaltenen, freigegebenen Akten und Revisionen. Keine Google-Abrechnung. Fehlende Messwerte werden kenntlich gemacht.</footer>
|
||||
</div></main></body></html>
|
||||
@@ -55,7 +55,8 @@ class AnalysisWorker:
|
||||
|
||||
def _status(self, status, *, code=None, case=None):
|
||||
record = {'status': status, 'updated_at': timestamp(), 'pid': os.getpid(),
|
||||
'code': safe_code(code) if code else None, 'case': case}
|
||||
'code': safe_code(code) if code else None, 'case': case,
|
||||
'service_id': getattr(self.telemetry, 'service_id', None)}
|
||||
atomic_json(self.root / 'status.json', record)
|
||||
|
||||
def _due(self, value):
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
// Real Chromium smoke test; Node built-ins only. No paid model calls.
|
||||
import {spawn} from 'node:child_process';
|
||||
import {mkdtemp, mkdir, writeFile, rm} from 'node:fs/promises';
|
||||
import {tmpdir} from 'node:os';
|
||||
import path from 'node:path';
|
||||
const port=18765, debugPort=18766;
|
||||
const pause=ms=>new Promise(r=>setTimeout(r,ms));
|
||||
const output=path.resolve('service-data/ui-preview');await mkdir(output,{recursive:true});
|
||||
const profile=await mkdtemp(path.join(tmpdir(),'vba-browser-'));
|
||||
const fixtureRoot=await mkdtemp(path.join(tmpdir(),'vba-ui-data-'));
|
||||
let browser,server,ws; const errors=[];
|
||||
async function until(fn, milliseconds=15000){const start=Date.now();while(Date.now()-start<milliseconds){try{const v=await fn();if(v)return v;}catch{}await pause(100);}throw new Error('Timed out waiting for UI');}
|
||||
try {
|
||||
server=spawn(path.resolve('.venv/Scripts/python.exe'),['tests/ui_dashboard_server.py'],{windowsHide:true,env:{...process.env,VBA_UI_TEST_PORT:String(port),VBA_UI_TEST_WORKSPACE:fixtureRoot},stdio:'ignore'});
|
||||
await until(async()=> (await fetch(`http://127.0.0.1:${port}/healthz`)).ok);
|
||||
browser=spawn(process.env.BROWSER_PATH||'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe',[
|
||||
'--headless=new','--disable-gpu','--no-first-run','--no-default-browser-check',
|
||||
`--user-data-dir=${profile}`,`--remote-debugging-port=${debugPort}`,'about:blank'],{windowsHide:true,stdio:'ignore'});
|
||||
const tabs=await until(async()=>await (await fetch(`http://127.0.0.1:${debugPort}/json`)).json());
|
||||
ws=new WebSocket(tabs.find(t=>t.type==='page').webSocketDebuggerUrl);
|
||||
await new Promise((resolve,reject)=>{ws.onopen=resolve;ws.onerror=reject;});
|
||||
let next=0;const pending=new Map();
|
||||
ws.onmessage=e=>{const m=JSON.parse(e.data);if(m.id){const p=pending.get(m.id);pending.delete(m.id);m.error?p.reject(new Error(m.error.message)):p.resolve(m.result);}
|
||||
if(m.method==='Runtime.exceptionThrown')errors.push(m.params.exceptionDetails.text);
|
||||
if(m.method==='Log.entryAdded'&&m.params.entry.level==='error')errors.push(m.params.entry.text);};
|
||||
const send=(method,params={})=>new Promise((resolve,reject)=>{const id=++next;pending.set(id,{resolve,reject});ws.send(JSON.stringify({id,method,params}));});
|
||||
const evaluate=async expression=>{const r=await send('Runtime.evaluate',{expression,returnByValue:true,awaitPromise:true});if(r.exceptionDetails)throw new Error(r.exceptionDetails.text);return r.result.value;};
|
||||
await send('Runtime.enable');await send('Log.enable');await send('Page.enable');
|
||||
await send('Emulation.setDeviceMetricsOverride',{width:1440,height:1200,deviceScaleFactor:1,mobile:false});
|
||||
await send('Page.navigate',{url:`http://127.0.0.1:${port}/`});
|
||||
await until(()=>evaluate("document.readyState === 'complete' && typeof render === 'function'"));
|
||||
await evaluate("document.getElementById('token').value='ui_fixture_"+'x'.repeat(40)+"';document.getElementById('login-form').dispatchEvent(new Event('submit',{bubbles:true,cancelable:true}));");
|
||||
await until(()=>evaluate("document.getElementById('token-count').textContent==='37.800'"));
|
||||
const state=await evaluate("({workers:document.querySelectorAll('.worker').length,cases:document.querySelectorAll('#case-rows tr').length,active:document.getElementById('active-count').textContent,correction:document.getElementById('correction-count').textContent,overflow:document.documentElement.scrollWidth>innerWidth,stored:localStorage.length,progress:document.querySelector('.worker [role=progressbar]').getAttribute('aria-valuenow')})");
|
||||
if(state.workers!==1||state.cases!==1||state.active!=='1'||state.stored!==0||state.overflow||state.progress!=='57')throw new Error(JSON.stringify(state));
|
||||
await writeFile(path.join(output,'desktop.png'),Buffer.from((await send('Page.captureScreenshot',{format:'png'})).data,'base64'));
|
||||
await send('Emulation.setDeviceMetricsOverride',{width:390,height:1200,deviceScaleFactor:1,mobile:true});await pause(200);
|
||||
if(await evaluate('document.documentElement.scrollWidth>innerWidth'))throw new Error('Mobile horizontal overflow');
|
||||
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")!=='37.800')throw new Error('Project aggregation differs');
|
||||
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(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 {
|
||||
ws?.close();browser?.kill();server?.kill();await pause(400);
|
||||
for(const [folder,prefix] of [[profile,'vba-browser-'],[fixtureRoot,'vba-ui-data-']]) {
|
||||
const target=path.resolve(folder),relative=path.relative(path.resolve(tmpdir()),target);
|
||||
if(!relative||relative.startsWith('..')||path.isAbsolute(relative)||!path.basename(target).startsWith(prefix))throw new Error('Unsafe temporary cleanup');
|
||||
await rm(target,{recursive:true,force:true,maxRetries:5,retryDelay:200});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
pytest.importorskip('starlette')
|
||||
pytest.importorskip('httpx')
|
||||
pytest.importorskip('multipart')
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from viano_bug_analysis.api import APIConfig, Client, Project
|
||||
from viano_bug_analysis.api.app import create_app
|
||||
from viano_bug_analysis.api.dashboard import aggregate, case_view
|
||||
from viano_bug_analysis.llm.accounting import classification
|
||||
from viano_bug_analysis.lifecycle.storage import atomic_json
|
||||
from test_case_builder import case_setup
|
||||
|
||||
|
||||
@pytest.mark.parametrize('operation,rounds,prompt,expected', [
|
||||
('case-interpretation:v1:default',1,'case_plan_v2','regular'),
|
||||
('case-interpretation:v1:correction-interpretation-1',1,'case_plan_v2','correction'),
|
||||
('hypotheses:v1:default',2,'hypotheses_v4','correction'),
|
||||
('investigation:v1:H1:1:0:token',1,'investigation_lean_v21','regular'),
|
||||
('investigation:v1:H1:1:1:token',1,'investigation_lean_v21','correction'),
|
||||
('investigation-startup-review:v1:0:token',1,'startup','regular'),
|
||||
('investigation-startup-review:v1:1:token',1,'startup','correction'),
|
||||
('entity-resolution:v1:plan:1',1,'entity_resolution_plan_v4','regular'),
|
||||
('entity-resolution:v1:assessment:2',1,'entity_resolution_assessment_v4','correction'),
|
||||
('diagnosis:v1:early:2:attempt:1',1,'diagnosis_v8','regular'),
|
||||
('change-request:v1:attempt:2',1,'change_request_v9','correction'),
|
||||
('other',1,'unknown','unknown'),
|
||||
])
|
||||
def test_call_classification(operation, rounds, prompt, expected):
|
||||
assert classification(operation,rounds,prompt) == expected
|
||||
|
||||
|
||||
class Reader:
|
||||
def __init__(self, docs, manifest):
|
||||
self.docs, self.manifest = docs, manifest
|
||||
self.files = docs
|
||||
self._archive = SimpleNamespace(getinfo=lambda ref: SimpleNamespace(file_size=len(json.dumps(docs[ref]))))
|
||||
def read_bytes(self, ref):
|
||||
return json.dumps(self.docs[ref]).encode()
|
||||
|
||||
|
||||
def test_correction_slice_disjoint_derived_excluded_and_thinking_not_doubled(monkeypatch):
|
||||
import viano_bug_analysis.api.dashboard as module
|
||||
docs = {'case': {'case': {'analysis_revision': 1}, 'bug': {'reports':[{'title':'Testfall'}]}},
|
||||
'request': {'prompt_version':'investigation_lean_v21'}}
|
||||
chain = []
|
||||
for i, category in enumerate(('regular', 'correction', 'regular')):
|
||||
ref = f'call{i}'
|
||||
docs[ref] = {'rounds':1, 'phase':'INVESTIGATING_HYPOTHESES', 'request_ref':'request',
|
||||
'derived': i==2, 'outcome': {'usage_classification':category,
|
||||
'metadata':{'input_tokens':100,'output_tokens':40,'reasoning_tokens':30,'cached_input_tokens':20}}}
|
||||
chain.append(('cp'+str(i), {'details':{'operation':'llm-round','result_ref':ref}, 'created_at':'2026-10-21T10:00:00Z'}))
|
||||
# Duplicate checkpoint provenance does not charge a provider call twice.
|
||||
chain.append(chain[0])
|
||||
monkeypatch.setattr(module,'checkpoint_chain',lambda reader:chain)
|
||||
reader=Reader(docs,{'case_id':'BUG-1','package_id':'one','analysis_revision':1,'package_generation':10,
|
||||
'state':'INVESTIGATING_HYPOTHESES','artifacts':{'case':'case'},'project':{'id':'p'},'updated_at':'now'})
|
||||
view=case_view(reader)
|
||||
assert len(view['calls'])==2
|
||||
usage=aggregate([view]);buckets={b['id']:b for b in usage['buckets']}
|
||||
assert buckets['investigation']['input_tokens']==100
|
||||
assert buckets['correction']['input_tokens']==100
|
||||
assert sum(b['input_tokens']+b['output_tokens'] for b in buckets.values())==280
|
||||
assert sum(b['reasoning_tokens'] for b in buckets.values())==60
|
||||
|
||||
|
||||
def test_unknown_usage_is_not_reported_as_complete():
|
||||
call={'bucket':'unknown','category':'unknown','phase':'diagnosis','day':'2026-10-21',
|
||||
'input_tokens':None,'output_tokens':5,'reasoning_tokens':None,'cached_input_tokens':None}
|
||||
result=aggregate([{'calls':[call]}])
|
||||
assert result['unknown_usage_calls']==1
|
||||
assert next(b for b in result['buckets'] if b['id']=='unknown')['output_tokens']==5
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def project_manifest(manifest):
|
||||
manifest['project']={'id':'p','type':'wow','profile':'vcore'}
|
||||
|
||||
|
||||
def test_overview_scope_assets_progress_and_completed_case_hidden(case_setup, monkeypatch):
|
||||
import viano_bug_analysis.api.dashboard as module
|
||||
store,builder,*_=case_setup
|
||||
built=builder.build('case-A','stable-package')
|
||||
token='dashboard_test_'+'x'*40;monkeypatch.setenv('DASHBOARD_TOKEN',token)
|
||||
config=APIConfig(store.workspace.root,(Project('p','wow','vcore'),Project('other','wow','vcore')),
|
||||
(Client('allowed','DASHBOARD_TOKEN',('p',)),))
|
||||
app=create_app(config);client=TestClient(app);auth={'Authorization':'Bearer '+token}
|
||||
assert client.get('/v1/overview').status_code==401
|
||||
html=client.get('/')
|
||||
assert html.status_code==200 and 'Content-Security-Policy' in html.headers
|
||||
assert 'renderPie' in client.get('/assets/dashboard.js').text
|
||||
assert client.get('/assets/missing.js').status_code==404
|
||||
monkeypatch.setattr(module,'read_status',lambda _: {'observer':'LIVE','service_id':'test-service'})
|
||||
directory=store.workspace.runtime/'worker';directory.mkdir(exist_ok=True)
|
||||
atomic_json(directory/'status.json',{'status':'ANALYZING','service_id':'test-service','case':{'case_id':'case-A','package_id':'stable-package'}})
|
||||
data=client.get('/v1/overview',headers=auth).json()
|
||||
assert data['workers'][0]['case']['title']=='Generic problem'
|
||||
assert data['workers'][0]['case']['progress']==29 # intake + completed casebuild / seven stages
|
||||
assert data['cases'][0]['project_id']=='p'
|
||||
assert token not in json.dumps(data) and str(store.workspace.root) not in json.dumps(data)
|
||||
monkeypatch.setattr(module,'read_status',lambda _: {'observer':'LIVE','service_id':'unrelated-trial'})
|
||||
assert client.get('/v1/overview',headers=auth).json()['workers']==[]
|
||||
monkeypatch.setattr(module,'read_status',lambda _: {'observer':'LIVE','service_id':'test-service'})
|
||||
stranger=Client('other','DASHBOARD_TOKEN',('other',))
|
||||
hidden=module.Dashboard(app.state.intake).overview(stranger)
|
||||
assert hidden['cases']==[] and hidden['workers']==[]
|
||||
assert sum(b['calls'] for b in hidden['usage']['buckets'])==0
|
||||
for state in ('INTERPRETING_CASE','GENERATING_HYPOTHESES','INVESTIGATING_HYPOTHESES','BUILDING_DIAGNOSIS','OUTPUT_READY'):
|
||||
store.advance('case-A','stable-package',state,step_id='test:'+state)
|
||||
completed=client.get('/v1/overview',headers=auth).json()
|
||||
assert completed['workers']==[]
|
||||
monkeypatch.setattr(module,'read_status',lambda _: {'observer':'HEARTBEAT_STALE'})
|
||||
assert client.get('/v1/overview',headers=auth).json()['workers']==[]
|
||||
def corrupted(_):
|
||||
from viano_bug_analysis.telemetry.config import TelemetryError
|
||||
raise TelemetryError('TELEMETRY_READ')
|
||||
monkeypatch.setattr(module,'read_status',corrupted)
|
||||
response=client.get('/v1/overview',headers=auth)
|
||||
assert response.status_code==200 and response.json()['workers']==[]
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Synthetic, disposable UI fixture; never uses model or database providers."""
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import uvicorn
|
||||
from viano_bug_analysis.api import APIConfig, Client, Project
|
||||
from viano_bug_analysis.api.app import create_app
|
||||
from viano_bug_analysis.lifecycle import Workspace, CheckpointStore
|
||||
from viano_bug_analysis.lifecycle.job import timestamp
|
||||
from viano_bug_analysis.lifecycle.storage import atomic_json, json_bytes
|
||||
from viano_bug_analysis.package.factory import PackageFactory
|
||||
from viano_bug_analysis.case import CaseBuilder
|
||||
import viano_bug_analysis.api.dashboard as dashboard
|
||||
|
||||
TOKEN = 'ui_fixture_' + 'x' * 40
|
||||
|
||||
|
||||
def seed(root, case_id, title, completed=False):
|
||||
workspace = Workspace(root)
|
||||
store = CheckpointStore(workspace)
|
||||
now = timestamp()
|
||||
manifest = {'format':'viano-bug-case-package','format_version':1,'case_id':case_id,
|
||||
'package_id':'preview-'+case_id,'package_generation':0,'analysis_revision':0,
|
||||
'project':{'id':'viano-core','type':'wow','profile':'vcore'},'created_at':now,'updated_at':now,
|
||||
'state':'NEW','routing':{'destination':'inbox','reason':None,'retry_count':0,'retry_not_before':None},
|
||||
'artifacts':{'case':None,'diagnosis':None,'change_request':None,'case_inputs':'inputs.json'},
|
||||
'history':{'analysis_revisions':0,'reproductions':0,'evidence':0,'handoffs':0,'feedback':0,'events_ref':None},'integrity':{}}
|
||||
contents={'inputs.json':json_bytes({'format':'viano-case-inputs','format_version':1,'reports':['report.json']}),
|
||||
'report.json':json_bytes({'format':'viano-bug-report','format_version':1,'title':title,
|
||||
'observations':[{'text':'Synthetic UI fixture observation'}]})}
|
||||
path=workspace.inbox/(case_id+'.zip');PackageFactory().create(path,manifest,contents)
|
||||
store.intake(path);path.unlink()
|
||||
identity=case_id,manifest['package_id'];CaseBuilder(store).build(*identity)
|
||||
for state,pointer in [('INTERPRETING_CASE','case_interpretation'),('GENERATING_HYPOTHESES','hypotheses')]:
|
||||
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')
|
||||
for i,(category,inp,out) in enumerate([('regular',12000,3000),('correction',3000,900)]):
|
||||
ref=f'call{i}.json';request=f'request{i}.json'
|
||||
call={'rounds':1,'phase':'INVESTIGATING_HYPOTHESES','request_ref':request,'outcome':{
|
||||
'usage_classification':category,'metadata':{'input_tokens':inp,'output_tokens':out,
|
||||
'reasoning_tokens':out//2,'cached_input_tokens':1000}}}
|
||||
store.advance(*identity,'INVESTIGATING_HYPOTHESES',step_id=f'call{i}',
|
||||
details={'operation':'llm-round','result_ref':ref},
|
||||
additions={ref:json_bytes(call),request:json_bytes({'prompt_version':'investigation_lean_v21'})})
|
||||
if completed:
|
||||
store.advance(*identity,'BUILDING_DIAGNOSIS',step_id='diagnosis')
|
||||
store.advance(*identity,'OUTPUT_READY',step_id='done')
|
||||
return identity
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
with tempfile.TemporaryDirectory(prefix='vba-dashboard-', dir=os.environ.get('VBA_UI_TEST_WORKSPACE')) as temporary:
|
||||
root=Path(temporary)
|
||||
identity=seed(root,'BUG-000020','Wolpertinger fehlen in Dun Morogh')
|
||||
seed(root,'BUG-000017','Leuchtbrille – Kontrolllauf',completed=True)
|
||||
config=APIConfig(root,(Project('viano-core','wow','vcore'),),
|
||||
(Client('preview','VBA_UI_TEST_TOKEN',('viano-core',)),))
|
||||
os.environ['VBA_UI_TEST_TOKEN']=TOKEN
|
||||
dashboard.read_status=lambda _: {'observer':'LIVE','service_id':'fixture-worker'}
|
||||
folder=root/'runtime/worker';folder.mkdir(exist_ok=True)
|
||||
atomic_json(folder/'status.json',{'status':'ANALYZING','service_id':'fixture-worker',
|
||||
'case':{'case_id':identity[0],'package_id':identity[1]}})
|
||||
uvicorn.run(create_app(config),host='127.0.0.1',port=int(os.environ.get('VBA_UI_TEST_PORT','8765')),access_log=False)
|
||||
Reference in new issue
Block a user