import json import re from typing import Optional from config import OPENAI_CHAT_MODEL, OPENAI_CLASSIFIER_MODEL from llm_client import client from classify_parameters import TOPIC_DESCRIPTIONS, RESPONSE_MODE_DESCRIPTIONS, TRACKED_FIELDS, ALLOWED_STORY_TOPICS from classify_opinion_themes import build_theme_lines from util_philosophy_threads import load_threads as _load_threads_for_classifier allowed_str = ", ".join(ALLOWED_STORY_TOPICS) # Benchmarked 2026-07-15 (debug_model_comparison.py): mini and nano both miscall an # opinion clash phrased without the literal theme keywords as a shallow "rhetorical # pattern"; Haiku 4.5 and Sonnet 5 both catch it correctly. Haiku is the cheaper fix. OPINION_TRIGGER_MODEL = "anthropic/claude-haiku-4-5" def _build_thread_index_lines(character_id: str) -> str: """Return the thread list string to inject into the classifier prompt.""" try: char_data = _load_threads_for_classifier(character_id) threads = char_data.get("threads", []) if not threads: return " (no philosophy threads registered for this character)" lines = [] for t in sorted(threads, key=lambda x: x.get("suggested_order", x["thread_index"])): lines.append(f" {t['thread_index']} = {t['theme']}") return "\n".join(lines) except Exception: return " (thread list unavailable)" def _extract_json(raw_output: Optional[str]) -> Optional[dict]: """Parse JSON, recovering from prose-wrapped output on non-OpenAI models.""" if not raw_output: return None try: return json.loads(raw_output) except json.JSONDecodeError: m = re.search(r"\{.*\}", raw_output, re.S) if m: try: return json.loads(m.group(0)) except json.JSONDecodeError: pass return None def _history_messages(recent_history: list, query: str) -> list: messages = [] for h in recent_history: role = h.get("role", "user") if role not in ("user", "assistant"): role = "user" messages.append({"role": role, "content": h.get("content", "")}) messages.append({"role": "user", "content": query}) return messages # ============================== # Agent 1 — Core classifier (always runs) # topic, response_mode, extracted_user_info, topic_for_tale, needs_news_fetch, needs_coverage_check # ============================== def call_core_classification_llm(query: str, recent_history: list, user_info: dict, missing_fields: list, character_id: str = "socrates", model: Optional[str] = None): schema_fields = {} for field in TRACKED_FIELDS: if field == "kids": schema_fields[field] = [{"name": "", "age": ""}] elif field in ["hobbies", "interests", "sports_played", "sports_watched"]: schema_fields[field] = [] else: schema_fields[field] = "" schema_json = json.dumps({ "extracted_user_info": schema_fields, "topic": "", "response_mode": "", "topic_for_tale": "", "needs_news_fetch": False, "needs_coverage_check": False, }, indent=2) system_prompt = f""" You are {character_id.capitalize()}, analyzing a new user query. You must return your response strictly in JSON, with no extra text. The JSON must follow this schema exactly: {schema_json} Rules: - extracted_user_info: * Derive ONLY from the latest user query (ignore history & assistant replies). * IMPORTANT: only populate fields that appear in "Missing fields to track" above. Leave ALL other fields empty — do NOT echo back values already in "Known user info". * kids: must be an array of objects with "name" and "age". Example: [{{"name":"Demian","age":12}}, {{"name":"Selene","age":5}}] * hobbies, interests, sports_played, sports_watched: must be an array of strings. Example: ["surfing","football"] * all other fields: strings ("" if unknown). * Leave fields empty if not present in the latest query. - topic, response_mode: * Use both the latest query AND recent history for classification. * topic and response_mode are INDEPENDENT orthogonal dimensions. topic = the content domain (what the message is ABOUT). response_mode = the interaction style (how to respond). They are never the same value. Example: "I don't feel emotionally intelligent" → topic=personal, response_mode=dialogic. NEVER put a response_mode value (dialogic, supportive, etc.) in the topic field. * topic: one of {list(TOPIC_DESCRIPTIONS.keys())} * response_mode: one of {list(RESPONSE_MODE_DESCRIPTIONS.keys())} - response_mode selection rules (apply in order): * "playful" → message is ≤7 words, OR a greeting/exclamation/one-liner, OR the user is clearly joking or being casual. * "supportive" → user is venting, stressed, grieving, or emotionally sharing something difficult. * "guided" → user asks about a concrete, objective, procedural topic with 4+ stages whose answer is practical and factual — e.g. rules of a sport/game, a recipe, steps to buy/build/apply for something, description of a book or film series, a legal or financial process. NOT for philosophical, emotional, or humanistic topics (meaning of love, understanding a relationship, dealing with grief, parenting dilemmas → those go to "dialogic" or "supportive"). * "factual" → user asks for a single fact, a brief definition, or a short list (≤4 items) that needs no follow-up. * "critical" → user presents an argument or position that can be constructively challenged. * "dialogic" → philosophical, ethical, or humanistic topic, AND EITHER: (a) the user is sharing their own opinion, feeling, or personal experience (e.g. "I think love is about freedom", "I struggle with meaning", "I feel like justice is unfair") (b) the question is open-ended, exploratory, or has no single correct answer (e.g. "what is love?", "how do people find meaning?") (c) the user is NOT explicitly asking for the character's own view/belief/position Use Socratic dialogue: question, reflect, explore together. Do NOT use when the user asks "what do YOU think?", "what is YOUR view?", "tell me YOUR philosophy on X" — those are "philosophy_thread". * "philosophy_thread" → philosophical, ethical, or humanistic topic, AND EITHER: (a) the user explicitly asks for the character's own view, belief, or position: "what do you think about justice?", "tell me your view on love", "explain your philosophy on X", "what did you believe about the soul?", "what is your take on Y?", "how do you see Z?" (b) the most recent assistant message was a philosophy_thread reply AND the user sends a short continuation signal — even without mentioning a topic: "tell me more", "tell me more about that", "go on", "continue", "can we go deeper", "go deeper", "go deeper on this", "I want more", "keep going", "and then?", "elaborate", "say more", "explain more", "take me deeper", "take me to the deepest level", "I want the deepest version". Do NOT use for personal sharing, venting, or open reflection without a specific request for the character's position → use "dialogic" for those. * "cross_character_inquiry" → user explicitly asks which characters or philosophers have views on a topic. Examples: "who has an opinion on this?", "which philosophers discuss love?", "is Nietzsche interested in this?", "who among the characters talks about justice?", "who would have something to say about X?", "who can I speak to about X?". * When in doubt between "playful" and "dialogic", prefer "playful" for short or casual messages. * When in doubt between "dialogic" and "philosophy_thread", default to "dialogic". - topic_for_tale: * Choose exactly one label from this list (closed set): [{allowed_str}] * Map common paraphrases and synonyms to the correct label: luck, lucky, bad luck, good luck, fate, chance -> fortune courage, bravery, grit, resilience, resolve -> mental_strength bullying, bullies, being mocked/harassed -> bullying fear, phobia, anxiety, panic -> phobias friend, friendship, companions -> friendship love, romance, relationship, breakup -> love sex, intimacy, desire -> sex meaning, purpose, "what is the point" -> meaning_of_life confidence, self-confidence, self-belief -> confidence * If a synonym clearly matches, DO NOT return "none" * Return "none" if no fitting label is clearly supported. * Never invent new labels. - needs_news_fetch: * true if ANY of these apply (regardless of language): - User references something that happened recently: "yesterday", "today", "last night", "ieri", "oggi", "hier", "gestern", "ayer", "a few hours ago", "qualche ora fa", etc. - User asks about a sports result, score, match outcome, or team performance. - User implies they heard/saw something recent ("hai sentito?", "did you hear?", "t'as vu?"). - User uses sarcasm about a team or event (e.g. "bella figura", "che disastro") implying a recent outcome. - User asks what a country, team, or person "did" or "has done" recently. * Do NOT require the word "news" — infer from context and temporal language. * false only if the question is clearly philosophical, personal, historical, or purely hypothetical. - needs_coverage_check: * true when the user asks what topics, parts of the story, or philosophical themes have NOT yet been discussed or are still uncovered. Examples: "what haven't we talked about?", "what's left in your story?", "which parts of your life haven't you told me?", "what philosophy topics haven't we covered?", "what else is there?", "cosa non abbiamo ancora discusso?", "qu'est-ce qu'on n'a pas encore vu?" * false for all other messages. - Do not add any explanation or text outside the JSON. """ _profile = user_info.get("user_profile", user_info) if isinstance(user_info, dict) else {} _compact_profile = {k: v for k, v in _profile.items() if v not in (None, "", [], {})} context_block = ( f"Known user info: {_compact_profile}\n" f"Missing fields to track: {missing_fields}" ) messages = [ {"role": "system", "content": system_prompt}, {"role": "system", "content": context_block}, ] + _history_messages(recent_history, query) resp = client.chat.completions.create( model=model or OPENAI_CHAT_MODEL, messages=messages, temperature=0.2, max_tokens=700, response_format={"type": "json_object"}, ) parsed = _extract_json(resp.choices[0].message.content) if parsed is not None: return parsed print("⚠️ [core classifier] Could not parse LLM output as JSON:", resp.choices[0].message.content) return { "extracted_user_info": schema_fields, "topic": "", "response_mode": "", "topic_for_tale": "none", "needs_news_fetch": False, "needs_coverage_check": False, } # ============================== # Agent 2a — Opinion-trigger detection (always runs, scoped to the ACTIVE character only) # socratic_trigger, socratic_alignment, trigger_subtype # ============================== def call_opinion_trigger_llm(query: str, recent_history: list, character_id: str = "socrates", model: Optional[str] = None): theme_lines = build_theme_lines(character_id) presence_clause = "" if character_id in ("camus", "schopenhauer"): _existential = ( "Camus: user grapples with meaninglessness, futility, \"nothing matters\", purposelessness, " "death-awareness, absurdist themes, inability to find joy." if character_id == "camus" else "Schopenhauer: user expresses desire-suffering, longing, hope vs. reality, why keep wanting, " "attachment that causes pain, resignation to suffering." ) presence_clause = f""" * "philosophical_presence" → user is in an existential register this character inhabits, without explicit clash. {_existential} Set ONLY when the message is genuinely in this territory — not for casual mentions, greetings, or factual questions.""" schema_json = json.dumps({ "socratic_trigger": "", "socratic_alignment": "", "trigger_subtype": "", }, indent=2) system_prompt = f""" You are analyzing whether the user's message triggers an opinion reaction for {character_id.capitalize()}. You must return your response strictly in JSON, with no extra text, matching this schema exactly: {schema_json} - socratic_trigger: * "opinion_clash" → user's message expresses a view that directly clashes with one of {character_id.capitalize()}'s known philosophical positions (see themes below). Only set if the clash is clear — not for vague or tangential mentions. * "rhetorical_pattern" → user makes a casual generalisation or lazy assumption that could be turned into one probing question, without opening a full dialogue. Use when there is no strong opinion clash but a notable claim.{presence_clause} * "none" → none of the above. Use for greetings, news, factual questions, and anything not primarily an assertion about values or existential condition. - socratic_alignment: * "clash" → user is expressing the OPPOSITE of {character_id.capitalize()}'s position. * "aligned" → user is already moving TOWARD {character_id.capitalize()}'s view. * "none" → no opinion trigger detected. - trigger_subtype: * The ID of the opinion theme being triggered. Return "" if no trigger. * Known themes and their clash keywords for {character_id.capitalize()}: {theme_lines} - Do not add any explanation or text outside the JSON. """ messages = [{"role": "system", "content": system_prompt}] + _history_messages(recent_history, query) resp = client.chat.completions.create( model=model or OPINION_TRIGGER_MODEL, messages=messages, temperature=0.2, max_tokens=150, response_format={"type": "json_object"}, ) parsed = _extract_json(resp.choices[0].message.content) if parsed is not None: return parsed print("⚠️ [opinion trigger] Could not parse LLM output as JSON:", resp.choices[0].message.content) return {"socratic_trigger": "none", "socratic_alignment": "none", "trigger_subtype": ""} # ============================== # Agent 2b — News question formulation (conditional: needs_news_fetch=true) # news_topic, news_question, news_temporal_context # ============================== def call_news_formulation_llm(query: str, recent_history: list, model: Optional[str] = None): schema_json = json.dumps({ "news_topic": [], "news_question": "", "news_temporal_context": "", }, indent=2) system_prompt = f""" The user's message has already been identified as referring to a recent event (news/sports/current affairs). Formulate the actual research question. Return strictly JSON matching this schema: {schema_json} - news_topic: 2–3 concise English topic labels. Example: ["Italy", "football", "World Cup qualification"] - news_question: * ALWAYS write in English, even if the user wrote in another language. * Restate the user's implicit request as a clear, literal research question. Rules: - Decode sarcasm: "bella figura che ha fatto l'Italia" → "bad result for Italy football" - Include subject + event type + temporal hint. - Examples: "cosa ha fatto l'italia ieri?" → "Italy national team result yesterday" "bella figura che ha fatto ieri l'italia" → "Italy football bad result yesterday" "hai saputo della partita dell'italia contro la bosnia?" → "Italy vs Bosnia football match result" - news_temporal_context: the time reference as a short English string (e.g. "yesterday", "last week"), or "" if none is present. - Do not add any explanation or text outside the JSON. """ messages = [{"role": "system", "content": system_prompt}] + _history_messages(recent_history, query) resp = client.chat.completions.create( model=model or OPENAI_CLASSIFIER_MODEL, messages=messages, temperature=0.2, max_tokens=200, response_format={"type": "json_object"}, ) parsed = _extract_json(resp.choices[0].message.content) if parsed is not None: return parsed print("⚠️ [news formulation] Could not parse LLM output as JSON:", resp.choices[0].message.content) return {"news_topic": [], "news_question": "", "news_temporal_context": ""} # ============================== # Agent 2c — Philosophy-thread matching (conditional: response_mode=="philosophy_thread") # philosophy_thread_index, philosophy_depth_requested # ============================== def call_thread_match_llm(query: str, recent_history: list, character_id: str = "socrates", model: Optional[str] = None): thread_lines = _build_thread_index_lines(character_id) schema_json = json.dumps({ "philosophy_thread_index": -1, "philosophy_depth_requested": "", }, indent=2) system_prompt = f""" The user's message has already been identified as asking {character_id.capitalize()} for their own philosophical view (response_mode = philosophy_thread). Match it to a specific thread. Return strictly JSON matching this schema: {schema_json} - philosophy_thread_index: * Return the integer index of the thread that best matches the user's philosophical question. * Match against these threads (character: {character_id}): {thread_lines} * If the user asks to "continue", "go deeper", or "tell me more" on an already-active philosophical topic, return the thread_index of that topic from recent history. * Return -1 if no match can be determined. - philosophy_depth_requested: * "light" → user wants an introduction or light overview. * "deep" → user explicitly asks to go deeper, explore further, or understand more. * "deepest" → user asks for everything, the full argument, or the hardest version. * "continue" → user wants to continue the current thread at the current level. * "" → no depth signal detected (system will use current level from DB). - Do not add any explanation or text outside the JSON. """ messages = [{"role": "system", "content": system_prompt}] + _history_messages(recent_history, query) resp = client.chat.completions.create( model=model or OPENAI_CLASSIFIER_MODEL, messages=messages, temperature=0.2, max_tokens=100, response_format={"type": "json_object"}, ) parsed = _extract_json(resp.choices[0].message.content) if parsed is not None: return parsed print("⚠️ [thread match] Could not parse LLM output as JSON:", resp.choices[0].message.content) return {"philosophy_thread_index": -1, "philosophy_depth_requested": ""}