Socrates_docker / db_topic_logger.py
AlessandroAmodioNGI's picture
refactor: systematic rename of all modules + SQL reorganisation
a5a8164
Raw
History Blame Contribute Delete
10.4 kB
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}")