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