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:
parent
48a79da12f
commit
b70d6f364a
9 changed files with 80 additions and 19 deletions
25
app/core/clock.py
Normal file
25
app/core/clock.py
Normal 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'.")
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue