feat: schedule_hours-Backstop, zentrale Datum/Uhrzeit, Filler-Timing

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 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-06-30 00:43:08 +02:00
commit b70d6f364a
9 changed files with 80 additions and 19 deletions

View file

@ -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.

25
app/core/clock.py Normal file
View file

@ -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'.")

View file

@ -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)

View file

@ -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),
]

View file

@ -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":

View file

@ -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:

View file

@ -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)

View file

@ -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"}]

View file

@ -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 = [