feat(citations): Web-Such-Quellen unter der Antwort-Bubble anzeigen

Sonar liefert Citation-URLs; bisher verworfen, jetzt bis in die UI gereicht.

- ToolCallingLLM: _run_tool/_inject_forced_search geben Citations zurueck;
  stream() sammelt sie ueber einen on_citations-Callback.
- Orchestrator: chat_stream reicht on_citations durch (via _stream_supports)
  und legt die gesammelten URLs in PipelineTrace.citations.
- schemas: PipelineTrace.citations.
- ws.py: Citations im semantic-Event.
- Frontend (app.js): renderCitations() zeigt bis zu 4 Quellen-Links
  (Hostname, neuer Tab) unter der Antwort-Bubble.

Nur ueber den Streaming-Pfad (= Web-UI). Live verifiziert: "Wer ist
Bundeskanzler?" -> 15 Quellen gesammelt (Bundeskanzler.de, Wikipedia, ...).
Tests: 312 gruen; JS-Syntax geprueft. Doc §8.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-06-30 00:11:24 +02:00
commit e753788a23
6 changed files with 65 additions and 15 deletions

View file

@ -171,8 +171,11 @@ wird zur Tool-Rangordnung).
Tool-Client) + Koreferenz-Vorstufe + Registry-Eintrag + `web_search_enabled`
(global an, Opt-out) + Filler für **Server-TTS** + Tool/Sonar-Metriken.
schedule_hours-honest-punt akzeptiert.
- **Fast-follow:** Filler für **Gerät-TTS** (Event-Protokoll) → Citations in die
UI-Bubble → ggf. deterministischer Logistik-Nudge.
- **Erledigt (nach v1):** Backstop für heikle Kategorien (§5.7), Filler für
**Gerät-TTS** (§5.4), **Citations in die UI-Bubble** (`PipelineTrace.citations`
`on_citations` → WS `semantic`-Event → Quellen-Links unter der Antwort).
- **Fast-follow (offen):** „no-tool-endpoints"-404-Härtung (tool-unfähiges Modell
bewusst plain statt still stale), ggf. deterministischer Logistik-Nudge.
- **Später (Multitool):** weitere Tools in dieselbe `ToolCallingLLM`-Schleife
(Kalender, Erinnerungen, Medizin-Safety) mit Tool-Rangordnung.

View file

@ -176,7 +176,8 @@ async def _run_turn(
maybe_schedule_extraction(store, user.id, session_id)
await websocket.send_json(
{"type": "semantic", "text": trace.semantic_response, "spoken": trace.spoken_response}
{"type": "semantic", "text": trace.semantic_response,
"spoken": trace.spoken_response, "citations": trace.citations}
)
# Im text_only-Modus kommt kein Audio (Gerät spricht selbst).
if not audio_stream and not text_only:

View file

@ -289,6 +289,13 @@ class Orchestrator:
except (asyncio.CancelledError, Exception):
pass
collected_citations: list[str] = []
async def _on_citations(urls) -> None:
for u in (urls or []):
if u not in collected_citations:
collected_citations.append(u)
if queue is not None:
consumer_task = asyncio.create_task(_consume())
@ -297,6 +304,8 @@ class Orchestrator:
stream_kwargs = {"history": history, "language": effective_language}
if _stream_supports(stream_fn, "on_tool_start"):
stream_kwargs["on_tool_start"] = _on_tool_start
if _stream_supports(stream_fn, "on_citations"):
stream_kwargs["on_citations"] = _on_citations
async for delta in stream_fn(trace.cleaned_transcript or "", **stream_kwargs):
if patience_task is not None:
await _cancel_patience() # Antwort beginnt -> Geduldsschleife stoppen
@ -318,6 +327,7 @@ class Orchestrator:
trace.semantic_response = "".join(parts)
if not trace.semantic_response:
raise RuntimeError("LLM returned an empty response")
trace.citations = collected_citations
trace.spoken_response = await self.spoken_adapter.run(
trace.semantic_response,

View file

@ -192,38 +192,39 @@ class ToolCallingLLM(LLMProvider):
raise last_error or RuntimeError("ToolCallingLLM: alle Versuche fehlgeschlagen")
async def _run_tool(self, tool_call: dict, language: str | None) -> str:
"""Führt einen Tool-Aufruf aus und liefert den tool-Message-Inhalt."""
async def _run_tool(self, tool_call: dict, language: str | None) -> tuple[str, list]:
"""Führt einen Tool-Aufruf aus; liefert (tool-Message-Inhalt, Citations)."""
fn = tool_call.get("function", {})
name = fn.get("name", "")
metrics.inc("tool_calls_total", {"tool": name or "unknown"})
tool = self.tools.get(name)
if tool is None:
logger.warning("Unbekanntes Tool angefragt: %r", name)
return f"ERROR: unknown tool {name!r}."
return f"ERROR: unknown tool {name!r}.", []
try:
args = json.loads(fn.get("arguments") or "{}")
except json.JSONDecodeError:
args = {}
query = str(args.get("query", "")).strip()
result = await tool.run(query, language=language)
# result.citations -> UI-Bubble (Fast-follow); hier (noch) nicht durchgereicht.
return result.text
return result.text, list(getattr(result, "citations", []) or [])
async def _inject_forced_search(self, messages: list[dict], query: str,
language: str | None) -> None:
language: str | None) -> list:
"""Backstop: heikle Kategorie → Suche DETERMINISTISCH ausführen und das
Ergebnis als synthetischen Tool-Turn einspeisen. Verlässlicher als
`tool_choice`-Forcen (das honoriert das Modell nicht zuverlässig)."""
`tool_choice`-Forcen (das honoriert das Modell nicht zuverlässig).
Liefert die Citations zurück."""
tool = self.tools.get("web_search")
if tool is None:
return
return []
metrics.inc("tool_calls_total", {"tool": "web_search"})
result = await tool.run(query, language=language)
messages.append({"role": "assistant", "content": None, "tool_calls": [
{"id": "forced_0", "type": "function",
"function": {"name": "web_search", "arguments": json.dumps({"query": query})}}]})
messages.append({"role": "tool", "tool_call_id": "forced_0", "content": result.text})
return list(getattr(result, "citations", []) or [])
async def complete(self, text: str, history: list[dict] | None = None,
session_id: str | None = None, language: str | None = None) -> str:
@ -243,7 +244,7 @@ class ToolCallingLLM(LLMProvider):
break # leer ohne Tool-Call -> finale Runde erzwingen
messages.append(msg) # Assistant-Message mit tool_calls (unverändert zurück)
for tc in tool_calls:
result_text = await self._run_tool(tc, language)
result_text, _cites = await self._run_tool(tc, language)
messages.append({"role": "tool", "tool_call_id": tc.get("id", ""),
"content": result_text})
@ -254,9 +255,14 @@ class ToolCallingLLM(LLMProvider):
raise RuntimeError("ToolCallingLLM returned an empty response")
return content
@staticmethod
async def _emit_citations(cites: list, on_citations) -> None:
if cites and on_citations is not None:
await on_citations(cites)
async def stream(self, text: str, history: list[dict] | None = None,
session_id: str | None = None, language: str | None = None,
on_tool_start=None, **kwargs) -> AsyncIterator[str]:
on_tool_start=None, on_citations=None, **kwargs) -> AsyncIterator[str]:
"""Gestreamter Loop: Text-Deltas durchreichen; bei tool_call Sonar+nächste Runde.
Kein Latenz-Regress im Normalfall (kein Tool): die Antwort streamt direkt
@ -269,7 +275,8 @@ class ToolCallingLLM(LLMProvider):
metrics.inc("search_forced_total")
if on_tool_start is not None:
await on_tool_start(language) # Filler deckt die erzwungene Suche
await self._inject_forced_search(messages, text, language)
cites = await self._inject_forced_search(messages, text, language)
await self._emit_citations(cites, on_citations)
for _round in range(self.max_rounds):
acc = _StreamAcc()
@ -281,7 +288,8 @@ class ToolCallingLLM(LLMProvider):
await on_tool_start(language) # 4b: Filler sofort sprechen/anzeigen
messages.append(acc.assistant_message())
for tc in acc.tool_calls:
result_text = await self._run_tool(tc, language)
result_text, cites = await self._run_tool(tc, language)
await self._emit_citations(cites, on_citations)
messages.append({"role": "tool", "tool_call_id": tc["id"],
"content": result_text})

View file

@ -34,6 +34,7 @@ class PipelineTrace(BaseModel):
semantic_response: str | None = None
spoken_response: str | None = None
tts_ready_text: str | None = None
citations: list[str] = [] # Quellen-URLs der Web-Suche (für die UI-Bubble)
class SpeakRequest(BaseModel):

View file

@ -513,6 +513,32 @@ function addMessage(role, text, meta = {}) {
return div;
}
// Quellen-Links der Web-Suche unter die Antwort-Bubble (max. 4, idempotent).
function renderCitations(bubble, urls) {
if (!bubble || !Array.isArray(urls) || !urls.length) return;
let box = bubble._citationsEl;
if (!box) {
box = document.createElement("div");
box.className = "citations mt-1 text-xs text-slate-500 dark:text-slate-400 "
+ "flex flex-wrap gap-x-2 gap-y-0.5";
bubble._citationsEl = box;
bubble.appendChild(box);
}
box.innerHTML = "";
const label = document.createElement("span");
label.textContent = "Quellen:";
box.appendChild(label);
urls.slice(0, 4).forEach((u) => {
let host;
try { host = new URL(u).hostname.replace(/^www\./, ""); } catch { host = u; }
const a = document.createElement("a");
a.href = u; a.target = "_blank"; a.rel = "noopener noreferrer";
a.textContent = host;
a.className = "underline hover:text-slate-700 dark:hover:text-white";
box.appendChild(a);
});
}
// ---------- Vorlesen / Replay ----------
function setReplayPlaying(btn, on) {
if (!btn) return;
@ -704,6 +730,7 @@ function runTurn(path, onopen) {
case "semantic":
if (!answerEl) answerEl = addMessage("assistant", "", { lang: turnLang() });
if (msg.text) answerEl._textEl.textContent = msg.text;
if (msg.citations) renderCitations(answerEl, msg.citations);
// Sprache final festhalten (für Replay) und im Geräte-Modus lokal vorlesen.
// Für TTS das bereinigte "spoken" (ohne Markdown/Emojis) bevorzugen, anzeigen
// bleibt der Originaltext.