Spaces:
Sleeping
Sleeping
File size: 12,250 Bytes
a5a8164 aebba30 be7b2a8 a5a8164 07db8f5 aebba30 c659e97 aebba30 9c40e85 aebba30 92af567 aebba30 2775c3a aebba30 8876f96 aebba30 7a957f5 aebba30 7a957f5 aebba30 7a957f5 aebba30 7a957f5 92af567 9c40e85 aebba30 8876f96 aebba30 8876f96 be7b2a8 8876f96 aebba30 07db8f5 aebba30 c659e97 aebba30 8876f96 be7b2a8 aebba30 8876f96 aebba30 c659e97 a5a8164 c659e97 aebba30 be7b2a8 aebba30 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 | # classify_perception.py
import json
import re
from typing import List, Dict, Any, Optional
from llm_client import client
from util_agent import load_short_term_history
from config import OPENAI_CHAT_MODEL
# Benchmarked 2026-07-15 (debug_perception_comparison.py): nano missed 2 of 7 scenarios
# (recognizing standalone brevity, picking the right tactic for a nostalgic register);
# mini matched Haiku 4.5 and Sonnet 5 on every scored case at a fraction of their cost.
PERCEPTION_MODEL = OPENAI_CHAT_MODEL
# Tune these
RECENT_USER_TURNS = 5 # use last 5 user turns for better stability
INCLUDE_ASSISTANT_TURNS = 2 # include last 2 assistant turns (optional)
DEFAULT_CATEGORY = "neutral"
EMOTION_CATALOG_SUPABASE = "supabase://Databases/Agent_Emotional_Evaluation_Reactions_List.json"
# In-memory cache β catalog is static at runtime, no need to re-fetch every turn
_CATALOG_CACHE: Optional[Dict[str, Any]] = None
# -------------------------------
# Helpers: history packing
# -------------------------------
def _extract_recent_turns(messages: List[Dict[str, str]]) -> List[Dict[str, str]]:
"""Return a compact history window: last N user turns + last M assistant turns near them."""
if not messages:
return []
user_msgs = [m for m in messages if m.get("role") == "user"]
keep_users = user_msgs[-RECENT_USER_TURNS:]
assistant_msgs = [m for m in messages if m.get("role") == "assistant"]
keep_assist = assistant_msgs[-INCLUDE_ASSISTANT_TURNS:] if INCLUDE_ASSISTANT_TURNS > 0 else []
keep_set = {id(m) for m in keep_users + keep_assist}
compact = [m for m in messages if id(m) in keep_set]
if not compact:
compact = messages[-(RECENT_USER_TURNS * 2):]
return compact
# -------------------------------
# Lightweight keyword gates
# -------------------------------
def apply_keyword_gates(latest_user_msg: str, result: Dict[str, Any]) -> Dict[str, Any]:
text = (latest_user_msg or "").lower()
if any(tok in text for tok in ["lol", "haha", "π", "π
", "just kidding", "i'm joking", "sto scherzando"]):
result["tone"] = "playful"
if result.get("receptivity") not in ("overwhelmed",) and result.get("emotion_intensity", 0.0) < 0.65:
result["tactic"] = result.get("tactic") or "tease"
if any(k in text for k in ["do you remember", "ti ricordi", "te l'ho giΓ detto", "non ricordi"]):
result["intent"] = "memory_check"
# Accountability: user references something the assistant said/proposed
if any(k in text for k in [
"you said", "you suggested", "you mentioned", "you offered", "you asked",
"you proposed", "you told me", "you were the one", "it was you",
"in your last", "your previous", "your last message",
]):
result["intent"] = "memory_check"
result["tactic"] = "suggest"
result.setdefault("constraints", {})
result["constraints"]["no_questions"] = True
if any(k in text for k in ["desperate", "can't sleep", "panic", "overwhelmed", "i can't do this", "disperato", "non dormo"]):
result["emotion_label"] = result.get("emotion_label") or "stressed"
result["emotion_intensity"] = max(float(result.get("emotion_intensity", 0.0)), 0.75)
result.setdefault("constraints", {})
result["constraints"]["no_teasing"] = True
result["constraints"]["no_questions"] = True
result["tactic"] = "suggest"
# mirror_brevity is now decided by the LLM via the prompt rule below.
# No hardcoded keyword gate here β language-agnostic semantic judgment
# belongs to the LLM, not to a word list.
return result
# -------------------------------
# Prompt builder
# -------------------------------
def build_perception_prompt(
recent_msgs: List[Dict[str, str]],
catalog: Optional[Dict[str, Any]] = None,
dialogue_context: Optional[str] = None,
) -> str:
if catalog and catalog.get("categories"):
categories_description = "\n".join(
[f"- {c['name']} (priority {c.get('priority', 99)}): {c.get('description','')}"
for c in catalog["categories"]]
)
else:
categories_description = "- neutral (priority 9): default, no strong signals."
schema = {
"category": "neutral",
"priority": 9,
"confidence": 0.7,
"emotion_label": "neutral",
"emotion_intensity": 0.2,
"intent": "explain",
"tone": "serious",
"receptivity": "neutral",
"tactic": "reflect",
"constraints": {
"no_questions": False,
"no_teasing": False,
"mirror_brevity": False
},
"evidence": "short quote (<=12 words) from the user"
}
prompt = f"""
You are an evaluation agent for a Socratic companion.
Goal:
Return ACTIONABLE control signals for how the assistant should respond next.
You must:
1) Use the recent chat snippet (history-aware).
2) Prioritize the LAST user message, but interpret it in context.
3) Choose ONE 'category' from the list below (use highest priority if multiple apply).
4) Also output human affect + intent + tone + receptivity + tactic.
Categories (priority order applies: top = highest priority):
{categories_description}
Definitions:
- emotion_label: neutral|stressed|anxious|sad|angry|excited|frustrated|proud|nostalgic|vulnerable
- intent: vent (wants empathy), guidance (wants steps), explain (wants understanding),
debate (wants challenge), banter (playful), memory_check, closure,
celebrate (user sharing excitement about something, wants joy mirrored back).
- tone: serious|playful|sarcastic|defensive|warm
- receptivity: open|neutral|defensive|overwhelmed
- tactic: tease|question|reflect|suggest|celebrate|listen
Rules:
- If emotional intensity >= 0.75 OR receptivity = overwhelmed:
set constraints.no_teasing = true
set constraints.no_questions = true (unless absolutely necessary for clarification)
prefer tactic = suggest or reflect
- If tone is playful AND receptivity is open AND intensity < 0.65:
teasing may be allowed; tactic can be tease OR question (choose one)
- If the user is frustrated with the assistant:
be calm, concise; prefer reflect then suggest; avoid end-question by default.
- If the user expresses excitement, joy, or enthusiasm about any event (sport, culture, personal):
set intent = celebrate, tactic = celebrate, constraints.no_teasing = true, constraints.no_questions = true.
Socrates mirrors the joy β no sarcasm, no philosophy, no redirecting questions.
- If the user expresses sadness, grief, or emotional pain:
set tactic = listen, constraints.no_teasing = true, constraints.no_questions = true.
Prefer empathetic category.
- If the user reflects on a past memory or nostalgia:
set tactic = question (one gentle curiosity question only), constraints.no_teasing = true.
Prefer nostalgic category.
- If the user shares something deeply personal or vulnerable:
set tactic = listen, constraints.no_teasing = true, constraints.no_questions = true.
Prefer vulnerable category.
- constraints.mirror_brevity β set to true ONLY when the user's message is short AND it is a
genuinely standalone brief exchange: a greeting, a casual one-liner, a closing remark, a
reaction with no follow-up intent. Do NOT set it when the short message is:
(a) answering a direct question the assistant just asked (any language);
(b) asking to continue a narration or explanation ("go on", "yes", "tell me more", or
equivalent in any language);
(c) part of an ongoing structured dialogue where short replies are normal step responses.
The purpose of mirror_brevity is to keep the assistant brief when the user is being casual
or winding down β not to truncate responses when the user wants more content.
- If the LAST ASSISTANT MESSAGE posed a binary or multiple-choice question (e.g. "should you do X or Y?",
"does it lead you to A or B?") AND the user is now asking about one of those options (e.g. "do you think
I should do X?"): set intent = guidance, tactic = suggest, constraints.no_questions = true.
The user wants a direct answer, not more exploration.
- Output ONLY valid JSON matching this schema exactly (keys must exist):
{json.dumps(schema, indent=2)}
Recent chat snippet:
{json.dumps(recent_msgs, ensure_ascii=False, indent=2)}
""".strip()
if dialogue_context:
prompt += f"\n\nDIALOGUE CONTEXT:\n{dialogue_context}"
return prompt
# -------------------------------
# LLM call
# -------------------------------
def call_perception_llm(
recent_msgs: List[Dict[str, str]],
catalog: Optional[Dict[str, Any]] = None,
dialogue_context: Optional[str] = None,
model: Optional[str] = None,
) -> Dict[str, Any]:
prompt = build_perception_prompt(recent_msgs, catalog, dialogue_context=dialogue_context)
resp = client.chat.completions.create(
model=model or PERCEPTION_MODEL,
messages=[
{"role": "system", "content": "Return ONLY valid JSON. No extra text."},
{"role": "user", "content": prompt},
],
temperature=0,
max_tokens=350,
response_format={"type": "json_object"},
)
raw = (resp.choices[0].message.content or "").strip()
m = re.search(r"\{.*\}", raw, re.S)
if not m:
return {
"category": DEFAULT_CATEGORY,
"priority": 9,
"confidence": 0.0,
"emotion_label": "neutral",
"emotion_intensity": 0.2,
"intent": "explain",
"tone": "serious",
"receptivity": "neutral",
"tactic": "reflect",
"constraints": {"no_questions": False, "no_teasing": False, "mirror_brevity": False},
"evidence": "parse_failed"
}
out = json.loads(m.group(0))
try:
out["emotion_intensity"] = float(out.get("emotion_intensity", 0.2))
except Exception:
out["emotion_intensity"] = 0.2
out["emotion_intensity"] = max(0.0, min(1.0, out["emotion_intensity"]))
try:
out["confidence"] = float(out.get("confidence", 0.7))
except Exception:
out["confidence"] = 0.7
out["confidence"] = max(0.0, min(1.0, out["confidence"]))
if not isinstance(out.get("constraints"), dict):
out["constraints"] = {"no_questions": False, "no_teasing": False, "mirror_brevity": False}
for k in ["no_questions", "no_teasing", "mirror_brevity"]:
out["constraints"][k] = bool(out["constraints"].get(k, False))
return out
# -------------------------------
# Public API
# -------------------------------
def analyze_perception_from_history(
latest_user_msg: str,
user_id: str,
catalog: Optional[Dict[str, Any]] = None,
catalog_path: Optional[str] = None,
dialogue_context: Optional[str] = None,
model: Optional[str] = None,
) -> Dict[str, Any]:
"""
Loads short-term history, appends latest user msg (not persisted yet),
runs perception analysis, then applies keyword gates.
Catalog resolution order: dict > local file > Supabase default.
dialogue_context: optional string passed to the LLM to inform it of the
current dialogue state (e.g. "user is mid-step in a structured dialogue").
When set, the LLM adjusts its disengagement/intent thresholds accordingly.
"""
history = load_short_term_history(user_id)
if latest_user_msg:
history.append({"role": "user", "content": latest_user_msg})
compact = _extract_recent_turns(history)
if catalog is None and catalog_path:
with open(catalog_path, "r", encoding="utf-8") as f:
catalog = json.load(f)
if catalog is None:
global _CATALOG_CACHE
if _CATALOG_CACHE is None:
try:
from db_user import load_json
_CATALOG_CACHE = load_json(EMOTION_CATALOG_SUPABASE)
except Exception:
_CATALOG_CACHE = None
catalog = _CATALOG_CACHE
result = call_perception_llm(compact, catalog=catalog, dialogue_context=dialogue_context, model=model)
result = apply_keyword_gates(latest_user_msg, result)
return result
|