""" Chat orchestration service. This is the single place that wires together everything built in the previous layers for one chat turn: agents.router -> classify + retrieve context llm.prompts -> build the right prompt for the route llm.generate -> call the configured LLM provider memory.mem0_client -> pull/store long-term preference memory (logged-in only) history.service -> persist the transcript (logged-in only) Kept separate from app.py so Flask routes stay thin, and so the same logic can be reused by the Gradio UI layer later without going through HTTP. """ import config from agents.router import RouteType, route from history import service as history_service from history.session_manager import resolve_session from llm.generate import generate from llm.prompts import ( build_comparison_prompt, build_followup_prompt, build_reject_prompt, build_single_prompt, ) from logs.logger import get_logger from memory.mem0_client import get_mem0_client from rag.retriever import get_retriever logger = get_logger(__name__) def _build_prompt(decision, mem0_context, recent_messages): if decision.route_type == RouteType.SINGLE_TOPIC: return build_single_prompt( decision.single_chunks, mem0_context, recent_messages, decision.query ) if decision.route_type == RouteType.COMPARISON: return build_comparison_prompt( decision.comparison_context, mem0_context, recent_messages, decision.query ) if decision.route_type == RouteType.FOLLOWUP: return build_followup_prompt( decision.followup_context, mem0_context, recent_messages, decision.query ) # OUT_OF_SCOPE return build_reject_prompt(decision.query) def handle_chat_message( query: str, user=None, session_id: int = None, anonymous_history: list = None, ) -> dict: """ Args: query: the user's message. user: the authenticated auth.models.User, or None if anonymous. session_id: existing chat session id (logged-in users only, optional). anonymous_history: client-managed recent history for anonymous users (no server-side persistence — see spec: "when not logged in, chat works normally, no persistence, no history panel"). Ignored for logged-in users, whose history is read from SQLite instead. Returns: { "response": "...", "route_type": "single_topic" | "comparison" | "followup" | "out_of_scope", "topics": [...], "session_id": int | None, # None for anonymous users } """ is_logged_in = user is not None session_obj = None if is_logged_in: session_obj = resolve_session(user.id, session_id, first_message=query) recent_messages = history_service.get_recent_messages(session_obj.id) else: recent_messages = (anonymous_history or [])[-config.MAX_RECENT_HISTORY_MESSAGES :] retriever = get_retriever() decision = route(query, retriever, recent_messages=recent_messages) mem0_context = [] if is_logged_in: mem0_context = get_mem0_client().get_relevant_context(str(user.id), query) prompt = _build_prompt(decision, mem0_context, recent_messages) response_text = generate(prompt) if is_logged_in: history_service.add_message( session_obj.id, role="user", content=query, route_type=decision.route_type.value, retrieved_topics=decision.topics, ) history_service.add_message( session_obj.id, role="assistant", content=response_text, route_type=decision.route_type.value, ) get_mem0_client().add_interaction(str(user.id), query, response_text) logger.info( "Chat turn complete | user_id=%s | route=%s | topics=%s | session_id=%s", user.id if is_logged_in else None, decision.route_type.value, decision.topics, session_obj.id if session_obj else None, ) return { "response": response_text, "route_type": decision.route_type.value, "topics": decision.topics, "session_id": session_obj.id if session_obj else None, }