openher / engine /genome /critic.py
kellyxiaowei's picture
Deploy OpenHer Gradio Space — gemma-4-E4B served on Modal
dff25f7 verified
Raw
History Blame Contribute Delete
10.6 kB
"""
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)