feat(studio): Phase 3 - Fall-Chat (RAG) mit Guardrails, Timeline, Recherche-Angebot

Fall-Chat und Ereignis-Timeline, alles ueber Claude, strikt fallorientiert.

incidents.py:
- GET /{id}/events: Aktivitaets-Timeline (article_ingest/refresh/analysis/chat_qa/source_change)
- POST /{id}/ask: Fall-Chat/RAG mit Guardrails:
  * tools=None (kein Netz/kein Werkzeug) = zentrale Anti-Exfil-Abwehr (GitLost)
  * nur Materialien DIESES Falls (scope=alle entfaellt), [n]-Zitate, strikt aus Materialien
  * Claude-Vorauswahl der relevanten Artikel statt lokalem Embedding (kein lokales Modell)
  * _escape_prompt_content + Ausgabe-Leak-Filter + EchoLeak-Haertung (externe Bilder/Links/URLs raus)
  * Verbotsliste: kein Backend/App/Modell/Anbieter, kein Fremd-Fall
  * chat_qa in incident_events protokolliert
- Recherche-Angebot: liefert die Materialien die Frage nicht, haengt der Analyst einen
  needs_research-Block an (focus + description_addition, NUR aus der Frage abgeleitet).
- POST /{id}/clarify (nur auf Nutzer-Bestaetigung): Beschreibung ergaenzen + fokussierte
  Folge-Recherche AUSSCHLIESSLICH zur Frage (Researcher title=focus/description=frage),
  url-dedupliziert in den Fall. Laeuft als Hintergrund-Job (stage_runners.start_job).

stage_runners.py: generischer start_job() fuer Hintergrund-Jobs (run-status-sichtbar).
api.js: clarify(). studio.js + studio.css: Angebot rendern, bestaetigen, run-status pollen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Dieser Commit ist enthalten in:
claude-dev
2026-07-24 23:34:52 +02:00
Ursprung b686685f43
Commit ee31702015
5 geänderte Dateien mit 542 neuen und 0 gelöschten Zeilen

Datei anzeigen

@@ -1387,6 +1387,10 @@ const Studio = {
this.convId = res.conversation_id || this.convId;
thinking.classList.remove('chat-thinking');
thinking.innerHTML = this._renderReply(res.reply || '', res.sources || []);
// Analyst bietet gezielte Recherche an, wenn die Materialien nicht reichen
if (res.offer && res.offer.needs_research) {
thinking.insertAdjacentHTML('beforeend', this._renderOffer(res.offer));
}
// Frage & Antwort wurden serverseitig protokolliert -> Timeline auffrischen
this._tlLoaded = false;
if (this.activeTab === 'timeline') this.loadTimeline(true);
@@ -1425,6 +1429,67 @@ const Studio = {
item.classList.add('cite-flash');
},
// Recherche-Angebot des Analysten (wenn die Materialien die Frage nicht hergeben)
_renderOffer(offer) {
const focus = UI.escape(offer.focus || '');
const add = UI.escape(offer.description_addition || '');
const payload = encodeURIComponent(JSON.stringify({
focus: offer.focus || '', description_addition: offer.description_addition || '',
}));
return `<div class="chat-offer" data-offer="${payload}">
<div class="chat-offer-title">Dazu liegt in diesem Fall noch nichts vor.</div>
<div class="chat-offer-text">Ich kann die Fallbeschreibung um diesen Aspekt ergänzen und gezielt dazu recherchieren, ausschließlich zu dieser Frage.</div>
<label class="chat-offer-label">Ergänzung der Fallbeschreibung (editierbar):</label>
<textarea class="chat-offer-add" rows="2">${add}</textarea>
<div class="chat-offer-focus">Suchfokus: <em>${focus}</em></div>
<button class="studio-btn chat-offer-btn" type="button" onclick="Studio.runClarify(this)">Ergänzen und gezielt recherchieren</button>
</div>`;
},
async runClarify(btn) {
const card = btn.closest('.chat-offer');
if (!card || !this.incident) return;
let data = {};
try { data = JSON.parse(decodeURIComponent(card.dataset.offer || '%7B%7D')); } catch (e) {}
const addEl = card.querySelector('.chat-offer-add');
const description_addition = addEl ? (addEl.value || '').trim() : (data.description_addition || '');
const focus = data.focus || '';
if (!focus) return;
btn.disabled = true; btn.textContent = 'Recherche läuft …';
try {
await API.clarify(this.incident.id, { focus, description_addition });
this._pollClarify(card);
} catch (e) {
btn.disabled = false; btn.textContent = 'Ergänzen und gezielt recherchieren';
UI.showToast((e && e.message) || 'Recherche konnte nicht gestartet werden', 'error');
}
},
_pollClarify(card) {
const incId = this.incident && this.incident.id;
if (!incId) return;
const btn = card.querySelector('.chat-offer-btn');
const textEl = card.querySelector('.chat-offer-text');
const tick = async () => {
let st = null;
try { const r = await API.getRunStatus(incId); st = r && r.state; } catch (e) {}
if (st && st.status === 'running') { setTimeout(tick, 4000); return; }
if (st && st.status === 'done') {
const n = (st.result && st.result.inserted) || 0;
card.innerHTML = `<div class="chat-offer-title">Gezielte Recherche abgeschlossen.</div>
<div class="chat-offer-text">${n} neue Meldung${n === 1 ? '' : 'en'} zur Frage erfasst. Starte Analyse oder Faktencheck neu und stelle die Frage erneut.</div>`;
this._tlLoaded = false;
try { this.fresh = await API.getFreshness(incId); this._renderFreshness(); } catch (e) {}
} else if (st && st.status === 'error') {
if (btn) { btn.disabled = false; btn.textContent = 'Erneut versuchen'; }
if (textEl) textEl.textContent = 'Die Recherche ist fehlgeschlagen. Bitte erneut versuchen.';
} else {
setTimeout(tick, 4000);
}
};
setTimeout(tick, 3000);
},
// === Bausteine (Kacheln in Spalte 3) ===
// Ein Klick startet den Baustein sofort. Läuft er, zeigt die Kachel Spinner +
// Fortschritt; bei den Orchestrator-Bausteinen (Sammeln, Kompletter Lauf) bricht