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

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)