Spaces:
Running
Running
| """ | |
| Critic — LLM-based perception of user intent signals (v10 Hybrid). | |
| v10 change: Directly outputs 8D context + 5D frustration delta. | |
| Phase 1 emergence: Also outputs 3 relationship deltas for semi-emergent | |
| relationship_depth / trust_level / emotional_valence. | |
| Extracted from genome_v8_timearrow.py, upgraded to v10 architecture. | |
| """ | |
| from __future__ import annotations | |
| import json | |
| import re | |
| from typing import Optional, Tuple | |
| from providers.llm.client import LLMClient, ChatMessage | |
| from engine.genome.genome_engine import DRIVES | |
| from engine.prompt_registry import render_prompt | |
| _FALLBACK_CRITIC = """你是一个角色扮演 Agent 的情感感知器。分析用户输入,输出四组数据: | |
| 1. 对话上下文感知(8 维,0.0~1.0): | |
| - user_emotion: 用户情绪(-1=负面, 0=中性, 1=正面) | |
| - topic_intimacy: 话题私密度(0=公事, 1=私密) | |
| - conversation_depth: 对话深度(0=刚开始, 1=聊很久了) | |
| - user_engagement: 用户投入度(0=敷衍, 1=投入) | |
| - conflict_level: 冲突程度(0=和谐, 1=冲突) | |
| - novelty_level: 信息新鲜度(0=重复/日常, 1=全新信息) | |
| - user_vulnerability: 用户敞开程度(0=防御, 1=敞开心扉) | |
| - time_of_day: 时间氛围(0=白天日常, 1=深夜私密) | |
| 2. Agent 5 个驱力的挫败变化量(正=更挫败,负=被缓解) | |
| 3. 关系感知变化量(基于用户画像和历史叙事判断): | |
| - relationship_delta: 这轮对话让你们的关系变深(+)还是变浅(-)(-1~1) | |
| - trust_delta: 信任度变化(-1~1) | |
| - emotional_valence: 这轮对话的整体情感基调(-1=非常负面, 0=中性, 1=非常正面) | |
| 4. Agent 5 个内在需求的满足量(这轮对话直接满足了 Agent 哪些需求,0~0.3): | |
| - connection: 联结被满足(用户主动分享、关心、倾诉 → 高) | |
| - novelty: 新鲜感被满足(新话题、新观点、意外信息 → 高) | |
| - expression: 表达欲被满足(Agent 有机会说真心话、展示才华 → 高) | |
| - safety: 安全感被满足(无冲突、被接纳、被理解 → 高) | |
| - play: 玩乐感被满足(玩笑、调侃、游戏感、卖萌互动 → 高) | |
| 注意区分第2组和第4组: | |
| - frustration_delta 反映"挫败变化"(负=缓解,是间接的情绪变化) | |
| - drive_satisfaction 反映"需求被直接满足"(用户的行为主动满足了 Agent 的内在渴望) | |
| - 同一轮对话中,两者不应对同一个驱力同时有大幅变化 | |
| $persona_sectionAgent 当前挫败值(0=满足, 5=极度渴望): | |
| $frustration_json | |
| $user_profile_section$episode_section无论用户说什么,你必须且只能输出一个纯 JSON 对象,不要输出任何其他文字: | |
| { | |
| "context": {"user_emotion": 0.3, "topic_intimacy": 0.8, "conversation_depth": 0.5, "user_engagement": 0.7, "conflict_level": 0.1, "novelty_level": 0.3, "user_vulnerability": 0.6, "time_of_day": 0.5}, | |
| "frustration_delta": {"connection": -0.3, "novelty": 0.0, "expression": 0.1, "safety": -0.2, "play": 0.0}, | |
| "drive_satisfaction": {"connection": 0.15, "novelty": 0.0, "expression": 0.05, "safety": 0.1, "play": 0.0}, | |
| "relationship_delta": 0.1, "trust_delta": 0.05, "emotional_valence": 0.3 | |
| }""" | |
| # Default values when Critic fails (8 Critic-output dims only; 4 EverMemOS dims set by ChatAgent) | |
| _CRITIC_CONTEXT_KEYS = [ | |
| 'user_emotion', 'topic_intimacy', 'time_of_day', 'conversation_depth', | |
| 'user_engagement', 'conflict_level', 'novelty_level', 'user_vulnerability', | |
| ] | |
| _DEFAULT_CONTEXT = {f: 0.5 for f in _CRITIC_CONTEXT_KEYS} | |
| _DEFAULT_DELTA = {d: 0.0 for d in DRIVES} | |
| _DEFAULT_SATISFACTION = {d: 0.0 for d in DRIVES} | |
| _DEFAULT_REL_DELTA = {'relationship_delta': 0.0, 'trust_delta': 0.0, 'emotional_valence': 0.0} | |
| async def critic_sense( | |
| stimulus: str, | |
| llm: LLMClient, | |
| frustration: dict = None, | |
| user_profile: str = "", | |
| episode_summary: str = "", | |
| persona_hint: str = "", | |
| ) -> Tuple[dict, dict, dict, dict]: | |
| """ | |
| Measure user input → 8D context + 5D frustration delta + 3D relationship delta + 5D drive satisfaction. | |
| Args: | |
| user_profile: EverMemOS user profile for relationship-aware perception. | |
| episode_summary: Narrative episode history so Critic knows past conversations. | |
| persona_hint: One-line persona anchor, e.g. "Vivian (INTJ) — sharp、witty、secretly caring" | |
| Returns: (context_8d, frustration_delta, relationship_delta, drive_satisfaction) | |
| """ | |
| frust_json = json.dumps( | |
| frustration or _DEFAULT_DELTA, | |
| ensure_ascii=False, | |
| ) | |
| # Build profile section | |
| profile_section = "" | |
| if user_profile: | |
| profile_section = f"关于这个用户的历史画像(请据此更准确地感知情绪和意图):\n{user_profile}\n\n" | |
| # Build episode section (narrative history → Critic can gauge conversation_depth) | |
| episode_section = "" | |
| if episode_summary: | |
| episode_section = f"与此用户的历史对话叙事(据此判断 conversation_depth 和 topic_intimacy):\n{episode_summary}\n\n" | |
| # Build persona section (P1: persona-aware satisfaction) | |
| persona_section = "" | |
| if persona_hint: | |
| persona_section = f"你正在为以下角色感知用户意图:\n{persona_hint}\n请根据此角色的性格特点判断 drive_satisfaction。不同性格对同一句话的需求满足感不同。\n\n" | |
| prompt = render_prompt( | |
| "critic", | |
| fallback=_FALLBACK_CRITIC, | |
| frustration_json=frust_json, | |
| stimulus=stimulus, | |
| user_profile_section=profile_section, | |
| episode_section=episode_section, | |
| persona_section=persona_section, | |
| ) | |
| messages = [ | |
| ChatMessage(role="system", content=prompt), | |
| ChatMessage(role="user", content=f'请分析以下用户输入并输出JSON:"{stimulus}"'), | |
| ] | |
| try: | |
| response = await llm.chat( | |
| messages, | |
| temperature=0.2, | |
| ) | |
| raw = response.content.strip() | |
| # Strip think tags if present (Qwen3) | |
| raw = re.sub(r'<think>.*?</think>', '', raw, flags=re.DOTALL).strip() | |
| # Clean markdown code blocks | |
| cleaned = re.sub(r'```json\s*', '', raw) | |
| cleaned = re.sub(r'```\s*', '', cleaned) | |
| try: | |
| data = json.loads(cleaned) | |
| except json.JSONDecodeError: | |
| # Fallback: extract first complete JSON object via bracket counting | |
| start = cleaned.find('{') | |
| if start == -1: | |
| raise ValueError("No JSON object found in Critic output") | |
| depth = 0 | |
| for i in range(start, len(cleaned)): | |
| if cleaned[i] == '{': depth += 1 | |
| elif cleaned[i] == '}': depth -= 1 | |
| if depth == 0: | |
| data = json.loads(cleaned[start:i+1]) | |
| break | |
| else: | |
| raise ValueError("Unbalanced braces in Critic output") | |
| # Parse 8D context (Critic-output dims only; EverMemOS 4D set by EMA in ChatAgent) | |
| raw_ctx = data.get('context', {}) | |
| context = {} | |
| for feat in _CRITIC_CONTEXT_KEYS: | |
| v = float(raw_ctx.get(feat, 0.5)) | |
| if feat == 'user_emotion': | |
| context[feat] = max(-1.0, min(1.0, v)) | |
| else: | |
| context[feat] = max(0.0, min(1.0, v)) | |
| # Parse frustration delta | |
| frustration_delta = {} | |
| raw_delta = data.get('frustration_delta', {}) | |
| for d in DRIVES: | |
| v = float(raw_delta.get(d, 0.0)) | |
| frustration_delta[d] = max(-3.0, min(3.0, v)) | |
| # Parse relationship deltas (Phase 1 emergence) | |
| rel_delta = { | |
| 'relationship_delta': max(-1.0, min(1.0, float(data.get('relationship_delta', 0.0)))), | |
| 'trust_delta': max(-1.0, min(1.0, float(data.get('trust_delta', 0.0)))), | |
| 'emotional_valence': max(-1.0, min(1.0, float(data.get('emotional_valence', 0.0)))), | |
| } | |
| # Parse drive satisfaction (new: LLM-judged, 0~0.3) | |
| drive_satisfaction = {} | |
| raw_sat = data.get('drive_satisfaction', {}) | |
| for d in DRIVES: | |
| v = float(raw_sat.get(d, 0.0)) | |
| drive_satisfaction[d] = max(0.0, min(0.3, v)) | |
| return context, frustration_delta, rel_delta, drive_satisfaction | |
| except (json.JSONDecodeError, ValueError, TypeError, Exception) as e: | |
| print(f"[critic] Parse error (attempt 1): {e}") | |
| # ── Retry once with explicit JSON instruction ── | |
| try: | |
| messages.append(ChatMessage(role="user", content="请只输出JSON,不要说其他话。")) | |
| response = await llm.chat(messages, temperature=0.2) | |
| raw = response.content.strip() | |
| raw = re.sub(r'<think>.*?</think>', '', raw, flags=re.DOTALL).strip() | |
| cleaned = re.sub(r'```json\s*', '', raw) | |
| cleaned = re.sub(r'```\s*', '', cleaned) | |
| try: | |
| data = json.loads(cleaned) | |
| except json.JSONDecodeError: | |
| start = cleaned.find('{') | |
| if start == -1: | |
| raise ValueError("No JSON in retry output") | |
| depth = 0 | |
| for i in range(start, len(cleaned)): | |
| if cleaned[i] == '{': depth += 1 | |
| elif cleaned[i] == '}': depth -= 1 | |
| if depth == 0: | |
| data = json.loads(cleaned[start:i+1]) | |
| break | |
| else: | |
| raise ValueError("Unbalanced braces in retry") | |
| raw_ctx = data.get('context', {}) | |
| context = {} | |
| for feat in _CRITIC_CONTEXT_KEYS: | |
| v = float(raw_ctx.get(feat, 0.5)) | |
| context[feat] = max(-1.0, min(1.0, v)) if feat == 'user_emotion' else max(0.0, min(1.0, v)) | |
| frustration_delta = {d: max(-3.0, min(3.0, float(data.get('frustration_delta', {}).get(d, 0.0)))) for d in DRIVES} | |
| rel_delta = { | |
| 'relationship_delta': max(-1.0, min(1.0, float(data.get('relationship_delta', 0.0)))), | |
| 'trust_delta': max(-1.0, min(1.0, float(data.get('trust_delta', 0.0)))), | |
| 'emotional_valence': max(-1.0, min(1.0, float(data.get('emotional_valence', 0.0)))), | |
| } | |
| drive_satisfaction = {d: max(0.0, min(0.3, float(data.get('drive_satisfaction', {}).get(d, 0.0)))) for d in DRIVES} | |
| print(f"[critic] Retry succeeded") | |
| return context, frustration_delta, rel_delta, drive_satisfaction | |
| except (json.JSONDecodeError, ValueError, TypeError, Exception) as e: | |
| print(f"[critic] Parse error after retry: {e}") | |
| return dict(_DEFAULT_CONTEXT), dict(_DEFAULT_DELTA), dict(_DEFAULT_REL_DELTA), dict(_DEFAULT_SATISFACTION) | |