From e753788a23596940a456e5b2904d6a15482f02ef Mon Sep 17 00:00:00 2001 From: dschlueter Date: Tue, 30 Jun 2026 00:11:24 +0200 Subject: [PATCH] feat(citations): Web-Such-Quellen unter der Antwort-Bubble anzeigen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Docs/weg2-tool-calling.md | 7 +++++-- app/api/ws.py | 3 ++- app/core/orchestrator.py | 10 ++++++++++ app/providers/llm/tool_calling.py | 32 +++++++++++++++++++------------ app/schemas.py | 1 + app/web/app.js | 27 ++++++++++++++++++++++++++ 6 files changed, 65 insertions(+), 15 deletions(-) diff --git a/Docs/weg2-tool-calling.md b/Docs/weg2-tool-calling.md index bb04e81..3eed303 100644 --- a/Docs/weg2-tool-calling.md +++ b/Docs/weg2-tool-calling.md @@ -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. diff --git a/app/api/ws.py b/app/api/ws.py index 8201c8d..db320df 100644 --- a/app/api/ws.py +++ b/app/api/ws.py @@ -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: diff --git a/app/core/orchestrator.py b/app/core/orchestrator.py index 871a156..c044be5 100644 --- a/app/core/orchestrator.py +++ b/app/core/orchestrator.py @@ -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, diff --git a/app/providers/llm/tool_calling.py b/app/providers/llm/tool_calling.py index c665638..4bc4ae8 100644 --- a/app/providers/llm/tool_calling.py +++ b/app/providers/llm/tool_calling.py @@ -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}) diff --git a/app/schemas.py b/app/schemas.py index 4d3baa0..6a782e6 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -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): diff --git a/app/web/app.js b/app/web/app.js index 2b26ae0..f449547 100644 --- a/app/web/app.js +++ b/app/web/app.js @@ -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.