Spaces:
Running
Running
| 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": ""} | |