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:
parent
fdf65e8623
commit
e753788a23
6 changed files with 65 additions and 15 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue