from datetime import datetime from typing import List from openai import OpenAI from db_user import _load_history, supabase from config import OPENAI_CLASSIFIER_MODEL from util_llm import safe_parse_json import os # Set default model MODEL = OPENAI_CLASSIFIER_MODEL client = OpenAI(api_key=os.getenv("OPENAI_API_KEY")) GENERIC_TOPICS = { "Politics": "Domestic or international political parties, leaders, elections, government decisions", "Government & Elections": "Policies, governance structures, elections at any level", "Law & Justice": "Courts, legislation, police, justice system", "Economy & Finance": "Markets, inflation, trade, personal finance, banking", "International Relations / Geopolitics": "Diplomacy, treaties, conflicts between states", "War & Conflicts": "Armed conflicts, military actions, peace negotiations", "Environment & Climate": "Climate change, natural disasters, conservation, sustainability", "Energy & Sustainability": "Oil, gas, renewables, energy transition", "Science & Research": "Discoveries, academic research, biology, physics", "Technology & Innovation": "Software, AI, internet, digital tools", "Space & Astronomy": "Space missions, astronomy, astrophysics", "Arts & Culture": "Painting, theatre, museums, cultural heritage", "Music": "Music industry, artists, concerts, releases", "Movies & TV": "Cinema, TV shows, streaming", "Literature & Philosophy": "Books, philosophy, critical thought", "Food & Cooking": "Recipes, gastronomy, culinary culture", "Travel & Tourism": "Destinations, flights, travel trends", "Soccer / Football": "Football clubs, players, tournaments, matches", "Basketball": "Basketball leagues, players, tournaments", "Tennis": "Tennis players, tournaments, competitions", "Other Sports": "Rugby, cricket, athletics, swimming, etc.", "Olympics": "Olympic Games and preparation", "Business & Corporations": "Companies, industries, corporate moves", "Jobs & Employment": "Career, labour market, unemployment", "Real Estate & Housing": "Housing markets, property, mortgages", "Health Industry": "Healthcare sector, pharma, biotech", "Education": "Schools, universities, education policies", "Health & Medicine": "Personal health, medical research, treatments", "Social Issues": "Inequality, migration, human rights", "Religion & Spirituality": "Religions, spiritual practices, rituals", "Celebrity & Entertainment": "Celebrities, influencers, entertainment news" } def query_llm_rewrite_only(prompt: str, model: str = MODEL) -> str: """Minimal wrapper for LLM calls.""" response = client.chat.completions.create( model=model, messages=[{"role": "user", "content": prompt}], temperature=0 ) return response.choices[0].message.content.strip() def extract_topics_from_history(user_id: str = None, max_topics: int = 3) -> list[dict]: """Classify topics from the *latest* chat session as a whole.""" data = _load_history("chat_history_short", user_id) sessions = data.get("sessions", []) if not sessions: return [] msgs = [] last_session = sessions[-1] for m in last_session.get("messages", []): if isinstance(m, dict) and m.get("content"): msgs.append(m["content"]) combined = "\n".join(msgs[-20:]) # last 20 msgs for context # call classifier once on the whole session classification = classify_topic_with_llm(combined) # make sure the result is always a list if isinstance(classification, dict): return [classification] elif isinstance(classification, list): return classification[:max_topics] return [] def update_topic_log(user_id: str = None) -> dict: """ Update both generic and specific topic logs for the latest session. - Generic topics: increment only in predefined taxonomy - Specific topics: insert new or merge aliases Returns top 2 generic + top 2 specific topics """ ensure_generic_topics(user_id) # --- Extract topics from recent history --- candidates = extract_topics_from_history(user_id=user_id) now = datetime.utcnow().isoformat() for classification in candidates: generic = classification.get("generic_topic") specific = classification.get("specific_topic") aliases = classification.get("aliases", []) # --- Update generic table --- if generic: update_generic_topic(user_id, generic, now) # --- Update specific table --- if specific: update_specific_topic(user_id, specific, parent_generic=generic, aliases=aliases, now=now) # --- Get top 2 generic topics --- resp_gen = ( supabase.table("topic_log_generic") .select("*") .eq("user_id", user_id) .order("count", desc=True) .order("last_discussed", desc=True) .limit(2) .execute() ) top_generic = [row["topic"] for row in resp_gen.data] # --- Get top 2 specific topics --- resp_spec = ( supabase.table("topic_log_specific") .select("*") .eq("user_id", user_id) .order("count", desc=True) .order("last_discussed", desc=True) .limit(2) .execute() ) top_specific = [row["topic"] for row in resp_spec.data] return {"generic": top_generic, "specific": top_specific} def classify_topic_with_llm(text: str, model: str = MODEL, max_topics: int = 3) -> list[dict]: """ Classify a text snippet (whole session) into up to N topics. Each topic has: - generic_topic: one of the predefined taxonomy - specific_topic: optional entity or concept (e.g. AS Roma, Stoicism) - aliases: list of synonyms/variants for the specific topic """ taxonomy = "\n".join([f"- {k}: {v}" for k, v in GENERIC_TOPICS.items()]) prompt = f""" You are a topic classifier. TASK: - Analyze the following text (represents a full chat session). - Extract up to {max_topics} distinct topics discussed. - For each topic return: 1. generic_topic: one of the predefined taxonomy below 2. specific_topic: optional team, person, place, or concept (null if none) 3. aliases: list of synonyms/variants/alternative names for the specific topic GENERIC TAXONOMY: {taxonomy} RULES: - generic_topic MUST be exactly one of the predefined categories. - aliases must always include the phrase mentioned in the text. - If no specific entity is present, set specific_topic=null and aliases=[]. - Return ONLY a JSON array of objects, each object with keys: ["generic_topic", "specific_topic", "aliases"] TEXT: {text} """ raw = query_llm_rewrite_only(prompt, model=model) try: parsed = safe_parse_json(raw) if isinstance(parsed, list): return parsed[:max_topics] elif isinstance(parsed, dict): return [parsed] except Exception: # fallback: assume unknown generic return [{"generic_topic": "Other", "specific_topic": None, "aliases": []}] def update_specific_topic(user_id: str, specific: str, parent_generic: str, aliases: list[str], now: str): """ Upsert a specific topic linked to a generic parent. - If the topic or one of its aliases already exists → update count, merge aliases. - Otherwise insert as a new specific topic. """ # 1. Fetch all existing specific topics for this user resp = supabase.table("topic_log_specific").select("*").eq("user_id", user_id).execute() existing_topics = resp.data or [] # 2. Try to find a match by topic or alias matched_row = None for row in existing_topics: row_aliases = row.get("aliases", []) or [] if specific == row["topic"] or specific in row_aliases: matched_row = row break if matched_row: # 3. Update existing row current_count = int(matched_row.get("count", 0)) + 1 current_aliases = set(matched_row.get("aliases", []) or []) new_aliases = set(aliases or []) merged_aliases = list(current_aliases.union(new_aliases)) supabase.table("topic_log_specific").update({ "count": current_count, "last_discussed": now, "aliases": merged_aliases }).eq("user_id", user_id).eq("topic", matched_row["topic"]).execute() else: # 4. Insert new row supabase.table("topic_log_specific").insert({ "user_id": user_id, "topic": specific, "aliases": aliases or [specific], "parent_generic": parent_generic, "count": 1, "last_discussed": now }).execute() def update_generic_topic(user_id: str, topic: str, now: str): """ Increment count for a predefined generic topic. Assumes rows are already seeded for each user. """ resp = supabase.table("topic_log_generic").select("count").eq("user_id", user_id).eq("topic", topic).execute() if resp.data: current_count = int(resp.data[0]["count"]) supabase.table("topic_log_generic").update({ "count": current_count + 1, "last_discussed": now }).eq("user_id", user_id).eq("topic", topic).execute() else: # Fallback: this should not happen if seeding was done supabase.table("topic_log_generic").insert({ "user_id": user_id, "topic": topic, "count": 1, "last_discussed": now }).execute() def ensure_generic_topics(user_id: str): """ Ensure that all predefined generic topics exist for the user. If missing, insert them with count=0. """ resp = supabase.table("topic_log_generic").select("topic").eq("user_id", user_id).execute() existing = {row["topic"] for row in resp.data} if resp.data else set() missing = [t for t in GENERIC_TOPICS.keys() if t not in existing] if missing: rows = [ {"user_id": user_id, "topic": t, "count": 0, "last_discussed": None} for t in missing ] supabase.table("topic_log_generic").insert(rows).execute() print(f"✅ Seeded {len(missing)} generic topics for user {user_id}") else: print(f"â„šī¸ All generic topics already exist for user {user_id}")