From b70d6f364aa49dc8690a3084087fe295df35c954 Mon Sep 17 00:00:00 2001 From: dschlueter Date: Tue, 30 Jun 2026 00:43:08 +0200 Subject: [PATCH] feat: schedule_hours-Backstop, zentrale Datum/Uhrzeit, Filler-Timing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1) schedule_hours-Nudge: search_backstop erkennt jetzt auch Oeffnungszeiten, "hat X geoeffnet", Notdienst und Fahrplaene/naechste Abfahrt (de/en/nl/fr) und erzwingt dafuer die Suche. +6 Offline-Tests. 2) Zentrale Datum-/Uhrzeit-Auskunft (app/core/clock.py, now_context()): jedes Modul kann das aktuelle Datum + Uhrzeit (lokale TZ, Wochentag) erfragen. In die System-Prompts aller antwortenden LLMs injiziert (ToolCallingLLM, OpenRouter, lokal) -> relative Zeitangaben ("heute", "morgen", "in 3 Stunden", "naechsten Montag") werden aufgeloest. Live: "Welcher Wochentag/Datum?" -> korrekt OHNE Web-Suche. 3) Filler-Timing: Mindestabstand 10 s zwischen Opening und Geduldssaetzen (FILLER_PATIENCE_INTERVAL 4->10). Sobald die Antwort vorliegt, wird ein anstehender Filler/Geduldssatz NICHT mehr ausgegeben (answer_started-Gate). Tests: 318 gruen. Doc §5.4/§5.7/§8. Co-Authored-By: Claude Opus 4.8 --- Docs/weg2-tool-calling.md | 14 +++++++---- app/core/clock.py | 25 ++++++++++++++++++++ app/core/orchestrator.py | 18 ++++++++------ app/pipeline/search_backstop.py | 14 +++++++++++ app/providers/llm/local_openai_compatible.py | 2 ++ app/providers/llm/openrouter.py | 5 ++-- app/providers/llm/tool_calling.py | 7 +++--- tests/test_local_llm_messages.py | 7 ++++-- tests/test_search_backstop.py | 7 ++++++ 9 files changed, 80 insertions(+), 19 deletions(-) create mode 100644 app/core/clock.py diff --git a/Docs/weg2-tool-calling.md b/Docs/weg2-tool-calling.md index 8e7679e..8c75449 100644 --- a/Docs/weg2-tool-calling.md +++ b/Docs/weg2-tool-calling.md @@ -99,8 +99,11 @@ Sprachen werden beim ersten Bedarf einmalig übersetzt. Fällt die Übersetzung Fallback Englisch. **Geduld-Schleife:** Beim Tool-Start läuft ein Hintergrund-Task, der alle -`FILLER_PATIENCE_INTERVAL` Sekunden einen zufälligen PATIENCE-Satz nachschiebt; er -wird beim ersten Antwort-Delta (Antwort beginnt) und im `finally` abgeräumt. +`FILLER_PATIENCE_INTERVAL` Sekunden (Mindestabstand **10 s** zwischen Opening und +Geduldssätzen bzw. zwischen Geduldssätzen) einen zufälligen PATIENCE-Satz +nachschiebt. Sobald die Antwort vorliegt (erstes Antwort-Delta), wird die Schleife +abgeräumt **und** ein anstehender Filler/Geduldssatz NICHT mehr ausgegeben +(`answer_started`-Gate in `_emit_filler`). **Ephemeralität (Invariante):** Filler laufen über den `on_filler`-Callback, nicht über den Delta-Stream — sie landen **nicht** in `trace.semantic_response` und @@ -186,8 +189,11 @@ wird zur Tool-Rangordnung). - **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). -- **Erledigt:** „no-tool-endpoints"-404-Härtung (§5.8). -- **Fast-follow (offen):** ggf. deterministischer Logistik-Nudge (`schedule_hours`). +- **Erledigt:** „no-tool-endpoints"-404-Härtung (§5.8); `schedule_hours`-Nudge + (Öffnungszeiten/Fahrpläne jetzt im Backstop); zentrale Datum-/Uhrzeit-Auskunft + (`app/core/clock.py` → `now_context()` in alle LLM-System-Prompts injiziert, damit + relative Zeitangaben aufgelöst werden). +- **Fast-follow (offen):** — - **Später (Multitool):** weitere Tools in dieselbe `ToolCallingLLM`-Schleife (Kalender, Erinnerungen, Medizin-Safety) mit Tool-Rangordnung. diff --git a/app/core/clock.py b/app/core/clock.py new file mode 100644 index 0000000..71cc8ca --- /dev/null +++ b/app/core/clock.py @@ -0,0 +1,25 @@ +"""Zentrale Datum-/Uhrzeit-Auskunft für alle Programmteile. + +Jedes Modul kann hiermit das aktuelle Datum und die Uhrzeit (lokale Zeitzone) +erfragen, um relative Zeitangaben ("heute", "morgen", "in 3 Stunden", "nächsten +Montag") in absolute Werte umzurechnen. Wird u. a. in die LLM-System-Prompts +injiziert (`now_context()`), damit die Modelle relative Zeitangaben auflösen können. +""" +from datetime import datetime + +_WEEKDAYS_EN = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] + + +def now() -> datetime: + """Aktuelle lokale, zeitzonenbewusste Zeit.""" + return datetime.now().astimezone() + + +def now_context() -> str: + """Knapper, eindeutiger Datum-/Uhrzeit-Hinweis (Englisch) für LLM-System-Prompts.""" + n = now() + tz = n.strftime("%Z") or "local" + return (f"The current date and time is {n.strftime('%Y-%m-%d')} " + f"({_WEEKDAYS_EN[n.weekday()]}) {n.strftime('%H:%M')} ({tz}). " + f"Use this to resolve relative dates and times such as 'today', " + f"'tomorrow', 'next Monday', or 'in 3 hours'.") diff --git a/app/core/orchestrator.py b/app/core/orchestrator.py index c044be5..40762a2 100644 --- a/app/core/orchestrator.py +++ b/app/core/orchestrator.py @@ -6,9 +6,9 @@ from app.pipeline.sentence_chunker import SentenceChunker from app.pipeline.fillers import FillerPhrases from app.metrics import timer, metrics -# Intervall (Sekunden), nach dem bei noch laufender Recherche ein zufälliger -# Geduldssatz nachgeschoben wird. -FILLER_PATIENCE_INTERVAL = 4.0 +# Mindestabstand (Sekunden) zwischen Beruhigungssatz und Geduldssätzen bzw. +# zwischen Geduldssätzen, bei noch laufender Recherche. +FILLER_PATIENCE_INTERVAL = 10.0 def _stage(name: str): @@ -248,15 +248,18 @@ class Orchestrator: await queue.put(sentence) patience_task: asyncio.Task | None = None + answer_started = False async def _emit_filler(phrase: str) -> None: """Ephemer: dedizierter Filler-Kanal (Anzeige/Status + Geräte-TTS) + _dispatch (Server-TTS, "Satz null"). Läuft bewusst NICHT über den Token-Stream -> nie in parts/ - semantic_response/History. on_filler bevorzugt (eigener Event, damit - Geräte-TTS ihn sprechen kann); sonst Fallback auf on_token (Anzeige). + semantic_response/History. Sobald die Antwort vorliegt, wird ein + anstehender Filler/Geduldssatz NICHT mehr ausgegeben. """ + if answer_started: + return if on_filler is not None: await on_filler(phrase) elif on_token is not None: @@ -307,8 +310,9 @@ class Orchestrator: 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 + if not answer_started: + answer_started = True # Antwort liegt vor -> keine weiteren Filler + await _cancel_patience() parts.append(delta) if on_token: await on_token(delta) diff --git a/app/pipeline/search_backstop.py b/app/pipeline/search_backstop.py index 8e27fa4..4a8ff33 100644 --- a/app/pipeline/search_backstop.py +++ b/app/pipeline/search_backstop.py @@ -42,6 +42,20 @@ _PATTERNS = [ re.compile(r"\bstill\s+alive\b", re.I), re.compile(r"\b(?:encore|toujours)\s+(?:en\s+vie|vivant)\b", re.I), re.compile(r"\b(?:sigue|todav[íi]a)\b.{0,20}\bviv[oa]\b", re.I), + + # Öffnungszeiten / „hat X geöffnet" (Gegenwart/Logistik) + re.compile(r"\b(?:öffnungszeiten|opening hours|openingstijden|horaires? d.ouverture)\b", re.I), + re.compile(r"\b(?:geöffnet|geschlossen)\b", re.I), + re.compile(r"\bnoch\s+(?:offen|auf|geöffnet)\b", re.I), + re.compile(r"\bis\b.{0,30}\bopen\b", re.I), + re.compile(r"\b(?:geopend|open)\b.{0,12}\b(?:vandaag|nu)\b", re.I), + re.compile(r"\bnotdienst\b", re.I), + # Fahrpläne / nächste Abfahrt + re.compile(r"\b(?:wann|wanneer|hoe laat|when|what time|à quelle heure)\b.{0,40}" + r"\b(?:bus|z[üu]ge?|bahn|tram|s-?bahn|u-?bahn|train|trein|metro|f[äa]hre|veerboot|flug|flight)\b", re.I), + re.compile(r"\b(?:nächste[rn]?|next|volgende|prochain\w*)\b.{0,15}" + r"\b(?:bus|zug|bahn|train|trein|tram|f[äa]hre|metro)\b", re.I), + re.compile(r"\b(?:abfahrt\w*|departure|vertrek\w*)\b", re.I), ] diff --git a/app/providers/llm/local_openai_compatible.py b/app/providers/llm/local_openai_compatible.py index eb7761d..af0e9db 100644 --- a/app/providers/llm/local_openai_compatible.py +++ b/app/providers/llm/local_openai_compatible.py @@ -2,6 +2,7 @@ from collections.abc import AsyncIterator import httpx +from app.core import clock from app.providers.llm.base import ( LLMProvider, lang_instruction, @@ -41,6 +42,7 @@ class LocalOpenAICompatibleLLM(LLMProvider): system_parts: list[str] = [] if self.system_prompt: system_parts.append(self.system_prompt) + system_parts.append(clock.now_context()) # aktuelles Datum/Uhrzeit (relative Angaben auflösen) rest: list[dict] = [] for msg in history or []: if msg.get("role") == "system": diff --git a/app/providers/llm/openrouter.py b/app/providers/llm/openrouter.py index d047692..722f788 100644 --- a/app/providers/llm/openrouter.py +++ b/app/providers/llm/openrouter.py @@ -4,6 +4,7 @@ from collections.abc import AsyncIterator import httpx +from app.core import clock from app.providers.llm.base import ( LLMProvider, lang_instruction, @@ -100,10 +101,10 @@ class OpenRouterLLMProvider(LLMProvider): if not text or not text.strip(): raise ValueError("LLM input text is empty") - system_content = SYSTEM_PROMPT + system_content = f"{SYSTEM_PROMPT}\n\n{clock.now_context()}" instr = lang_instruction(language) if instr: - system_content = f"{SYSTEM_PROMPT}\n\n{instr}" + system_content = f"{system_content}\n\n{instr}" messages = [{"role": "system", "content": system_content}] if history: diff --git a/app/providers/llm/tool_calling.py b/app/providers/llm/tool_calling.py index 27033cf..97e8667 100644 --- a/app/providers/llm/tool_calling.py +++ b/app/providers/llm/tool_calling.py @@ -13,10 +13,10 @@ import asyncio import json import logging from collections.abc import AsyncIterator -from datetime import date import httpx +from app.core import clock from app.metrics import metrics from app.pipeline.search_backstop import should_force_search from app.providers.llm.base import LLMProvider, lang_instruction, with_lang_reminder @@ -75,7 +75,7 @@ WEB_SEARCH_TOOL = { # zu früh als zu spät) — das verschiebt im Zweifel Richtung „suchen", die sichere # Seite (höherer Recall). Im Eval mit genau dieser Formulierung validiert. _TRIGGER_AND_PRIORITY = ( - "Your knowledge was last updated around {cutoff}. Today is {today}. " + "Your knowledge was last updated around {cutoff}. " "When a question could require information newer than {cutoff} that you " "cannot reason out yourself, call web_search before answering. Never call " "web_search merely to find the current time or today's date — you already " @@ -86,8 +86,7 @@ _TRIGGER_AND_PRIORITY = ( def _system_prompt(cutoff: str, language: str | None) -> str: - today = date.today().isoformat() - parts = [PERSONA_PROMPT, _TRIGGER_AND_PRIORITY.format(cutoff=cutoff, today=today)] + parts = [PERSONA_PROMPT, clock.now_context(), _TRIGGER_AND_PRIORITY.format(cutoff=cutoff)] instr = lang_instruction(language) if instr: parts.append(instr) diff --git a/tests/test_local_llm_messages.py b/tests/test_local_llm_messages.py index 4c5ea46..c1330ce 100644 --- a/tests/test_local_llm_messages.py +++ b/tests/test_local_llm_messages.py @@ -24,7 +24,10 @@ def test_single_system_message_when_history_has_system(): assert msgs[-1] == {"role": "user", "content": "Wie geht es dir?"} -def test_no_system_message_without_prompt_or_history(): +def test_clock_system_message_without_prompt_or_history(): + # Datum/Uhrzeit-Kontext wird immer als (einzige) fuehrende System-Nachricht gesetzt. llm = _llm(system_prompt="") msgs = llm._build_messages("Hallo", None) - assert msgs == [{"role": "user", "content": "Hallo"}] + assert msgs[0]["role"] == "system" + assert "current date and time" in msgs[0]["content"].lower() + assert msgs[1:] == [{"role": "user", "content": "Hallo"}] diff --git a/tests/test_search_backstop.py b/tests/test_search_backstop.py index b559d69..9b616ab 100644 --- a/tests/test_search_backstop.py +++ b/tests/test_search_backstop.py @@ -17,6 +17,13 @@ FORCE = [ "Lebt Heino noch?", "Is Brigitte Bardot still alive?", "Leeft Willeke Alberti nog?", + # schedule_hours / Logistik + "Hat die Arztpraxis heute Nachmittag noch geöffnet?", + "Welche Apotheke hat heute Nacht Notdienst?", + "What are the opening hours of the British Museum?", + "Wann fährt der nächste Bus ins Zentrum?", + "Wanneer vertrekt de volgende trein naar Utrecht?", + "Wann geht der nächste Zug nach München?", ] NO_FORCE = [