5151# Stores both user and agent messages for the chat panel.
5252_chat_log : deque [dict ] = deque (maxlen = 200 )
5353
54+ # ── Thinking status (broadcast to SSE clients) ───
55+ _thinking_status : str = ""
56+
5457
5558# ── Persistence helpers ───────────────────────────
5659
@@ -464,6 +467,70 @@ class ChatMessage(BaseModel):
464467}
465468
466469
470+ _MONTH_NAMES = {
471+ "januar" : "01" , "februar" : "02" , "märz" : "03" , "april" : "04" ,
472+ "mai" : "05" , "juni" : "06" , "juli" : "07" , "august" : "08" ,
473+ "september" : "09" , "oktober" : "10" , "november" : "11" , "dezember" : "12" ,
474+ }
475+
476+
477+ def _extract_date (text : str ) -> str :
478+ """Extract a date from user text. Returns YYYY-MM-DD or DD.MM.YYYY or empty string."""
479+ # Match DD.MM or DD.MM.YYYY
480+ m = re .search (r'(\d{1,2})\.(\d{1,2})(?:\.(\d{4}))?' , text )
481+ if m :
482+ day = int (m .group (1 ))
483+ month = int (m .group (2 ))
484+ year = int (m .group (3 )) if m .group (3 ) else 2026
485+ return f"{ year } -{ month :02d} -{ day :02d} "
486+
487+ # Match "DD. Monat" or "DD Monat"
488+ m = re .search (r'(\d{1,2})\.?\s+(januar|februar|märz|april|mai|juni|juli|august|september|oktober|november|dezember)' , text .lower ())
489+ if m :
490+ day = int (m .group (1 ))
491+ month = int (_MONTH_NAMES [m .group (2 )])
492+ return f"2026-{ month :02d} -{ day :02d} "
493+
494+ return ""
495+
496+
497+ def _date_matches (iso_date : str , text : str ) -> bool :
498+ """Check if an ISO date (YYYY-MM-DD) matches a date in text (various formats)."""
499+ try :
500+ from datetime import datetime as dt
501+ parsed = dt .strptime (iso_date , "%Y-%m-%d" )
502+ # DD.MM.YYYY format
503+ if parsed .strftime ("%d.%m.%Y" ) in text :
504+ return True
505+ # German month name
506+ months_de = ["Januar" , "Februar" , "März" , "April" , "Mai" , "Juni" ,
507+ "Juli" , "August" , "September" , "Oktober" , "November" , "Dezember" ]
508+ german = f"{ parsed .day } . { months_de [parsed .month - 1 ]} "
509+ if german in text :
510+ return True
511+ except (ValueError , IndexError ):
512+ pass
513+ return False
514+
515+
516+ def _find_portal (text : str , config ) -> str | None :
517+ """Find a portal name mentioned in text, with partial matching."""
518+ lower = text .lower ()
519+ # Exact match first
520+ for portal in config .portale :
521+ if portal .name .lower () in lower :
522+ return portal .name
523+ # Partial match: split portal name on spaces/hyphens and check core parts
524+ for portal in config .portale :
525+ parts = re .split (r'[\s\-]+' , portal .name .lower ())
526+ # Match if any distinctive part (>3 chars, not generic) appears
527+ generic = {"fest" , "zelt" , "festzelt" , "wiesn" }
528+ for part in parts :
529+ if len (part ) > 3 and part not in generic and part in lower :
530+ return portal .name
531+ return None
532+
533+
467534def _classify_intent (text : str ) -> str :
468535 """Classify user message into an intent. Returns intent name or 'unknown'."""
469536 lower = text .lower ().strip ()
@@ -534,14 +601,11 @@ async def post_chat(body: ChatMessage):
534601 }
535602
536603 def _on_tool_progress (tool_name : str , tool_args : dict ) -> None :
604+ global _thinking_status
537605 portal = tool_args .get ("portal_name" ) or tool_args .get ("name" ) or ""
538606 label = _TOOL_LABELS .get (tool_name , tool_name )
539607 detail = f" — { portal } " if portal else ""
540- _chat_log .append ({
541- "timestamp" : datetime .now ().isoformat (),
542- "role" : "thinking" ,
543- "message" : f"{ label } { detail } " ,
544- })
608+ _thinking_status = f"{ label } { detail } "
545609
546610 history = list (_chat_log )[:- 1 ] # exclude current message (already in prompt)
547611 reply_text = await llm_chat (
@@ -550,9 +614,8 @@ def _on_tool_progress(tool_name: str, tool_args: dict) -> None:
550614 on_progress = _on_tool_progress ,
551615 )
552616
553- # Remove thinking entries before adding final reply
554- while _chat_log and _chat_log [- 1 ].get ("role" ) == "thinking" :
555- _chat_log .pop ()
617+ # Clear thinking status
618+ _thinking_status = ""
556619
557620 reply = _chat_reply (reply_text )
558621 return {"user" : user_entry , "reply" : reply }
@@ -563,18 +626,78 @@ def _on_tool_progress(tool_name: str, tool_args: dict) -> None:
563626 logger .warning ("LLM chat error, falling back to keywords: %s" , e , exc_info = True )
564627
565628 # ── Keyword fallback ──────────────────────────
629+ # First, check for date/portal mentions (more specific than keyword intents)
630+ snapshots = load_snapshots ()
631+ config = _load_config ()
632+ mentioned_date = _extract_date (text )
633+ mentioned_portal = _find_portal (text , config )
634+
635+ if mentioned_portal and mentioned_date :
636+ snap = snapshots .get (mentioned_portal )
637+ has_date = False
638+ if snap :
639+ for d in snap .datum_options :
640+ val = d .get ("value" , d .get ("text" , "" ))
641+ txt = d .get ("text" , d .get ("value" , "" ))
642+ if mentioned_date in val or mentioned_date in txt or _date_matches (mentioned_date , txt ):
643+ has_date = True
644+ break
645+ if has_date :
646+ reply = _chat_reply (
647+ f"**{ mentioned_portal } ** hat den **{ mentioned_date } ** als auswählbares Datum. "
648+ f"Abend-Slots sind nicht bestätigt (dafür ist ein Deep-Scan nötig)."
649+ )
650+ else :
651+ reply = _chat_reply (f"**{ mentioned_portal } ** hat den **{ mentioned_date } ** leider **nicht** verfügbar." )
652+ return {"user" : user_entry , "reply" : reply }
653+
654+ if mentioned_date :
655+ with_date = []
656+ without_date = []
657+ for name , snap in snapshots .items ():
658+ found = False
659+ for d in snap .datum_options :
660+ val = d .get ("value" , d .get ("text" , "" ))
661+ txt = d .get ("text" , d .get ("value" , "" ))
662+ if mentioned_date in val or mentioned_date in txt or _date_matches (mentioned_date , txt ):
663+ found = True
664+ break
665+ if found :
666+ with_date .append (name )
667+ else :
668+ without_date .append (name )
669+
670+ if with_date :
671+ reply = _chat_reply (
672+ f"**{ len (with_date )} ** Zelte haben den **{ mentioned_date } ** als auswählbares Datum: "
673+ f"{ ', ' .join (with_date )} .\n \n "
674+ f"**{ len (without_date )} ** Zelte haben diesen Tag nicht."
675+ )
676+ else :
677+ reply = _chat_reply (f"Kein Zelt hat den **{ mentioned_date } ** verfügbar." )
678+ return {"user" : user_entry , "reply" : reply }
679+
680+ if mentioned_portal :
681+ snap = snapshots .get (mentioned_portal )
682+ if snap and snap .datum_options :
683+ dates = [d .get ("text" , d .get ("value" , "" )) for d in snap .datum_options ]
684+ reply = _chat_reply (
685+ f"**{ mentioned_portal } ** hat **{ len (dates )} ** auswählbare Termine:\n "
686+ + ", " .join (dates )
687+ )
688+ elif snap :
689+ reply = _chat_reply (f"**{ mentioned_portal } ** hat aktuell **keine** verfügbaren Termine." )
690+ else :
691+ reply = _chat_reply (f"**{ mentioned_portal } ** wurde noch nicht gescannt." )
692+ return {"user" : user_entry , "reply" : reply }
693+
694+ # Fall back to keyword classification for generic intents
566695 intent = _classify_intent (text )
567696
568697 # ── Intent: Scan ──────────────────────────────
569698 if intent == "scan" :
570699 config = _load_config ()
571- # Check if a specific portal is mentioned
572- target_portal = None
573- lower = text .lower ()
574- for portal in config .portale :
575- if portal .name .lower () in lower :
576- target_portal = portal .name
577- break
700+ target_portal = _find_portal (text , config )
578701
579702 if target_portal :
580703 reply = _chat_reply (f"Starting scan for **{ target_portal } **..." )
@@ -652,7 +775,7 @@ def _on_tool_progress(tool_name: str, tool_args: dict) -> None:
652775 reply = _chat_reply ("Portals:\n " + "\n " .join (lines ))
653776 return {"user" : user_entry , "reply" : reply }
654777
655- # ── Default: unrecognized → show help ─────── ──
778+ # ── Truly unrecognized → show help ──
656779 reply = _chat_reply (
657780 "I didn't quite catch that. Here's what I can help with:\n "
658781 "- **scan** — Start scanning portals\n "
@@ -692,10 +815,15 @@ async def event_generator():
692815 # Use a snapshot of the current deque to avoid index race conditions
693816 # when items are evicted from the maxlen ring buffer.
694817 last_seen = len (list (_chat_log ))
818+ prev_thinking = ""
695819 yield f"data: { json .dumps ({'type' : 'connected' , 'count' : last_seen })} \n \n "
696820 while True :
697821 if await request .is_disconnected ():
698822 break
823+ # Broadcast thinking status changes immediately
824+ if _thinking_status != prev_thinking :
825+ prev_thinking = _thinking_status
826+ yield f"data: { json .dumps ({'role' : 'thinking' , 'message' : _thinking_status })} \n \n "
699827 snapshot = list (_chat_log )
700828 if len (snapshot ) > last_seen :
701829 for item in snapshot [last_seen :]:
@@ -704,7 +832,7 @@ async def event_generator():
704832 elif len (snapshot ) < last_seen :
705833 # Buffer wrapped — reset
706834 last_seen = len (snapshot )
707- await asyncio .sleep (0.5 )
835+ await asyncio .sleep (0.3 )
708836
709837 return StreamingResponse (event_generator (), media_type = "text/event-stream" )
710838
0 commit comments