diff --git a/CLAUDE.md b/CLAUDE.md index 1e5cf17..c8666f6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -58,6 +58,22 @@ Diese drei Punkte sind der Grund, warum "die KI soll sagen, wenn sie scheitert" hier funktioniert: nicht weil das Modell Einsicht hätte, sondern weil eine externe, prüfbare Bedingung es erzwingt. +## Sichtbarkeit der LLM-Arbeit (Grundregel) + +So wie das System **sichtbar abbricht**, wenn es scheitert, muss es auch +**sichtbar zeigen**, wenn es arbeitet. Jede LLM-Aktion (die Kategorisierung) +wird in der Oberfläche als *laufende Aktivität* dargestellt: erkennbarer +Fortschritt (z. B. „LLM kategorisiert … Batch X/Y"), ein animierter Indikator, +und das auslösende Bedien-Element im Lade-Zustand. Eine LLM-Aktion darf **nie** +wie ein eingefrorenes, totes UI aussehen. + +Der Grund ist derselbe wie beim Abbruch: Transparenz. Der Nutzer muss jederzeit +zwischen „arbeitet gerade" und „hängt / ist fertig" unterscheiden können -- sonst +wirkt selbst ein korrekt laufender Prozess wie ein Defekt. Das gilt für die +Web-UI (animierter Status + Fortschrittsbalken) ebenso wie für die CLI (laufende +Status-/Batch-Zeile). Deterministische Schritte (Fetch) dürfen still sein; die +LLM-Schritte nicht. + ## Skills Das Projekt nutzt zwei Skills (in `.claude/skills/`), die exakt dem Schnitt diff --git a/src/angebote/web_static/index.html b/src/angebote/web_static/index.html index 5e41763..fab5974 100644 --- a/src/angebote/web_static/index.html +++ b/src/angebote/web_static/index.html @@ -162,6 +162,23 @@ } .pending { color:var(--muted); } + /* LLM-Arbeit SICHTBAR machen (Grundregel: zeig, dass das Modell arbeitet) */ + @keyframes llm-spin { to { transform:rotate(360deg); } } + @keyframes llm-shimmer { 0%{background-position:-220px 0} 100%{background-position:220px 0} } + .llm-busy { + display:flex; align-items:center; gap:var(--s4); margin-top:var(--s3); + padding:var(--s4); border-radius:var(--radius); + background:var(--brand-weak); border:1px solid #cfe3d9; + } + .llm-busy .spin { flex:none; width:20px; height:20px; border:2.5px solid #c2dccf; border-top-color:var(--brand); border-radius:50%; animation:llm-spin .8s linear infinite; } + .llm-busy .txt { flex:1; min-width:0; } + .llm-busy .txt .hl { font-weight:700; color:var(--brand-d); } + .llm-busy .txt small { display:block; color:var(--brand); opacity:.9; font-size:12px; margin-top:1px; } + .llm-busy .bar2 { height:7px; border-radius:4px; background:#cfe3d9; overflow:hidden; margin-top:7px; } + .llm-busy .bar2 > i { display:block; height:100%; background:var(--brand); border-radius:4px; transition:width .3s; } + .llm-busy.indet .bar2 > i { width:40%; background:linear-gradient(90deg,#cfe3d9,var(--brand),#cfe3d9); background-size:220px 100%; animation:llm-shimmer 1.1s linear infinite; } + @media (prefers-reduced-motion: reduce){ .llm-busy .spin{ animation:none; } .llm-busy.indet .bar2 > i{ animation:none; } } + /* Ergebnis (Stufe 2) */ #result { margin-top:var(--s8); } .summary { display:flex; flex-wrap:wrap; gap:var(--s2) var(--s6); align-items:baseline; color:var(--muted); font-size:14px; margin-bottom:var(--s4); } @@ -537,19 +554,37 @@ async function holeRohdaten(){ } // ----- Stufe 2: Kategorisieren ---------------------------------------------- +// Sichtbarer LLM-Arbeits-Indikator (Grundregel: zeig, dass das Modell arbeitet) +function llmBusy(titel, done, total, modell){ + const indet = !total; + const pct = indet ? 0 : Math.round(100 * done / total); + return `
+
+
+ 🤖 ${esc(titel)}${total ? ` · Batch ${done}/${total}` : ""} + ${modell ? "Modell: " + esc(modell) : "das Sprachmodell ordnet die Angebote ein …"} +
+
+
`; +} + async function kategorisiere(){ if (!ROHDA) return; const plz = $("#plz").value.trim(); - $("#kategorisieren").disabled = true; + const btn = $("#kategorisieren"); + const btnText = btn.dataset.label || btn.textContent; + btn.dataset.label = btnText; + const modellName = $("#modell").value; + btn.disabled = true; btn.textContent = "⏳ LLM arbeitet …"; $("#result").hidden = true; - setStatus(`
Starte Kategorisierung …
`); + setStatus(llmBusy("LLM startet die Kategorisierung …", 0, 0, modellName)); let job; try { const r = await fetch(api("/api/kategorisieren"), { method:"POST", headers:{"Content-Type":"application/json"}, body: JSON.stringify({ - plz, modell: $("#modell").value, anbieter: anbieter(), + plz, modell: modellName, anbieter: anbieter(), key: anbieter()==="openrouter" ? ($("#key").value || undefined) : undefined }) }); @@ -557,7 +592,8 @@ async function kategorisiere(){ if (!r.ok) throw new Error(d.detail || r.status); job = d.job_id; } catch (e){ - setStatus(`
${esc(e.message)}
`); $("#kategorisieren").disabled = false; return; + setStatus(`
${esc(e.message)}
`); + btn.disabled = false; btn.textContent = btnText; return; } const poll = setInterval(async () => { @@ -565,17 +601,11 @@ async function kategorisiere(){ try { s = await (await fetch(api("/api/lauf/" + job))).json(); } catch { return; } if (s.status === "laufend"){ - if (s.total){ - const pct = Math.round(100 * s.done / s.total); - setStatus(`
Kategorisiere … Batch ${s.done}/${s.total} -
`); - } else { - setStatus(`
Kategorisiere …
`); - } + setStatus(llmBusy("LLM kategorisiert …", s.done || 0, s.total || 0, modellName)); return; } clearInterval(poll); - $("#kategorisieren").disabled = false; + btn.disabled = false; btn.textContent = btnText; if (s.status === "fehler"){ setStatus(`
${esc(s.fehler)}
`); return; } setStatus(""); DATEN = s.ergebnis;