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:
@@ -263,6 +263,10 @@ const API = {
|
||||
getRunStatus(incidentId) {
|
||||
return this._request('GET', `/incidents/${incidentId}/run-status`);
|
||||
},
|
||||
// Recherche-Angebot des Fall-Chats ausfuehren (Beschreibung ergaenzen + gezielte Recherche)
|
||||
clarify(incidentId, { focus, description_addition = '' } = {}) {
|
||||
return this._request('POST', `/incidents/${incidentId}/clarify`, { focus, description_addition });
|
||||
},
|
||||
// Datenstand je Artefakt (erzeugt? wie alt? wie viele neue Artikel seither?)
|
||||
getFreshness(incidentId) {
|
||||
return this._request('GET', `/incidents/${incidentId}/freshness`);
|
||||
|
||||
@@ -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
|
||||
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren