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
|
|
@ -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})
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue