初始化提交
This commit is contained in:
commit
887c1f449e
963 files changed
+166869
No files matched your search
@@ -0,0 +1 @@
|
||||
"""领域模块:认知对象与上下文组装(见后端架构计划 modules 节)。"""
|
||||
@@ -0,0 +1,19 @@
|
||||
"""通用 Agent 的纯领域契约。"""
|
||||
|
||||
from app.modules.agent.definition import (
|
||||
DEFAULT_CHAT_AGENT,
|
||||
AgentDefinition,
|
||||
ExecutionPolicy,
|
||||
ToolPolicy,
|
||||
)
|
||||
from app.modules.agent.task import AgentTask, Plan, PlanStep
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_CHAT_AGENT",
|
||||
"AgentDefinition",
|
||||
"AgentTask",
|
||||
"ExecutionPolicy",
|
||||
"Plan",
|
||||
"PlanStep",
|
||||
"ToolPolicy",
|
||||
]
|
||||
@@ -0,0 +1,44 @@
|
||||
"""通用 Agent 定义。
|
||||
|
||||
定义保持纯数据;模型调用、工具执行与持久化仍由既有运行时负责。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExecutionPolicy:
|
||||
"""一次 Agent 运行的硬性边界。"""
|
||||
|
||||
max_iterations: int = 8
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.max_iterations < 1:
|
||||
raise ValueError("max_iterations 必须 >= 1")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ToolPolicy:
|
||||
"""本次运行允许暴露给模型的工具名称。空值表示沿用运行时默认工具集。"""
|
||||
|
||||
allowed_tools: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AgentDefinition:
|
||||
"""可组合的 Agent 身份、提示、技能与执行边界。"""
|
||||
|
||||
name: str
|
||||
system_prompt: str
|
||||
model_hint: str | None = None
|
||||
skill_refs: tuple[str, ...] = ()
|
||||
tool_policy: ToolPolicy = field(default_factory=ToolPolicy)
|
||||
execution_policy: ExecutionPolicy = field(default_factory=ExecutionPolicy)
|
||||
|
||||
|
||||
DEFAULT_CHAT_AGENT = AgentDefinition(
|
||||
name="镜师",
|
||||
system_prompt="你是魂镜当前默认人物。请使用中文,保持诚实、克制、清晰。",
|
||||
)
|
||||
@@ -0,0 +1,46 @@
|
||||
"""通用 Agent 的任务与计划纯领域契约。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal
|
||||
|
||||
TaskStatus = Literal["queued", "running", "waiting_confirmation", "completed", "failed", "cancelled"]
|
||||
PlanStatus = Literal["draft", "approved", "running", "completed", "failed"]
|
||||
StepStatus = Literal["pending", "running", "completed", "failed", "skipped"]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PlanStep:
|
||||
"""可追踪的 Agent 计划步骤。"""
|
||||
|
||||
id: str
|
||||
title: str
|
||||
description: str = ""
|
||||
tool: str | None = None
|
||||
risk_level: str = "L2"
|
||||
status: StepStatus = "pending"
|
||||
result_summary: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Plan:
|
||||
"""任务的显式执行计划。"""
|
||||
|
||||
steps: tuple[PlanStep, ...]
|
||||
assumptions: tuple[str, ...] = ()
|
||||
risks: tuple[str, ...] = ()
|
||||
status: PlanStatus = "draft"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AgentTask:
|
||||
"""一次 Agent 执行的目标与可选计划。"""
|
||||
|
||||
id: str
|
||||
objective: str
|
||||
agent_name: str
|
||||
run_id: str | None = None
|
||||
status: TaskStatus = "queued"
|
||||
plan: Plan | None = None
|
||||
constraints: tuple[str, ...] = ()
|
||||
@@ -0,0 +1 @@
|
||||
"""大周天演化候选领域纯逻辑(造消息 / 解析候选草稿,禁止 IO)。"""
|
||||
@@ -0,0 +1,114 @@
|
||||
"""解析模型返回为演化候选草稿(纯函数,无 IO)。
|
||||
|
||||
模型可能返回纯 JSON、markdown 代码块或夹杂解释文本;本模块容错提取首个 JSON
|
||||
数组,并归一化为演化候选草稿。``evidence`` 序号由 application 映射为记忆 ID。
|
||||
|
||||
缺省字段取**安全悲观默认**:``level=L4``(必须人工确认)、``auto_apply_allowed=False``,
|
||||
避免模型漏字段时误自动应用高风险演化。
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class EvolutionCandidateDraft:
|
||||
type: str
|
||||
title: str
|
||||
proposal: str
|
||||
level: str = "L4"
|
||||
scope: str = "character_scope"
|
||||
source: str = "big_cycle"
|
||||
delta: dict[str, Any] = field(default_factory=dict)
|
||||
confidence: float = 0.5
|
||||
benefit_score: float = 0.5
|
||||
risk_score: float = 0.5
|
||||
drift_score: float = 0.0
|
||||
auto_apply_allowed: bool = False
|
||||
evidence: list[int] = field(default_factory=list)
|
||||
|
||||
|
||||
def parse_evolution_candidates(raw: str) -> list[EvolutionCandidateDraft]:
|
||||
drafts: list[EvolutionCandidateDraft] = []
|
||||
for item in _extract_json_array(raw):
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
type_ = str(item.get("type", "")).strip()
|
||||
title = str(item.get("title", "")).strip()
|
||||
proposal = str(item.get("proposal", "")).strip()
|
||||
if not type_ or not title or not proposal:
|
||||
continue
|
||||
drafts.append(
|
||||
EvolutionCandidateDraft(
|
||||
type=type_,
|
||||
title=title,
|
||||
proposal=proposal,
|
||||
level=_level(item.get("level")),
|
||||
scope=_scope(item.get("scope")),
|
||||
source=str(item.get("source", "big_cycle")).strip() or "big_cycle",
|
||||
delta=_dict(item.get("delta")),
|
||||
confidence=_clamp_float(item.get("confidence"), 0.5),
|
||||
benefit_score=_clamp_float(item.get("benefit_score"), 0.5),
|
||||
risk_score=_clamp_float(item.get("risk_score"), 0.5),
|
||||
drift_score=_clamp_float(item.get("drift_score"), 0.0),
|
||||
auto_apply_allowed=bool(item.get("auto_apply_allowed", False)),
|
||||
evidence=_int_list(item.get("evidence")),
|
||||
)
|
||||
)
|
||||
return drafts
|
||||
|
||||
|
||||
def _extract_json_array(raw: str) -> list[Any]:
|
||||
text = raw.strip()
|
||||
matched = re.search(r"```(?:json)?\s*(\[.*?\])\s*```", text, re.DOTALL)
|
||||
if matched:
|
||||
text = matched.group(1)
|
||||
else:
|
||||
start, end = text.find("["), text.rfind("]")
|
||||
if start != -1 and end > start:
|
||||
text = text[start : end + 1]
|
||||
try:
|
||||
data = json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
return []
|
||||
return data if isinstance(data, list) else []
|
||||
|
||||
|
||||
_VALID_LEVELS = {"L0", "L1", "L2", "L3", "L4"}
|
||||
_VALID_SCOPES = {"character_scope", "user_scope", "task_scope", "global_scope"}
|
||||
|
||||
|
||||
def _level(value: Any) -> str:
|
||||
text = str(value or "").strip().upper()
|
||||
return text if text in _VALID_LEVELS else "L4"
|
||||
|
||||
|
||||
def _scope(value: Any) -> str:
|
||||
text = str(value or "").strip()
|
||||
return text if text in _VALID_SCOPES else "character_scope"
|
||||
|
||||
|
||||
def _dict(value: Any) -> dict[str, Any]:
|
||||
return value if isinstance(value, dict) else {}
|
||||
|
||||
|
||||
def _clamp_float(value: Any, default: float) -> float:
|
||||
try:
|
||||
num = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
return max(0.0, min(1.0, num))
|
||||
|
||||
|
||||
def _int_list(value: Any) -> list[int]:
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
result: list[int] = []
|
||||
for item in value:
|
||||
try:
|
||||
result.append(int(item))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return result
|
||||
@@ -0,0 +1,78 @@
|
||||
"""大周天演化候选 prompt 构建(纯函数,无 IO)。
|
||||
|
||||
接收 ``EvaluationSnapshot``(由 application 阶段从 ORM 组装的态势快照),产出
|
||||
``(system, user)`` 消息对,喂给模型网关。模型返回的 JSON 由 ``extract`` 模块解析。
|
||||
|
||||
枚举取值以 [契约字典](../../docs/contracts/contract-glossary.md) §12 与
|
||||
[自我演化机制设计](../../docs/domain/core-domains.md) §11 为准。
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from app.modules.evolution import LEVELS as EVOLUTION_LEVELS
|
||||
from app.modules.evolution import SCOPES as EVOLUTION_SCOPES
|
||||
from app.modules.evolution import TYPES as EVOLUTION_TYPES
|
||||
|
||||
EVOLUTION_TYPES = EVOLUTION_TYPES
|
||||
EVOLUTION_LEVELS = EVOLUTION_LEVELS
|
||||
EVOLUTION_SCOPES = EVOLUTION_SCOPES
|
||||
|
||||
|
||||
@dataclass
|
||||
class EvaluationSnapshot:
|
||||
"""大周天自我评估态势快照(application 组装,供候选生成消费)。"""
|
||||
|
||||
character_id: str
|
||||
memory_count: int
|
||||
recent_memories: list[tuple[str, str]] # (title, content)
|
||||
personality_summary: str
|
||||
world_entity_count: int
|
||||
conclusion: str
|
||||
evidence_ids: list[str] = field(default_factory=list) # 记忆 ID,供 evidence 序号映射
|
||||
cumulative_drift: float = 0.0 # 最近大周天的累积漂移值
|
||||
drift_budget_remaining: float = 0.0 # 剩余漂移预算
|
||||
|
||||
|
||||
def build_evolution_extraction_messages(
|
||||
snapshot: EvaluationSnapshot,
|
||||
) -> tuple[str, str]:
|
||||
system = (
|
||||
"你是魂镜的大周天自我演化引擎。基于本轮自我评估的态势快照,生成轻量、可解释、"
|
||||
"可回滚的演化候选——只调整人格表达、程序记忆、工具策略等轻量参数,"
|
||||
"不改变核心使命、安全边界或高敏关系判断。"
|
||||
f"type 取自:{', '.join(EVOLUTION_TYPES)}。"
|
||||
f"level 取自:{', '.join(EVOLUTION_LEVELS)}"
|
||||
"(L0-L3 低风险可半自动,L4 核心人格/使命/安全边界必须人工确认)。"
|
||||
f"scope 取自:{', '.join(EVOLUTION_SCOPES)}。"
|
||||
'以 JSON 数组返回,每个元素:{"type": str, "source": "big_cycle", '
|
||||
'"level": str, "scope": str, "title": str, "proposal": str, '
|
||||
'"delta": 对象, "confidence": 0~1, "benefit_score": 0~1, '
|
||||
'"risk_score": 0~1, "auto_apply_allowed": bool, "evidence": [记忆序号]}。'
|
||||
"delta 为具体增量(如人格字段覆盖对象、程序记忆 title/content)。"
|
||||
"evidence 为下方记忆摘要的序号([0]/[1]…)列表。"
|
||||
"仅在态势充分支撑时提议,宁缺毋滥。若本轮无需演化返回 []。"
|
||||
"只返回 JSON 数组,不要解释或 markdown。"
|
||||
)
|
||||
user = (
|
||||
f"记忆总数:{snapshot.memory_count}\n"
|
||||
f"世界实体数:{snapshot.world_entity_count}\n"
|
||||
f"自我评估结论:{snapshot.conclusion or '(无明显态势)'}\n"
|
||||
f"当前人格摘要:\n{snapshot.personality_summary or '(尚未形成)'}\n"
|
||||
f"近期记忆摘要:\n{_format_memories(snapshot.recent_memories)}"
|
||||
)
|
||||
# 人格权重向量:将漂移信息注入 prompt,引导 LLM 保守生成
|
||||
if snapshot.cumulative_drift > 0:
|
||||
user += (
|
||||
f"\n\n⚠️ 人格漂移警告:累积漂移 {snapshot.cumulative_drift:.2f} / "
|
||||
f"预算 {snapshot.drift_budget_remaining + snapshot.cumulative_drift:.1f}。"
|
||||
f"剩余漂移预算 {snapshot.drift_budget_remaining:.1f}。"
|
||||
f"请尽可能保守,减少高风险 proposal,优先微调而非大幅改动。"
|
||||
)
|
||||
return system, user
|
||||
|
||||
|
||||
def _format_memories(memories: list[tuple[str, str]]) -> str:
|
||||
if not memories:
|
||||
return "(暂无)"
|
||||
lines = [f"[{idx}] {title}:{content}" for idx, (title, content) in enumerate(memories)]
|
||||
return "\n".join(lines)
|
||||
@@ -0,0 +1,154 @@
|
||||
"""人物领域逻辑:纯函数与常量,无 IO。
|
||||
|
||||
包含角色档案处理、状态转换规则、模型策略提取等不依赖外部系统的领域逻辑。
|
||||
设计见 ``docs/domain/identity-continuity.md``。
|
||||
|
||||
模块导入限制:禁止引入 ``repositories`` / ``db`` / ``integrations`` / ``ai_provider`` / ``httpx`` / ``sqlalchemy``。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
from typing import Any
|
||||
|
||||
from app.repositories.json_utils import dumps, loads_dict
|
||||
|
||||
# ============================================================
|
||||
# 常量
|
||||
# ============================================================
|
||||
|
||||
# create_character API 构建人物时的兜底模型策略(仅能力槽标识,不映射具体模型)。
|
||||
# 系统模板的模型策略由 config/character_templates.yaml 定义,不走此常量为源。
|
||||
MODEL_POLICY_CREATE_DEFAULTS: dict[str, str] = {
|
||||
"memory_extraction_model_hint": "memory-extraction",
|
||||
"personality_analysis_model_hint": "personality-analysis",
|
||||
}
|
||||
|
||||
DEFAULT_SYSTEM_TEMPLATE_KEY: str = "default"
|
||||
SYSTEM_TEMPLATE_OWNER_SCOPE: str = "system"
|
||||
|
||||
# 允许的状态转换:{当前状态 → [可转换的目标状态]}
|
||||
VALID_STATUS_TRANSITIONS: dict[str, list[str]] = {
|
||||
"initialized": ["active", "archived"],
|
||||
"active": ["paused", "archived"],
|
||||
"paused": ["active", "archived"],
|
||||
"archived": [], # 终态,不可再转换
|
||||
}
|
||||
|
||||
# 可变状态列表:此列表中的状态允许 profile / model_policy 修改
|
||||
MUTABLE_STATUSES: tuple[str, ...] = ("active", "paused", "initialized")
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 档案处理
|
||||
# ============================================================
|
||||
|
||||
|
||||
def sanitize_profile(profile: dict[str, Any]) -> None:
|
||||
"""原地转换 profile 中不可 JSON 序列化的类型(如 date / datetime)为 ISO 字符串。"""
|
||||
for key, value in profile.items():
|
||||
if isinstance(value, (date, datetime)):
|
||||
profile[key] = value.isoformat()
|
||||
|
||||
|
||||
def build_profile_json(profile: dict[str, Any], **extra: Any) -> str:
|
||||
"""构建 profile JSON 字符串:注入额外字段并序列化。"""
|
||||
merged = {**profile, **extra}
|
||||
sanitize_profile(merged)
|
||||
return dumps(merged)
|
||||
|
||||
|
||||
def build_model_policy_json(
|
||||
default_chat_model_hint: str | None = None,
|
||||
overrides: dict[str, Any] | None = None,
|
||||
) -> str:
|
||||
"""构建模型策略 JSON 字符串:以默认策略为基底,叠加 overrides。"""
|
||||
policy = dict(MODEL_POLICY_CREATE_DEFAULTS)
|
||||
if default_chat_model_hint:
|
||||
policy["default_chat_model_hint"] = default_chat_model_hint
|
||||
if overrides:
|
||||
policy.update(overrides)
|
||||
return dumps(policy)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 模型策略提取
|
||||
# ============================================================
|
||||
|
||||
|
||||
def extract_model_hint(model_policy_json: str, setting_default: str = "") -> str:
|
||||
"""从 ``model_policy_json`` 中提取 ``default_chat_model_hint``。
|
||||
|
||||
Args:
|
||||
model_policy_json: ORM 字段 ``character.model_policy_json`` 原始 JSON 文本。
|
||||
setting_default: 全局默认值(来自 ``settings.default_model_hint``)。
|
||||
|
||||
Returns:
|
||||
提取到的模型提示名,未配置时回退 ``setting_default``。
|
||||
"""
|
||||
policy = loads_dict(model_policy_json)
|
||||
value = policy.get("default_chat_model_hint")
|
||||
return value if isinstance(value, str) and value else setting_default
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 状态转换验证
|
||||
# ============================================================
|
||||
|
||||
|
||||
def validate_status_transition(current_status: str, target_status: str) -> None:
|
||||
"""验证人物状态转换是否合法。
|
||||
|
||||
Raises:
|
||||
ValueError: 转换不允许时。
|
||||
"""
|
||||
allowed = VALID_STATUS_TRANSITIONS.get(current_status, [])
|
||||
if target_status not in allowed:
|
||||
raise ValueError(
|
||||
f"不允许的状态转换:{current_status!r} → {target_status!r}。"
|
||||
f"允许的目标状态:{allowed or '(无,终态不可转换)'}"
|
||||
)
|
||||
|
||||
|
||||
def validate_is_mutable(status: str) -> None:
|
||||
"""检查人物当前状态是否允许修改档案/策略。
|
||||
|
||||
Raises:
|
||||
ValueError: 不可修改时。
|
||||
"""
|
||||
if status not in MUTABLE_STATUSES:
|
||||
raise ValueError(f"状态 {status!r} 的人物不可修改。")
|
||||
|
||||
|
||||
def require_not_archived(status: str) -> None:
|
||||
"""检查人物是否已归档(终态拦截,状态无关)。"""
|
||||
if status == "archived":
|
||||
raise ValueError("已归档人物不可操作,请先恢复。")
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 模板相关
|
||||
# ============================================================
|
||||
|
||||
|
||||
def ensure_character_mutable(is_system_template: bool) -> None:
|
||||
"""系统人物模板不可修改。"""
|
||||
if is_system_template:
|
||||
raise ValueError("系统人物模板不可修改。")
|
||||
|
||||
|
||||
__all__ = [
|
||||
"MODEL_POLICY_CREATE_DEFAULTS",
|
||||
"DEFAULT_SYSTEM_TEMPLATE_KEY",
|
||||
"SYSTEM_TEMPLATE_OWNER_SCOPE",
|
||||
"VALID_STATUS_TRANSITIONS",
|
||||
"MUTABLE_STATUSES",
|
||||
"build_model_policy_json",
|
||||
"build_profile_json",
|
||||
"ensure_character_mutable",
|
||||
"extract_model_hint",
|
||||
"require_not_archived",
|
||||
"sanitize_profile",
|
||||
"validate_is_mutable",
|
||||
"validate_status_transition",
|
||||
]
|
||||
Whitespace-only changes.
@@ -0,0 +1,18 @@
|
||||
"""聊天历史纯组装函数(IO 查询已迁 services/chat)。
|
||||
|
||||
``history_pairs`` 把已加载的消息列表转成 ``(role, content)`` 对,供上下文组装使用。
|
||||
本模块只接收已加载数据。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.models.chat import ChatMessage
|
||||
|
||||
|
||||
def history_pairs(messages: list[ChatMessage]) -> list[tuple[str, str]]:
|
||||
"""把消息列表转成 (role, content) 对,供上下文组装使用。"""
|
||||
return [
|
||||
(message.role, message.content)
|
||||
for message in messages
|
||||
if message.role in {"user", "assistant", "system"}
|
||||
]
|
||||
@@ -0,0 +1,11 @@
|
||||
"""聊天策略模块。"""
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.errors import AppError
|
||||
|
||||
|
||||
def validate_text_message(*, modality: str, content: str) -> None:
|
||||
if modality != "text":
|
||||
raise AppError("unsupported_media_type", "当前阶段仅支持文本消息。")
|
||||
if len(content.encode("utf-8")) > settings.max_text_message_bytes:
|
||||
raise AppError("payload_too_large", "单条文本消息超过大小限制。")
|
||||
@@ -0,0 +1 @@
|
||||
"""大周天内驱力领域纯逻辑(造消息 / 解析好奇问题与次生目标草稿,禁止 IO)。"""
|
||||
@@ -0,0 +1,127 @@
|
||||
"""解析模型返回为内驱力草稿(纯函数,无 IO)。
|
||||
|
||||
好奇问题可内嵌次生目标([内驱力生成器设计] ../../docs/domain/core-domains.md
|
||||
§6:好奇问题→转化次生目标)。缺省取**安全悲观默认**:``risk_level=L4``、
|
||||
``source=reflection``,避免模型漏字段时生成高风险探索。
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class SubgoalDraft:
|
||||
title: str
|
||||
description: str
|
||||
expected_information_gain: float = 0.5
|
||||
interruption_cost: float = 0.5
|
||||
risk_level: str = "L4"
|
||||
|
||||
|
||||
@dataclass
|
||||
class CuriosityDraft:
|
||||
question: str
|
||||
source: str = "reflection"
|
||||
related_entities: list[str] = field(default_factory=list)
|
||||
expected_value: float = 0.5
|
||||
urgency: float = 0.5
|
||||
risk_level: str = "L4"
|
||||
subgoals: list[SubgoalDraft] = field(default_factory=list)
|
||||
|
||||
|
||||
_VALID_SOURCES = {
|
||||
"world_uncertainty",
|
||||
"memory_gap",
|
||||
"perception_change",
|
||||
"goal_tension",
|
||||
"reflection",
|
||||
"time_rhythm",
|
||||
}
|
||||
_VALID_LEVELS = {"L0", "L1", "L2", "L3", "L4"}
|
||||
|
||||
|
||||
def parse_curiosities(raw: str) -> list[CuriosityDraft]:
|
||||
drafts: list[CuriosityDraft] = []
|
||||
for item in _extract_json_array(raw):
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
question = str(item.get("question", "")).strip()
|
||||
if not question:
|
||||
continue
|
||||
drafts.append(
|
||||
CuriosityDraft(
|
||||
question=question,
|
||||
source=_source(item.get("source")),
|
||||
related_entities=_str_list(item.get("related_entities")),
|
||||
expected_value=_clamp_float(item.get("expected_value"), 0.5),
|
||||
urgency=_clamp_float(item.get("urgency"), 0.5),
|
||||
risk_level=_level(item.get("risk_level")),
|
||||
subgoals=_parse_subgoals(item.get("subgoals")),
|
||||
)
|
||||
)
|
||||
return drafts
|
||||
|
||||
|
||||
def _parse_subgoals(value: Any) -> list[SubgoalDraft]:
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
result: list[SubgoalDraft] = []
|
||||
for item in value:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
title = str(item.get("title", "")).strip()
|
||||
description = str(item.get("description", "")).strip()
|
||||
if not title or not description:
|
||||
continue
|
||||
result.append(
|
||||
SubgoalDraft(
|
||||
title=title,
|
||||
description=description,
|
||||
expected_information_gain=_clamp_float(item.get("expected_information_gain"), 0.5),
|
||||
interruption_cost=_clamp_float(item.get("interruption_cost"), 0.5),
|
||||
risk_level=_level(item.get("risk_level")),
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def _extract_json_array(raw: str) -> list[Any]:
|
||||
text = raw.strip()
|
||||
matched = re.search(r"```(?:json)?\s*(\[.*?\])\s*```", text, re.DOTALL)
|
||||
if matched:
|
||||
text = matched.group(1)
|
||||
else:
|
||||
start, end = text.find("["), text.rfind("]")
|
||||
if start != -1 and end > start:
|
||||
text = text[start : end + 1]
|
||||
try:
|
||||
data = json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
return []
|
||||
return data if isinstance(data, list) else []
|
||||
|
||||
|
||||
def _source(value: Any) -> str:
|
||||
text = str(value or "").strip()
|
||||
return text if text in _VALID_SOURCES else "reflection"
|
||||
|
||||
|
||||
def _level(value: Any) -> str:
|
||||
text = str(value or "").strip().upper()
|
||||
return text if text in _VALID_LEVELS else "L4"
|
||||
|
||||
|
||||
def _str_list(value: Any) -> list[str]:
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
return [str(v) for v in value if v is not None]
|
||||
|
||||
|
||||
def _clamp_float(value: Any, default: float) -> float:
|
||||
try:
|
||||
num = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
return max(0.0, min(1.0, num))
|
||||
@@ -0,0 +1,55 @@
|
||||
"""大周天内驱力 prompt 构建(纯函数,无 IO)。
|
||||
|
||||
复用 ``EvaluationSnapshot``(态势快照),产出 ``(system, user)`` 消息对,喂给
|
||||
模型网关。模型返回的 JSON 由 ``extract`` 模块解析。
|
||||
|
||||
枚举取值以 [内驱力生成器设计](../../docs/domain/core-domains.md) §5 与
|
||||
[数据库 schema](../../docs/contracts/contract-glossary.md) §22 为准。
|
||||
"""
|
||||
|
||||
from app.modules.big_cycle.prompts import EvaluationSnapshot
|
||||
|
||||
DRIVE_SOURCES = (
|
||||
"world_uncertainty",
|
||||
"memory_gap",
|
||||
"perception_change",
|
||||
"goal_tension",
|
||||
"reflection",
|
||||
"time_rhythm",
|
||||
)
|
||||
DRIVE_LEVELS = ("L0", "L1", "L2", "L3", "L4")
|
||||
|
||||
|
||||
def build_drive_extraction_messages(
|
||||
snapshot: EvaluationSnapshot,
|
||||
) -> tuple[str, str]:
|
||||
system = (
|
||||
"你是魂镜的内驱力生成器。基于本轮自我评估的态势快照,识别「匮乏感」——"
|
||||
"世界模型不确定性、记忆缺口、目标张力等——生成好奇问题与可审核的探索性次生目标。"
|
||||
"不生成已有确信答案的问题;不为满足好奇而越权(摄像头 / 麦克风 / 敏感读取仍需授权)。"
|
||||
f"source 取自:{', '.join(DRIVE_SOURCES)}。"
|
||||
f"risk_level 取自:{', '.join(DRIVE_LEVELS)}"
|
||||
"(探索风险等级,L4 必须人工确认)。"
|
||||
'以 JSON 数组返回,每个元素是一个好奇问题:{"question": str, "source": str, '
|
||||
'"expected_value": 0~1, "urgency": 0~1, "risk_level": str, '
|
||||
'"related_entities": [实体名], "subgoals": [{"title": str, "description": str, '
|
||||
'"expected_information_gain": 0~1, "interruption_cost": 0~1, "risk_level": str}]}。'
|
||||
"subgoals 为该好奇问题转化的可执行探索目标,可空数组。"
|
||||
"仅在态势确有缺口时生成,宁缺毋滥;无缺口返回 []。"
|
||||
"只返回 JSON 数组,不要解释或 markdown。"
|
||||
)
|
||||
user = (
|
||||
f"记忆总数:{snapshot.memory_count}\n"
|
||||
f"世界实体数:{snapshot.world_entity_count}\n"
|
||||
f"自我评估结论:{snapshot.conclusion or '(无明显态势)'}\n"
|
||||
f"当前人格摘要:\n{snapshot.personality_summary or '(尚未形成)'}\n"
|
||||
f"近期记忆摘要:\n{_format_memories(snapshot.recent_memories)}"
|
||||
)
|
||||
return system, user
|
||||
|
||||
|
||||
def _format_memories(memories: list[tuple[str, str]]) -> str:
|
||||
if not memories:
|
||||
return "(暂无)"
|
||||
lines = [f"[{idx}] {title}:{content}" for idx, (title, content) in enumerate(memories)]
|
||||
return "\n".join(lines)
|
||||
@@ -0,0 +1,18 @@
|
||||
"""演化候选领域纯逻辑。
|
||||
|
||||
本模块是演化系统(EvolutionCandidate)的纯函数域,禁止 IO。
|
||||
IO 操作由 ``services/evolution.py`` 或 ``application/big_cycle.py`` 负责编排。
|
||||
|
||||
当前内容:
|
||||
- ``policies`` — level 分类、自动应用策略、类型/作用域枚举
|
||||
"""
|
||||
|
||||
from app.modules.evolution.policies import AUTO_LEVELS, LEVELS, SCOPES, TYPES, should_auto_apply
|
||||
|
||||
__all__ = [
|
||||
"AUTO_LEVELS",
|
||||
"LEVELS",
|
||||
"SCOPES",
|
||||
"TYPES",
|
||||
"should_auto_apply",
|
||||
]
|
||||
@@ -0,0 +1,57 @@
|
||||
"""演化候选领域纯逻辑(level 分类、自动应用策略、类型/作用域枚举)。
|
||||
|
||||
本模块是演化候选相关常量与纯谓词的单一源,禁止 IO。
|
||||
枚举取值以 [契约字典](../../docs/contracts/contract-glossary.md) §12 为准。
|
||||
"""
|
||||
|
||||
from app.models.evolution import EvolutionCandidate
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Level 常量与自动应用策略
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# 半自动应用的 level 范围(L0-L3 写入程序记忆/人格权重等轻量参数,L4 核心变更需人工审核)
|
||||
AUTO_LEVELS: set[str] = {"L0", "L1", "L2", "L3"}
|
||||
|
||||
# 所有可选 level,按风险升序排列
|
||||
LEVELS: tuple[str, ...] = ("L0", "L1", "L2", "L3", "L4")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Type / Scope 枚举
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# 演化候选类型
|
||||
TYPES: tuple[str, ...] = (
|
||||
"personality",
|
||||
"personality_weights",
|
||||
"retrieval_strategy",
|
||||
"tool_affinity",
|
||||
"memory",
|
||||
"tool_strategy",
|
||||
"safety_rule",
|
||||
"world_model",
|
||||
"procedural",
|
||||
"mood",
|
||||
)
|
||||
|
||||
# 演化候选作用域
|
||||
SCOPES: tuple[str, ...] = (
|
||||
"character_scope",
|
||||
"user_scope",
|
||||
"task_scope",
|
||||
"global_scope",
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 策略谓词
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def should_auto_apply(candidate: EvolutionCandidate) -> bool:
|
||||
"""判断演化候选是否可半自动应用。
|
||||
|
||||
满足任一条件即可自动应用:
|
||||
1. level 在 AUTO_LEVELS 范围内(L0-L3 低风险调整)
|
||||
2. auto_apply_allowed 为 True(模型+系统共同判定允许自动应用)
|
||||
"""
|
||||
return candidate.level in AUTO_LEVELS or candidate.auto_apply_allowed
|
||||
@@ -0,0 +1 @@
|
||||
"""大周天记忆/世界抽取领域纯逻辑(造消息 / 解析记忆候选与世界实体草稿,禁止 IO)。"""
|
||||
@@ -0,0 +1,140 @@
|
||||
"""解析模型返回为记忆/世界抽取草稿(纯函数,无 IO)。
|
||||
|
||||
模型输出对象 ``{"memories": [...], "entities": [...]}``。缺省取**安全悲观默认**:
|
||||
记忆 ``sensitivity=low``、世界 ``state_type=inference``(非 fact,召回降权,见
|
||||
[世界模型设计] ../../docs/domain/core-domains.md §8)。
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class MemoryCandidateDraft:
|
||||
type: str
|
||||
title: str
|
||||
content: str
|
||||
confidence: float = 0.5
|
||||
importance: float = 0.5
|
||||
sensitivity: str = "low"
|
||||
reason: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class WorldEntityDraft:
|
||||
type: str
|
||||
name: str
|
||||
attributes: dict[str, Any] = field(default_factory=dict)
|
||||
state_type: str = "inference"
|
||||
confidence: float = 0.5
|
||||
|
||||
|
||||
_MEMORY_TYPES = {
|
||||
"user",
|
||||
"preference",
|
||||
"event",
|
||||
"relationship",
|
||||
"skill",
|
||||
"goal",
|
||||
"other",
|
||||
}
|
||||
_ENTITY_TYPES = {
|
||||
"self",
|
||||
"person",
|
||||
"object",
|
||||
"place",
|
||||
"device",
|
||||
"project",
|
||||
"task",
|
||||
"concept",
|
||||
}
|
||||
_STATE_TYPES = {"fact", "observation", "inference", "hypothesis", "stale"}
|
||||
_SENSITIVITY = {"low", "medium", "high"}
|
||||
|
||||
|
||||
def parse_extraction(
|
||||
raw: str,
|
||||
) -> tuple[list[MemoryCandidateDraft], list[WorldEntityDraft]]:
|
||||
data = extract_json_object(raw)
|
||||
if not isinstance(data, dict):
|
||||
return [], []
|
||||
return parse_memory_drafts(data.get("memories")), _parse_entities(data.get("entities"))
|
||||
|
||||
|
||||
def parse_memory_drafts(value: Any) -> list[MemoryCandidateDraft]:
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
result: list[MemoryCandidateDraft] = []
|
||||
for item in value:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
title = str(item.get("title", "")).strip()
|
||||
content = str(item.get("content", "")).strip()
|
||||
if not title or not content:
|
||||
continue
|
||||
result.append(
|
||||
MemoryCandidateDraft(
|
||||
type=_enum(item.get("type"), _MEMORY_TYPES, "other"),
|
||||
title=title,
|
||||
content=content,
|
||||
confidence=_clamp(item.get("confidence"), 0.5),
|
||||
importance=_clamp(item.get("importance"), 0.5),
|
||||
sensitivity=_enum(item.get("sensitivity"), _SENSITIVITY, "low"),
|
||||
reason=str(item.get("reason", "")).strip(),
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def _parse_entities(value: Any) -> list[WorldEntityDraft]:
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
result: list[WorldEntityDraft] = []
|
||||
for item in value:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
name = str(item.get("name", "")).strip()
|
||||
if not name:
|
||||
continue
|
||||
attrs = item.get("attributes")
|
||||
result.append(
|
||||
WorldEntityDraft(
|
||||
type=_enum(item.get("type"), _ENTITY_TYPES, "concept"),
|
||||
name=name,
|
||||
attributes=attrs if isinstance(attrs, dict) else {},
|
||||
state_type=_enum(item.get("state_type"), _STATE_TYPES, "inference"),
|
||||
confidence=_clamp(item.get("confidence"), 0.5),
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def extract_json_object(raw: str) -> dict[str, Any]:
|
||||
text = raw.strip()
|
||||
if text.startswith("```"):
|
||||
text = re.sub(r"^```(?:json)?\s*", "", text)
|
||||
text = re.sub(r"\s*```$", "", text)
|
||||
start = text.find("{")
|
||||
end = text.rfind("}")
|
||||
if start != -1 and end > start:
|
||||
text = text[start : end + 1]
|
||||
try:
|
||||
data = json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
def _enum(value: Any, valid: set[str], default: str) -> str:
|
||||
text = str(value or "").strip().lower()
|
||||
return text if text in valid else default
|
||||
|
||||
|
||||
def _clamp(value: Any, default: float) -> float:
|
||||
try:
|
||||
num = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
return max(0.0, min(1.0, num))
|
||||
@@ -0,0 +1,46 @@
|
||||
"""大周天记忆/世界抽取 prompt 构建(纯函数,无 IO)。
|
||||
|
||||
复用 ``EvaluationSnapshot``(态势快照),产出 ``(system, user)`` 消息对,喂给
|
||||
模型网关。模型返回的 JSON 对象由 ``extract`` 模块解析。
|
||||
|
||||
枚举取值以 [记忆系统设计](../../docs/domain/memory-model.md) §6、
|
||||
[世界模型设计](../../docs/domain/core-domains.md) §8 与
|
||||
[数据库 schema](../../docs/contracts/contract-glossary.md) §20 为准。
|
||||
"""
|
||||
|
||||
from app.modules.big_cycle.prompts import EvaluationSnapshot
|
||||
|
||||
|
||||
def build_extraction_messages(
|
||||
snapshot: EvaluationSnapshot,
|
||||
) -> tuple[str, str]:
|
||||
system = (
|
||||
"你是魂镜的大周天记忆/世界抽取器。基于本轮自我评估的态势快照与近期记忆,"
|
||||
"抽取值得长期记住的记忆候选与世界实体候选(用户、项目、地点、对象等)。"
|
||||
"只抽取有长期价值、非琐碎的信息;低置信度或推断信息标注为 inference 而非 fact,"
|
||||
"不得把推断伪装成已确认事实。"
|
||||
'以 JSON 对象返回:{"memories": [...], "entities": [...]}。'
|
||||
'memories 每项:{"type": str(user/preference/event/relationship/skill/goal/fact/other), '
|
||||
'"title": str, "content": str, "importance": 0~1, '
|
||||
'"sensitivity": "low|medium|high", "confidence": 0~1, "reason": str}。'
|
||||
'entities 每项:{"type": str(self/person/object/place/device/project/task/concept), '
|
||||
'"name": str, "attributes": 对象, '
|
||||
'"state_type": str(fact/observation/inference/hypothesis/stale), "confidence": 0~1}。'
|
||||
'无值得抽取的内容时返回 {"memories": [], "entities": []}。'
|
||||
"只返回 JSON 对象,不要解释或 markdown。"
|
||||
)
|
||||
user = (
|
||||
f"记忆总数:{snapshot.memory_count}\n"
|
||||
f"世界实体数:{snapshot.world_entity_count}\n"
|
||||
f"自我评估结论:{snapshot.conclusion or '(无明显态势)'}\n"
|
||||
f"当前人格摘要:\n{snapshot.personality_summary or '(尚未形成)'}\n"
|
||||
f"近期记忆摘要:\n{_format_memories(snapshot.recent_memories)}"
|
||||
)
|
||||
return system, user
|
||||
|
||||
|
||||
def _format_memories(memories: list[tuple[str, str]]) -> str:
|
||||
if not memories:
|
||||
return "(暂无)"
|
||||
lines = [f"[{idx}] {title}:{content}" for idx, (title, content) in enumerate(memories)]
|
||||
return "\n".join(lines)
|
||||
@@ -0,0 +1,69 @@
|
||||
"""太一枢(Goal Orchestrator)领域逻辑。
|
||||
|
||||
v1 落地 §7.1 最小闭环 + §7.2 预留扩展:
|
||||
- 从用户消息提取会话目标写入 ``run.goal``,并注入运行契约。
|
||||
- 活跃目标按优先级排序(``prioritize_active_goals``),供运行时注入。
|
||||
|
||||
目标持久化体系(``goals`` 表)、调度与冲突裁决属 §7.2 / §7.3。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.modules.goal_orchestrator.extract import (
|
||||
extract_session_goal,
|
||||
format_goal_directive,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"extract_session_goal",
|
||||
"format_goal_directive",
|
||||
"prioritize_active_goals",
|
||||
"complete_session_goal",
|
||||
]
|
||||
|
||||
|
||||
def prioritize_active_goals(
|
||||
goals: list[dict],
|
||||
) -> list[dict]:
|
||||
"""活跃目标优先级排序。
|
||||
|
||||
按以下规则返回排序后的目标列表(最高优先级在前):
|
||||
1. ``priority`` 越小越优先(低数值 = 高优先级)
|
||||
2. ``priority`` 相同时 ``progress`` 越低越优先(未完成的优先)
|
||||
3. 层级越靠前越优先(L0 > L1 > L2 > L3 > L4)
|
||||
|
||||
Args:
|
||||
goals: 活跃目标字典列表,每个字典包含 ``title`` / ``level`` / ``priority`` / ``progress``。
|
||||
|
||||
Returns:
|
||||
排序后的目标列表。
|
||||
"""
|
||||
LEVEL_ORDER = {"L0": 0, "L1": 1, "L2": 2, "L3": 3, "L4": 4}
|
||||
|
||||
def _sort_key(g: dict) -> tuple:
|
||||
priority = g.get("priority")
|
||||
if priority is None:
|
||||
priority = 100
|
||||
progress = g.get("progress", 0) or 0
|
||||
level = LEVEL_ORDER.get(str(g.get("level", "")), 99)
|
||||
return (0 if priority is not None else 1, priority, level, progress)
|
||||
|
||||
return sorted(goals, key=_sort_key)
|
||||
|
||||
|
||||
def complete_session_goal(
|
||||
goal: dict,
|
||||
) -> dict:
|
||||
"""将会话目标标记为完成。
|
||||
|
||||
Args:
|
||||
goal: 会话目标字典,包含 ``id`` / ``title`` / ``level`` / ``status``。
|
||||
|
||||
Returns:
|
||||
更新后的目标字典(status=completed, progress=1.0)。
|
||||
"""
|
||||
return {
|
||||
**goal,
|
||||
"status": "completed",
|
||||
"progress": 1.0,
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
"""太一会话目标提取(纯函数,无 IO)。
|
||||
|
||||
从用户消息提取会话目标(L2)。v1 采用规则提取——用户消息本身即目标的最直接
|
||||
表达,清理与截断后写入 ``run.goal`` 并注入运行契约。LLM 提取为后续增强。
|
||||
"""
|
||||
|
||||
_MAX_GOAL_CHARS = 120
|
||||
|
||||
|
||||
def extract_session_goal(content: str) -> str:
|
||||
"""从用户消息内容提取会话目标摘要。
|
||||
|
||||
规则:去除首尾空白,超长截断到 ``_MAX_GOAL_CHARS``;空内容返回空串。
|
||||
"""
|
||||
goal = (content or "").strip()
|
||||
if not goal:
|
||||
return ""
|
||||
return goal[:_MAX_GOAL_CHARS]
|
||||
|
||||
|
||||
def format_goal_directive(goal: str) -> str:
|
||||
"""把会话目标格式化为运行契约的 system instruction 片段;空目标返回空串。"""
|
||||
if not goal:
|
||||
return ""
|
||||
return f"当前会话目标:{goal}"
|
||||
@@ -0,0 +1,5 @@
|
||||
"""资料导入领域逻辑(纯函数,无 IO)。"""
|
||||
|
||||
from app.modules.imports.parsing import ParsedItemDraft, parse_import
|
||||
|
||||
__all__ = ["ParsedItemDraft", "parse_import"]
|
||||
@@ -0,0 +1,120 @@
|
||||
"""酒馆角色卡解析器。
|
||||
|
||||
从 PNG(``tEXt`` chunk 的 ``chara``/``ccv3``)/ JSON / CHARX(zip 含 ``card.json``)
|
||||
解析角色卡,归一化为统一的 ``CharacterCard``(V2/V3 的 ``data`` 字段拍平)。
|
||||
|
||||
纯字节 / 文本解析,无文件 IO——调用方(service 层)负责读文件后传入字节。
|
||||
V1 扁平结构自动归一化;``character_book`` 原样保留,由映射层转世谱条目。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import io
|
||||
import json
|
||||
import zipfile
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
_PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
|
||||
_ZIP_SIGNATURE = b"PK\x03\x04"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CharacterCard:
|
||||
"""归一化后的角色卡(V1/V2/V3 统一为拍平字段)。"""
|
||||
|
||||
spec_version: str # "1" / "2.0" / "3.0"
|
||||
name: str = ""
|
||||
description: str = ""
|
||||
personality: str = ""
|
||||
scenario: str = ""
|
||||
first_mes: str = ""
|
||||
mes_example: str = ""
|
||||
creator_notes: str = ""
|
||||
system_prompt: str = ""
|
||||
post_history_instructions: str = ""
|
||||
alternate_greetings: list[str] = field(default_factory=list)
|
||||
tags: list[str] = field(default_factory=list)
|
||||
creator: str = ""
|
||||
character_version: str = ""
|
||||
character_book: dict | None = None # 原始 lorebook,由映射层转 LorebookBook
|
||||
nickname: str | None = None # V3
|
||||
extensions: dict = field(default_factory=dict) # 保留未知键(酒馆规范:不得删除)
|
||||
|
||||
|
||||
def parse_character_card(raw: bytes) -> CharacterCard:
|
||||
"""从原始字节解析角色卡(自动识别 PNG / JSON / CHARX)。"""
|
||||
if raw[:8] == _PNG_SIGNATURE:
|
||||
data = _parse_png(raw)
|
||||
elif raw[:4] == _ZIP_SIGNATURE:
|
||||
data = _parse_charx(raw)
|
||||
else:
|
||||
data = json.loads(raw.decode("utf-8-sig"))
|
||||
return _normalize(data)
|
||||
|
||||
|
||||
def _parse_png(raw: bytes) -> dict:
|
||||
"""从 PNG tEXt chunk 提取 chara / ccv3(base64 → JSON)。优先 ccv3。"""
|
||||
chunks: list[tuple[str, str]] = []
|
||||
offset = len(_PNG_SIGNATURE)
|
||||
while offset + 8 <= len(raw):
|
||||
length = int.from_bytes(raw[offset : offset + 4], "big")
|
||||
chunk_type = raw[offset + 4 : offset + 8]
|
||||
data_start = offset + 8
|
||||
data_end = data_start + length
|
||||
if data_end > len(raw):
|
||||
break
|
||||
if chunk_type == b"tEXt":
|
||||
null = raw[data_start:data_end].find(b"\x00")
|
||||
if null >= 0:
|
||||
keyword = raw[data_start : data_start + null].decode("latin-1")
|
||||
text = raw[data_start + null + 1 : data_end].decode("latin-1")
|
||||
chunks.append((keyword, text))
|
||||
offset = data_end + 4 # 跳过 CRC
|
||||
|
||||
for want in ("ccv3", "chara"):
|
||||
for keyword, text in chunks:
|
||||
if keyword == want:
|
||||
decoded = base64.b64decode(text)
|
||||
return json.loads(decoded.decode("utf-8"))
|
||||
raise ValueError("PNG 中未找到角色卡元数据(chara / ccv3 chunk)")
|
||||
|
||||
|
||||
def _parse_charx(raw: bytes) -> dict:
|
||||
"""从 CHARX(zip)提取 card.json。"""
|
||||
with zipfile.ZipFile(io.BytesIO(raw)) as zf:
|
||||
card_name = next((n for n in zf.namelist() if n.endswith("card.json")), None)
|
||||
if card_name is None:
|
||||
raise ValueError("CHARX 中未找到 card.json")
|
||||
with zf.open(card_name) as f:
|
||||
return json.loads(f.read().decode("utf-8"))
|
||||
|
||||
|
||||
def _normalize(data: dict) -> CharacterCard:
|
||||
"""V1 扁平结构归一化为 V2/V3(data 包裹)。"""
|
||||
if isinstance(data.get("data"), dict):
|
||||
inner = data["data"]
|
||||
spec_version = str(data.get("spec_version", "2.0"))
|
||||
else:
|
||||
inner = data
|
||||
spec_version = "1"
|
||||
|
||||
return CharacterCard(
|
||||
spec_version=spec_version,
|
||||
name=inner.get("name", ""),
|
||||
description=inner.get("description", ""),
|
||||
personality=inner.get("personality", ""),
|
||||
scenario=inner.get("scenario", ""),
|
||||
first_mes=inner.get("first_mes", ""),
|
||||
mes_example=inner.get("mes_example", ""),
|
||||
creator_notes=inner.get("creator_notes", ""),
|
||||
system_prompt=inner.get("system_prompt", ""),
|
||||
post_history_instructions=inner.get("post_history_instructions", ""),
|
||||
alternate_greetings=list(inner.get("alternate_greetings", []) or []),
|
||||
tags=list(inner.get("tags", []) or []),
|
||||
creator=inner.get("creator", ""),
|
||||
character_version=inner.get("character_version", ""),
|
||||
character_book=inner.get("character_book"),
|
||||
nickname=inner.get("nickname"),
|
||||
extensions=dict(inner.get("extensions", {}) or {}),
|
||||
)
|
||||
@@ -0,0 +1,71 @@
|
||||
"""角色卡导出映射:魂镜 Character + 世谱 → ST V3 卡静态快照。
|
||||
|
||||
反向于 import mapping。导出为静态快照——丢弃演化维度(人格版本 / 记忆 / 情绪),
|
||||
仅保留当前设定 + 世谱。``mission`` / ``safety_guidelines`` 道体无 ST 对应字段,不导出。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from app.models.character import Character
|
||||
from app.models.lorebook import Lorebook, LorebookEntry
|
||||
|
||||
|
||||
def character_to_card(
|
||||
character: Character,
|
||||
lorebook: Lorebook | None = None,
|
||||
entries: list[LorebookEntry] | None = None,
|
||||
) -> dict:
|
||||
"""Character + 世谱 → ST V3 卡 dict。"""
|
||||
profile = json.loads(character.profile_json or "{}")
|
||||
source_meta = profile.get("source_meta") or {}
|
||||
data: dict = {
|
||||
"name": character.name,
|
||||
"description": profile.get("background", ""),
|
||||
"personality": character.personality_summary or "",
|
||||
"scenario": profile.get("scenario", ""),
|
||||
"first_mes": profile.get("first_mes", ""),
|
||||
"mes_example": profile.get("mes_example", ""),
|
||||
"alternate_greetings": profile.get("alternate_greetings", []),
|
||||
"tags": profile.get("tags", []),
|
||||
"creator": source_meta.get("creator", ""),
|
||||
"character_version": source_meta.get("character_version", ""),
|
||||
"creator_notes": source_meta.get("creator_notes", ""),
|
||||
"system_prompt": profile.get("system_prompt", ""),
|
||||
"post_history_instructions": profile.get("post_history_instructions", ""),
|
||||
"extensions": profile.get("st_extensions", {}),
|
||||
}
|
||||
if character.display_name and character.display_name != character.name:
|
||||
data["nickname"] = character.display_name
|
||||
if lorebook is not None:
|
||||
data["character_book"] = lorebook_to_st_book(lorebook, entries or [])
|
||||
return {"spec": "chara_card_v3", "spec_version": "3.0", "data": data}
|
||||
|
||||
|
||||
def lorebook_to_st_book(lorebook: Lorebook, entries: list[LorebookEntry]) -> dict:
|
||||
return {
|
||||
"name": lorebook.name,
|
||||
"description": lorebook.description,
|
||||
"scan_depth": lorebook.scan_depth,
|
||||
"token_budget": lorebook.token_budget,
|
||||
"recursive_scanning": lorebook.recursive_scanning,
|
||||
"entries": [entry_to_st(e) for e in entries],
|
||||
}
|
||||
|
||||
|
||||
def entry_to_st(entry: LorebookEntry) -> dict:
|
||||
return {
|
||||
"keys": json.loads(entry.keys_json or "[]"),
|
||||
"content": entry.content,
|
||||
"secondary_keys": json.loads(entry.secondary_keys_json or "[]"),
|
||||
"constant": entry.constant,
|
||||
"selective": entry.selective,
|
||||
"enabled": entry.enabled,
|
||||
"case_sensitive": entry.case_sensitive,
|
||||
"use_regex": entry.use_regex,
|
||||
"position": entry.position,
|
||||
"insertion_order": entry.insertion_order,
|
||||
"priority": entry.priority,
|
||||
"comment": entry.comment,
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
"""解析模型返回为候选草稿(纯函数,无 IO)。
|
||||
|
||||
模型可能返回纯 JSON、markdown 代码块或夹杂解释文本;本模块容错提取首个 JSON 数组,
|
||||
并归一化为候选草稿。evidence 序号由 application 映射为 ``import_items.id``。
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProfileCandidateDraft:
|
||||
field_name: str
|
||||
value: str
|
||||
confidence: float
|
||||
reason: str
|
||||
evidence: list[int] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class MemoryCandidateDraft:
|
||||
type: str
|
||||
title: str
|
||||
content: str
|
||||
importance: float
|
||||
sensitivity: str
|
||||
reason: str
|
||||
evidence: list[int] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PersonalityProposalDraft:
|
||||
field_name: str
|
||||
change: str
|
||||
reason: str
|
||||
evidence: list[int] = field(default_factory=list)
|
||||
|
||||
|
||||
def parse_profile_candidates(raw: str) -> list[ProfileCandidateDraft]:
|
||||
drafts: list[ProfileCandidateDraft] = []
|
||||
for item in _extract_json_array(raw):
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
field_name = str(item.get("field", "")).strip()
|
||||
value = str(item.get("value", "")).strip()
|
||||
if not field_name or not value:
|
||||
continue
|
||||
drafts.append(
|
||||
ProfileCandidateDraft(
|
||||
field_name=field_name,
|
||||
value=value,
|
||||
confidence=_clamp_float(item.get("confidence"), 0.5),
|
||||
reason=str(item.get("reason", "")).strip(),
|
||||
evidence=_int_list(item.get("evidence")),
|
||||
)
|
||||
)
|
||||
return drafts
|
||||
|
||||
|
||||
def parse_memory_candidates(raw: str) -> list[MemoryCandidateDraft]:
|
||||
drafts: list[MemoryCandidateDraft] = []
|
||||
for item in _extract_json_array(raw):
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
title = str(item.get("title", "")).strip()
|
||||
content = str(item.get("content", "")).strip()
|
||||
if not title or not content:
|
||||
continue
|
||||
drafts.append(
|
||||
MemoryCandidateDraft(
|
||||
type=str(item.get("type", "other")).strip() or "other",
|
||||
title=title,
|
||||
content=content,
|
||||
importance=_clamp_float(item.get("importance"), 0.5),
|
||||
sensitivity=_sensitivity(item.get("sensitivity")),
|
||||
reason=str(item.get("reason", "")).strip(),
|
||||
evidence=_int_list(item.get("evidence")),
|
||||
)
|
||||
)
|
||||
return drafts
|
||||
|
||||
|
||||
def parse_personality_proposals(raw: str) -> list[PersonalityProposalDraft]:
|
||||
drafts: list[PersonalityProposalDraft] = []
|
||||
for item in _extract_json_array(raw):
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
field_name = str(item.get("field", "")).strip()
|
||||
change = str(item.get("change", "")).strip()
|
||||
if not field_name or not change:
|
||||
continue
|
||||
drafts.append(
|
||||
PersonalityProposalDraft(
|
||||
field_name=field_name,
|
||||
change=change,
|
||||
reason=str(item.get("reason", "")).strip(),
|
||||
evidence=_int_list(item.get("evidence")),
|
||||
)
|
||||
)
|
||||
return drafts
|
||||
|
||||
|
||||
def _extract_json_array(raw: str) -> list[Any]:
|
||||
text = raw.strip()
|
||||
matched = re.search(r"```(?:json)?\s*(\[.*?\])\s*```", text, re.DOTALL)
|
||||
if matched:
|
||||
text = matched.group(1)
|
||||
else:
|
||||
start, end = text.find("["), text.rfind("]")
|
||||
if start != -1 and end > start:
|
||||
text = text[start : end + 1]
|
||||
try:
|
||||
data = json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
return []
|
||||
return data if isinstance(data, list) else []
|
||||
|
||||
|
||||
def _clamp_float(value: Any, default: float) -> float:
|
||||
try:
|
||||
num = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
return max(0.0, min(1.0, num))
|
||||
|
||||
|
||||
def _int_list(value: Any) -> list[int]:
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
result: list[int] = []
|
||||
for item in value:
|
||||
try:
|
||||
result.append(int(item))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return result
|
||||
|
||||
|
||||
def _sensitivity(value: Any) -> str:
|
||||
text = str(value or "").strip().lower()
|
||||
return text if text in {"low", "medium", "high"} else "low"
|
||||
@@ -0,0 +1,66 @@
|
||||
"""角色卡字段映射:ST CharacterCard → 魂镜 Character + 世谱条目。
|
||||
|
||||
纯逻辑,无 IO。``system_prompt`` / ``post_history_instructions`` 导入时存入 profile,
|
||||
运行时作道体增补层注入(绝不覆盖恒定层,见融合设计 §6)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.modules.imports.card_parser import CharacterCard
|
||||
|
||||
|
||||
def card_to_profile(card: CharacterCard) -> dict:
|
||||
"""ST 卡 → Character profile JSON(自由字段,保留酒馆全部信息)。"""
|
||||
return {
|
||||
"background": card.description,
|
||||
"scenario": card.scenario,
|
||||
"first_mes": card.first_mes,
|
||||
"mes_example": card.mes_example,
|
||||
"alternate_greetings": card.alternate_greetings,
|
||||
"tags": card.tags,
|
||||
# 运行时作道体增补层注入(§6 安全边界),导入先保存
|
||||
"system_prompt": card.system_prompt,
|
||||
"post_history_instructions": card.post_history_instructions,
|
||||
"source_meta": {
|
||||
"creator": card.creator,
|
||||
"character_version": card.character_version,
|
||||
"creator_notes": card.creator_notes,
|
||||
"spec_version": card.spec_version,
|
||||
},
|
||||
"st_extensions": card.extensions, # 保留未知键(酒馆规范:不得删除)
|
||||
}
|
||||
|
||||
|
||||
def card_to_daoti(card: CharacterCard) -> dict:
|
||||
"""ST 卡 → 道体字段。
|
||||
|
||||
``personality`` 作 ``personality_summary`` 初始值(已拍板);``mission`` /
|
||||
``safety_guidelines`` 酒馆卡没有,导入不设,留用户 / 演化填。
|
||||
"""
|
||||
return {"personality_summary": card.personality or None}
|
||||
|
||||
|
||||
def character_book_to_entries(book: dict | None) -> list[dict]:
|
||||
"""ST character_book entries → 世谱条目构造参数。"""
|
||||
if not book:
|
||||
return []
|
||||
result = []
|
||||
for e in book.get("entries") or []:
|
||||
enabled_raw = e.get("enabled", True)
|
||||
result.append(
|
||||
{
|
||||
"keys": list(e.get("keys") or []),
|
||||
"content": e.get("content") or "",
|
||||
"secondary_keys": list(e.get("secondary_keys") or []),
|
||||
"constant": bool(e.get("constant", False)),
|
||||
"selective": bool(e.get("selective", False)),
|
||||
"enabled": True if enabled_raw is None else bool(enabled_raw),
|
||||
"case_sensitive": bool(e.get("case_sensitive", False)),
|
||||
"use_regex": bool(e.get("use_regex", False)),
|
||||
"position": e.get("position") or "after_char",
|
||||
"insertion_order": int(e.get("insertion_order") or 100),
|
||||
"priority": int(e.get("priority") or 10),
|
||||
"comment": e.get("comment") or e.get("name") or "",
|
||||
}
|
||||
)
|
||||
return result
|
||||
@@ -0,0 +1,113 @@
|
||||
"""导入资料结构解析(纯函数,无 IO)。
|
||||
|
||||
按 ``material_type`` / ``format`` 把原始资料切片为条目草稿。只做结构解析
|
||||
(消息数组、段落、字段),语义抽取(档案 / 记忆 / 人格候选)留给后续
|
||||
模型驱动阶段。领域层禁止 IO,本模块仅产出数据结构。
|
||||
"""
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
# 5a 不切片的媒体资料(仅存元数据与授权记录,spec §18 仅预留)。
|
||||
_NON_TEXT_MATERIAL_TYPES = {"voice_sample"}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ParsedItemDraft:
|
||||
"""解析产出的条目草稿;落库前由 service 补 sequence / id。"""
|
||||
|
||||
item_type: str
|
||||
role: str
|
||||
content: str
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
def parse_import(
|
||||
material_type: str, fmt: str, *, content: str | None = None
|
||||
) -> list[ParsedItemDraft]:
|
||||
"""按格式解析文本资料;媒体资料返回空。"""
|
||||
if material_type in _NON_TEXT_MATERIAL_TYPES:
|
||||
return []
|
||||
|
||||
if fmt == "json" and content:
|
||||
return _parse_json(content)
|
||||
|
||||
if content:
|
||||
return _parse_paragraphs(content)
|
||||
|
||||
return []
|
||||
|
||||
|
||||
def _parse_json(content: str) -> list[ParsedItemDraft]:
|
||||
try:
|
||||
data = json.loads(content)
|
||||
except json.JSONDecodeError:
|
||||
return _parse_paragraphs(content)
|
||||
|
||||
if (
|
||||
isinstance(data, list)
|
||||
and data
|
||||
and isinstance(data[0], dict)
|
||||
and ("role" in data[0] or "content" in data[0])
|
||||
):
|
||||
return _parse_messages(data)
|
||||
if isinstance(data, dict):
|
||||
return _parse_fields(data)
|
||||
return _parse_paragraphs(content)
|
||||
|
||||
|
||||
def _parse_messages(messages: list[dict[str, Any]]) -> list[ParsedItemDraft]:
|
||||
drafts: list[ParsedItemDraft] = []
|
||||
for msg in messages:
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
text = str(msg.get("content", "")).strip()
|
||||
if not text:
|
||||
continue
|
||||
meta = {k: v for k, v in msg.items() if k not in ("role", "content")}
|
||||
drafts.append(
|
||||
ParsedItemDraft(
|
||||
item_type="message",
|
||||
role=_normalize_role(str(msg.get("role", "other"))),
|
||||
content=text,
|
||||
metadata=meta,
|
||||
)
|
||||
)
|
||||
return drafts
|
||||
|
||||
|
||||
def _parse_fields(data: dict[str, Any]) -> list[ParsedItemDraft]:
|
||||
drafts: list[ParsedItemDraft] = []
|
||||
for key, value in data.items():
|
||||
text = str(value).strip()
|
||||
if text:
|
||||
drafts.append(
|
||||
ParsedItemDraft(
|
||||
item_type="profile_field",
|
||||
role="other",
|
||||
content=text,
|
||||
metadata={"field": key},
|
||||
)
|
||||
)
|
||||
return drafts
|
||||
|
||||
|
||||
def _parse_paragraphs(text: str) -> list[ParsedItemDraft]:
|
||||
drafts: list[ParsedItemDraft] = []
|
||||
for chunk in text.split("\n\n"):
|
||||
clean = chunk.strip()
|
||||
if clean:
|
||||
drafts.append(ParsedItemDraft(item_type="paragraph", role="other", content=clean))
|
||||
return drafts
|
||||
|
||||
|
||||
def _normalize_role(role: str) -> str:
|
||||
lowered = role.lower()
|
||||
if lowered in ("user", "human"):
|
||||
return "user"
|
||||
if lowered in ("assistant", "ai", "bot"):
|
||||
return "assistant"
|
||||
if lowered == "system":
|
||||
return "system"
|
||||
return "other"
|
||||
@@ -0,0 +1,85 @@
|
||||
"""候选抽取 prompt 构建(纯函数,无 IO)。
|
||||
|
||||
接收 ``(role, content)`` 元组列表(由 application 从 ORM 转换),产出
|
||||
``(system, user)`` 消息对,喂给模型网关。模型返回的 JSON 由 ``extract`` 模块解析。
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
PROFILE_FIELDS = (
|
||||
"display_name",
|
||||
"birthday",
|
||||
"height",
|
||||
"weight",
|
||||
"background",
|
||||
"speaking_style",
|
||||
)
|
||||
MEMORY_TYPES = (
|
||||
"user",
|
||||
"preference",
|
||||
"event",
|
||||
"relationship",
|
||||
"skill",
|
||||
"goal",
|
||||
"other",
|
||||
)
|
||||
PERSONALITY_FIELDS = (
|
||||
"identity_profile",
|
||||
"role_destiny",
|
||||
"personality_seed",
|
||||
"expression_style",
|
||||
"relationship_pattern",
|
||||
"traits",
|
||||
"values",
|
||||
"emotion_baseline",
|
||||
)
|
||||
|
||||
|
||||
def _format_items(items: Sequence[tuple[str, str]]) -> str:
|
||||
lines = [f"[{idx}] ({role}) {content}" for idx, (role, content) in enumerate(items)]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def build_profile_extraction_messages(
|
||||
items: Sequence[tuple[str, str]],
|
||||
) -> tuple[str, str]:
|
||||
system = (
|
||||
"你是资料分析助手。从用户提供的资料中提取人物档案字段候选,只提取明确出现的信息。"
|
||||
f"field 取自:{', '.join(PROFILE_FIELDS)}。"
|
||||
'以 JSON 数组返回,每个元素:{"field": str, "value": str, "confidence": 0~1, '
|
||||
'"reason": str, "evidence": [条目序号]}。'
|
||||
"evidence 为资料条目序号([0]/[1]…)列表。若资料无档案信息,返回 []。"
|
||||
"只返回 JSON 数组,不要解释或 markdown。"
|
||||
)
|
||||
return system, f"资料:\n{_format_items(items)}"
|
||||
|
||||
|
||||
def build_memory_extraction_messages(
|
||||
items: Sequence[tuple[str, str]],
|
||||
) -> tuple[str, str]:
|
||||
system = (
|
||||
"你是记忆分析助手。从用户提供的资料中提取值得长期记住的记忆候选。"
|
||||
f"type 取自:{', '.join(MEMORY_TYPES)}。"
|
||||
'以 JSON 数组返回:{"type": str, "title": str, "content": str, '
|
||||
'"importance": 0~1, "sensitivity": "low|medium|high", "reason": str, '
|
||||
'"evidence": [条目序号]}。'
|
||||
"只提取有长期价值的非琐碎信息。若无可提取返回 []。只返回 JSON 数组。"
|
||||
)
|
||||
return system, f"资料:\n{_format_items(items)}"
|
||||
|
||||
|
||||
def build_personality_extraction_messages(
|
||||
items: Sequence[tuple[str, str]], personality_summary: str
|
||||
) -> tuple[str, str]:
|
||||
system = (
|
||||
"你是人格分析助手。基于用户提供的资料和当前人格,提出人格完善建议。"
|
||||
f"field 取自:{', '.join(PERSONALITY_FIELDS)}。"
|
||||
'以 JSON 数组返回:{"field": str, "change": str, "reason": str, '
|
||||
'"evidence": [条目序号]}。'
|
||||
"change 为建议写入该字段的新内容。只在资料充分支撑时提议。若无需完善返回 []。"
|
||||
"只返回 JSON 数组。"
|
||||
)
|
||||
user = (
|
||||
f"当前人格摘要:\n{personality_summary or '(尚未形成)'}\n\n资料:\n{_format_items(items)}"
|
||||
)
|
||||
return system, user
|
||||
@@ -0,0 +1,40 @@
|
||||
"""叩心枢(KnockingPivot):人物主动对话守护模块。
|
||||
|
||||
设计见 docs/domain/core-domains.md。
|
||||
|
||||
叩心枢与辰正(时感)、灵核(情绪)并列——辰正提供时态语境,
|
||||
灵核产出情绪状态,叩心判断「此时该不该开口」。
|
||||
记忆浮现、情绪波动、时光流逝 → 叩动心弦 → 自然想说话。
|
||||
"""
|
||||
|
||||
from app.modules.knocking.conditions import (
|
||||
AnniversaryCondition,
|
||||
BigCycleCompletedCondition,
|
||||
ConditionResult,
|
||||
CuriosityCondition,
|
||||
EmotionChangeCondition,
|
||||
EvolutionAppliedCondition,
|
||||
MemoryRecallCondition,
|
||||
ProactiveCondition,
|
||||
RelationshipMilestoneCondition,
|
||||
TimeElapsedCondition,
|
||||
TimeOfDayCondition,
|
||||
UserReunionCondition,
|
||||
)
|
||||
from app.modules.knocking.pivot import KnockingPivot
|
||||
|
||||
__all__ = [
|
||||
"AnniversaryCondition",
|
||||
"BigCycleCompletedCondition",
|
||||
"ConditionResult",
|
||||
"CuriosityCondition",
|
||||
"EmotionChangeCondition",
|
||||
"EvolutionAppliedCondition",
|
||||
"KnockingPivot",
|
||||
"MemoryRecallCondition",
|
||||
"ProactiveCondition",
|
||||
"RelationshipMilestoneCondition",
|
||||
"TimeElapsedCondition",
|
||||
"TimeOfDayCondition",
|
||||
"UserReunionCondition",
|
||||
]
|
||||
@@ -0,0 +1,573 @@
|
||||
"""叩心枢触发条件引擎:接口与实现。
|
||||
|
||||
设计 docs/domain/core-domains.md §5。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.big_cycle import BigCycle
|
||||
from app.models.chat import ChatMessage
|
||||
from app.models.drive import CuriosityQuestion
|
||||
from app.models.evolution import EvolutionCandidate
|
||||
from app.models.memory import Memory
|
||||
from app.models.personality_emotion import CharacterEmotionState
|
||||
from app.models.time_anniversary import TimeAnniversary
|
||||
from app.modules.personality.emotion import EmotionState
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 条件接口
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConditionResult:
|
||||
"""条件检查结果。"""
|
||||
|
||||
triggered: bool = False
|
||||
"""是否触发。"""
|
||||
trigger_reason: str = ""
|
||||
"""触发理由描述(传入 opener prompt)。"""
|
||||
context: dict = field(default_factory=dict)
|
||||
"""开场上下文数据(可选的纪念日/好奇心问题等)。"""
|
||||
condition_name: str = ""
|
||||
"""触发条件的唯一名称(用于事件推送的 trigger 字段)。"""
|
||||
|
||||
|
||||
class ProactiveCondition:
|
||||
"""主动触发条件检查器基类。
|
||||
|
||||
子类重写 check() 方法,返回 ConditionResult。
|
||||
纯逻辑,不修改 DB——叩心枢主循环在 check 通过后才执行写入。
|
||||
"""
|
||||
|
||||
async def check(
|
||||
self,
|
||||
character_id: str,
|
||||
session: AsyncSession,
|
||||
*,
|
||||
now: datetime | None = None,
|
||||
) -> ConditionResult:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 第一梯队 ▸ 数据已在系统中流动
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TimeElapsedCondition(ProactiveCondition):
|
||||
"""空闲超时:查该角色最后一条消息时间,超过 min_idle_minutes 触发。
|
||||
|
||||
初次创建的角色(无历史消息)不触发——有意为之,避免新建角色立刻问候。
|
||||
"""
|
||||
|
||||
def __init__(self, min_idle_minutes: int = 360) -> None:
|
||||
self.min_idle_minutes = min_idle_minutes
|
||||
|
||||
async def check(
|
||||
self,
|
||||
character_id: str,
|
||||
session: AsyncSession,
|
||||
*,
|
||||
now: datetime | None = None,
|
||||
) -> ConditionResult:
|
||||
now = now or datetime.now(UTC)
|
||||
result = await session.execute(
|
||||
select(func.max(ChatMessage.created_at)).where(ChatMessage.character_id == character_id)
|
||||
)
|
||||
last_time: datetime | None = result.scalar()
|
||||
if last_time is None:
|
||||
return ConditionResult() # 无历史消息,不触发
|
||||
if last_time.tzinfo is None:
|
||||
last_time = last_time.replace(tzinfo=UTC)
|
||||
idle = (now - last_time).total_seconds() / 60
|
||||
if idle >= self.min_idle_minutes:
|
||||
hours = int(idle // 60)
|
||||
return ConditionResult(
|
||||
triggered=True,
|
||||
trigger_reason=f"距离上次对话已过去 {hours} 小时",
|
||||
condition_name="idle",
|
||||
)
|
||||
return ConditionResult()
|
||||
|
||||
|
||||
class TimeOfDayCondition(ProactiveCondition):
|
||||
"""时段问候:若当前时间在配置的时段窗口内,按窗口名触发。
|
||||
|
||||
windows 配置示例:["morning", "late_night"]
|
||||
默认时段映射:
|
||||
morning 06:00–09:00
|
||||
afternoon 12:00–14:00
|
||||
evening 17:00–19:00
|
||||
late_night 22:00–24:00
|
||||
"""
|
||||
|
||||
_WINDOWS: dict[str, tuple[int, int]] = {
|
||||
"morning": (6, 9),
|
||||
"afternoon": (12, 14),
|
||||
"evening": (17, 19),
|
||||
"late_night": (22, 24),
|
||||
}
|
||||
|
||||
def __init__(self, windows: list[str] | None = None) -> None:
|
||||
self.windows = windows or ["morning", "late_night"]
|
||||
|
||||
async def check(
|
||||
self,
|
||||
_character_id: str,
|
||||
_session: AsyncSession,
|
||||
*,
|
||||
now: datetime | None = None,
|
||||
) -> ConditionResult:
|
||||
now = now or datetime.now(UTC)
|
||||
hour = now.hour
|
||||
matched: str | None = None
|
||||
for name in self.windows:
|
||||
start, end = self._WINDOWS.get(name, (0, 0))
|
||||
if start <= hour < end:
|
||||
matched = name
|
||||
break
|
||||
if matched is None:
|
||||
return ConditionResult()
|
||||
label = {
|
||||
"morning": "早晨",
|
||||
"afternoon": "午后",
|
||||
"evening": "傍晚",
|
||||
"late_night": "深夜",
|
||||
}.get(matched, matched)
|
||||
return ConditionResult(
|
||||
triggered=True,
|
||||
trigger_reason=f"当前是{label}时段",
|
||||
context={"time_of_day": matched},
|
||||
condition_name="time_of_day",
|
||||
)
|
||||
|
||||
|
||||
class AnniversaryCondition(ProactiveCondition):
|
||||
"""纪念日匹配:每日首次 tick 检查 TimeAnniversary 表,匹配当天日期。
|
||||
|
||||
已通知标记:每次触发后写一条审计事件或专用记录 key,当天不重复。
|
||||
使用简单的轮次标记:本实现通过检查该 character 当天是否有
|
||||
已推送的 AnniversaryCondition 触发记录来判断——非精确消费语义,
|
||||
但对 30s tick 的场景已足够(最多漏 1 个 tick,不影响)。
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._checked_today: set[str] = set()
|
||||
"""{(character_id, date.isoformat())} 已检查过的组合——防止同一天多次 tick 重复推送。"""
|
||||
|
||||
async def check(
|
||||
self,
|
||||
character_id: str,
|
||||
session: AsyncSession,
|
||||
*,
|
||||
now: datetime | None = None,
|
||||
) -> ConditionResult:
|
||||
now = now or datetime.now(UTC)
|
||||
today = now.date().isoformat()
|
||||
key = f"{character_id}:{today}"
|
||||
if key in self._checked_today:
|
||||
return ConditionResult()
|
||||
self._checked_today.add(key)
|
||||
|
||||
result = await session.execute(
|
||||
select(TimeAnniversary).where(
|
||||
TimeAnniversary.character_id == character_id,
|
||||
TimeAnniversary.date == today,
|
||||
)
|
||||
)
|
||||
anniversaries: Sequence[TimeAnniversary] = result.scalars().all()
|
||||
if not anniversaries:
|
||||
return ConditionResult()
|
||||
|
||||
# 取第一个匹配的纪念日作为上下文
|
||||
ann = anniversaries[0]
|
||||
kind_labels = {"birthday": "生日", "reunion": "重逢纪念日", "custom": "纪念日"}
|
||||
kind_label = kind_labels.get(ann.kind, ann.kind)
|
||||
return ConditionResult(
|
||||
triggered=True,
|
||||
trigger_reason=f"今天是{kind_label}",
|
||||
context={
|
||||
"anniversary_kind": ann.kind,
|
||||
"anniversary_label": ann.name or kind_label,
|
||||
"anniversary_date": ann.date,
|
||||
},
|
||||
condition_name="anniversary",
|
||||
)
|
||||
|
||||
|
||||
class UserReunionCondition(ProactiveCondition):
|
||||
"""用户归来检测:通过 audit_event 或辰正事件判断用户是否刚回来。
|
||||
|
||||
查询最近一次 time.reunion 类型审计事件,若比上次消费的时间更新则触发。
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._last_seen_reunion: str | None = None
|
||||
"""上次已消费的 reunion 事件的 ID——避免重复触发。"""
|
||||
|
||||
async def check(
|
||||
self,
|
||||
character_id: str,
|
||||
session: AsyncSession,
|
||||
*,
|
||||
now: datetime | None = None, # noqa: ARG002
|
||||
) -> ConditionResult:
|
||||
from app.models.safety import AuditEvent
|
||||
|
||||
result = await session.execute(
|
||||
select(AuditEvent)
|
||||
.where(
|
||||
AuditEvent.character_id == character_id,
|
||||
AuditEvent.action == "time.reunion",
|
||||
)
|
||||
.order_by(AuditEvent.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
event = result.scalar_one_or_none()
|
||||
if event is None:
|
||||
return ConditionResult()
|
||||
if self._last_seen_reunion == event.id:
|
||||
return ConditionResult()
|
||||
self._last_seen_reunion = event.id
|
||||
return ConditionResult(
|
||||
triggered=True,
|
||||
trigger_reason="用户回到了灵台",
|
||||
condition_name="reunion",
|
||||
)
|
||||
|
||||
|
||||
class EmotionChangeCondition(ProactiveCondition):
|
||||
"""情绪变化:对比缓存的上一轮 EmotionState 快照,强度变化超过阈值触发。
|
||||
|
||||
同一 mood 变化后至少 recheck_cooldown_ticks 个 tick 内不再检查。
|
||||
"""
|
||||
|
||||
def __init__(self, min_delta: float = 0.3, recheck_cooldown_ticks: int = 10) -> None:
|
||||
self.min_delta = min_delta
|
||||
self.recheck_cooldown_ticks = recheck_cooldown_ticks
|
||||
self._previous: dict[str, EmotionState] = {}
|
||||
self._cooldown: dict[str, int] = {}
|
||||
|
||||
async def check(
|
||||
self,
|
||||
character_id: str,
|
||||
session: AsyncSession,
|
||||
*,
|
||||
now: datetime | None = None, # noqa: ARG002
|
||||
) -> ConditionResult:
|
||||
# 冷却检查
|
||||
if character_id in self._cooldown:
|
||||
remaining = self._cooldown[character_id]
|
||||
if remaining > 0:
|
||||
self._cooldown[character_id] = remaining - 1
|
||||
return ConditionResult()
|
||||
del self._cooldown[character_id]
|
||||
|
||||
# 读当前情绪
|
||||
result = await session.execute(
|
||||
select(CharacterEmotionState).where(CharacterEmotionState.character_id == character_id)
|
||||
)
|
||||
row = result.scalar_one_or_none()
|
||||
if row is None:
|
||||
return ConditionResult()
|
||||
current = EmotionState(mood=row.mood, intensity=row.intensity)
|
||||
|
||||
# 对比快照
|
||||
previous = self._previous.get(character_id)
|
||||
self._previous[character_id] = current # 更新快照
|
||||
if previous is None:
|
||||
return ConditionResult() # 首次无对比基线
|
||||
|
||||
if abs(current.intensity - previous.intensity) < self.min_delta:
|
||||
return ConditionResult()
|
||||
|
||||
# 触发
|
||||
mood_labels = {
|
||||
"neutral": "平静",
|
||||
"pleased": "愉悦",
|
||||
"touched": "感动",
|
||||
"wistful": "怅然怀念",
|
||||
"concerned": "关切",
|
||||
"festive": "喜庆",
|
||||
"melancholy": "惆怅",
|
||||
}
|
||||
from_label = mood_labels.get(previous.mood, previous.mood)
|
||||
to_label = mood_labels.get(current.mood, current.mood)
|
||||
intensity_pct = round(current.intensity * 100)
|
||||
self._cooldown[character_id] = self.recheck_cooldown_ticks
|
||||
|
||||
return ConditionResult(
|
||||
triggered=True,
|
||||
trigger_reason=f"情绪从「{from_label}」变为「{to_label}(强度 {intensity_pct}%)」",
|
||||
context={
|
||||
"from_mood": previous.mood,
|
||||
"to_mood": current.mood,
|
||||
"from_intensity": previous.intensity,
|
||||
"to_intensity": current.intensity,
|
||||
},
|
||||
condition_name="emotion",
|
||||
)
|
||||
|
||||
|
||||
class CuriosityCondition(ProactiveCondition):
|
||||
"""好奇心触发:查该角色是否有 CuriosityQuestion(status=active)未被用户看到。
|
||||
|
||||
取最新一条作为开场上下文。消费后应标记该问题为 'answered' 或 'seen'(由外部驱动,
|
||||
叩心枢只读不写)。
|
||||
"""
|
||||
|
||||
async def check(
|
||||
self,
|
||||
character_id: str,
|
||||
session: AsyncSession,
|
||||
*,
|
||||
now: datetime | None = None, # noqa: ARG002
|
||||
) -> ConditionResult:
|
||||
result = await session.execute(
|
||||
select(CuriosityQuestion)
|
||||
.where(CuriosityQuestion.character_id == character_id)
|
||||
.order_by(CuriosityQuestion.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
question = result.scalar_one_or_none()
|
||||
if question is None:
|
||||
return ConditionResult()
|
||||
return ConditionResult(
|
||||
triggered=True,
|
||||
trigger_reason="角色有一个想问的问题",
|
||||
context={
|
||||
"curiosity_question": question.question,
|
||||
"curiosity_id": question.id,
|
||||
},
|
||||
condition_name="curiosity",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 第二梯队 ▸ 需跨模块联动
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class EvolutionAppliedCondition(ProactiveCondition):
|
||||
"""演化应用:查 evolution_candidates 表,自上次检查后有新 applied 的记录则触发。
|
||||
|
||||
取最近一条 status=applied 的记录,用 title 和 proposal 作为开场上下文。
|
||||
这样角色可以自然地谈起自己的变化。
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._last_seen_at: datetime | None = None
|
||||
"""上次已触发的演化候选的 created_at。"""
|
||||
|
||||
async def check(
|
||||
self,
|
||||
character_id: str,
|
||||
session: AsyncSession,
|
||||
*,
|
||||
now: datetime | None = None, # noqa: ARG002
|
||||
) -> ConditionResult:
|
||||
result = await session.execute(
|
||||
select(EvolutionCandidate)
|
||||
.where(
|
||||
EvolutionCandidate.character_id == character_id,
|
||||
EvolutionCandidate.status == "applied",
|
||||
)
|
||||
.order_by(EvolutionCandidate.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
candidate = result.scalar_one_or_none()
|
||||
if candidate is None:
|
||||
return ConditionResult()
|
||||
if self._last_seen_at is not None and candidate.created_at <= self._last_seen_at:
|
||||
return ConditionResult()
|
||||
self._last_seen_at = candidate.created_at
|
||||
return ConditionResult(
|
||||
triggered=True,
|
||||
trigger_reason=f"发生了新的变化:{candidate.title}",
|
||||
context={
|
||||
"evolution_title": candidate.title,
|
||||
"evolution_proposal": candidate.proposal[:200],
|
||||
"evolution_level": candidate.level,
|
||||
},
|
||||
condition_name="evolution_applied",
|
||||
)
|
||||
|
||||
|
||||
class BigCycleCompletedCondition(ProactiveCondition):
|
||||
"""大周天完成:查 big_cycles 表,自上次检查后有新完成的循环则触发。
|
||||
|
||||
取最近一条 status=completed 的记录,从 summary 提取可分享的体悟。
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._last_seen_at: datetime | None = None
|
||||
"""上次已触发的循环的 completed_at。"""
|
||||
|
||||
async def check(
|
||||
self,
|
||||
character_id: str,
|
||||
session: AsyncSession,
|
||||
*,
|
||||
now: datetime | None = None, # noqa: ARG002
|
||||
) -> ConditionResult:
|
||||
result = await session.execute(
|
||||
select(BigCycle)
|
||||
.where(
|
||||
BigCycle.character_id == character_id,
|
||||
BigCycle.status == "completed",
|
||||
BigCycle.completed_at.isnot(None),
|
||||
)
|
||||
.order_by(BigCycle.completed_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
cycle = result.scalar_one_or_none()
|
||||
if cycle is None:
|
||||
return ConditionResult()
|
||||
if self._last_seen_at is not None and cycle.completed_at <= self._last_seen_at:
|
||||
return ConditionResult()
|
||||
self._last_seen_at = cycle.completed_at
|
||||
summary = cycle.summary or "回顾了一下最近的记忆"
|
||||
return ConditionResult(
|
||||
triggered=True,
|
||||
trigger_reason="大周天自我评估完成,有了新的体悟",
|
||||
context={
|
||||
"big_cycle_summary": summary,
|
||||
"big_cycle_trigger": cycle.trigger,
|
||||
},
|
||||
condition_name="big_cycle_completed",
|
||||
)
|
||||
|
||||
|
||||
class MemoryRecallCondition(ProactiveCondition):
|
||||
"""记忆唤醒:查角色的 memories 表,自上次检查后有新记忆则触发。
|
||||
|
||||
新记忆的写入源可能是:大周天抽取、资料导入、用户主动创建。
|
||||
每次 tick 取最新的未消费记忆作为开场素材。
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._last_seen_at: datetime | None = None
|
||||
"""上次已触发的记忆的 created_at。"""
|
||||
|
||||
async def check(
|
||||
self,
|
||||
character_id: str,
|
||||
session: AsyncSession,
|
||||
*,
|
||||
now: datetime | None = None, # noqa: ARG002
|
||||
) -> ConditionResult:
|
||||
result = await session.execute(
|
||||
select(Memory)
|
||||
.where(
|
||||
Memory.character_id == character_id,
|
||||
Memory.status == "active",
|
||||
)
|
||||
.order_by(Memory.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
memory = result.scalar_one_or_none()
|
||||
if memory is None:
|
||||
return ConditionResult()
|
||||
if self._last_seen_at is not None and memory.created_at <= self._last_seen_at:
|
||||
return ConditionResult()
|
||||
self._last_seen_at = memory.created_at
|
||||
return ConditionResult(
|
||||
triggered=True,
|
||||
trigger_reason=f"想起了一件关于「{memory.title}」的事",
|
||||
context={
|
||||
"memory_title": memory.title,
|
||||
"memory_content": memory.content[:200],
|
||||
"memory_type": memory.type,
|
||||
},
|
||||
condition_name="memory_recall",
|
||||
)
|
||||
|
||||
|
||||
class RelationshipMilestoneCondition(ProactiveCondition):
|
||||
"""关系里程碑:统计对话消息数与认识天数,达到阈值时触发。
|
||||
|
||||
每个角色只触发一次同类型的里程碑(记录到 _triggered 集合去重)。
|
||||
按 message_milestones → day_milestones 顺序检查,命中即停。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message_milestones: list[int] | None = None,
|
||||
day_milestones: list[int] | None = None,
|
||||
) -> None:
|
||||
self.message_milestones = message_milestones or [100, 500, 1000]
|
||||
self.day_milestones = day_milestones or [30, 180, 365]
|
||||
self._triggered: set[str] = set()
|
||||
"""已触发过的标记集合。格式:{character_id}:messages|days:{value}"""
|
||||
|
||||
async def check(
|
||||
self,
|
||||
character_id: str,
|
||||
session: AsyncSession,
|
||||
*,
|
||||
now: datetime | None = None,
|
||||
) -> ConditionResult:
|
||||
now = now or datetime.now(UTC)
|
||||
|
||||
# 1. 消息数里程碑
|
||||
count_result = await session.execute(
|
||||
select(func.count())
|
||||
.select_from(ChatMessage)
|
||||
.where(ChatMessage.character_id == character_id)
|
||||
)
|
||||
total_messages: int = count_result.scalar() or 0
|
||||
for threshold in sorted(self.message_milestones):
|
||||
key = f"{character_id}:messages:{threshold}"
|
||||
if key in self._triggered:
|
||||
continue
|
||||
if total_messages >= threshold:
|
||||
self._triggered.add(key)
|
||||
return ConditionResult(
|
||||
triggered=True,
|
||||
trigger_reason=f"我们已聊了 {total_messages} 条消息",
|
||||
context={
|
||||
"milestone_type": "messages",
|
||||
"milestone_value": total_messages,
|
||||
"milestone_threshold": threshold,
|
||||
},
|
||||
condition_name="relationship_milestone",
|
||||
)
|
||||
|
||||
# 2. 天数里程碑
|
||||
first_result = await session.execute(
|
||||
select(ChatMessage.created_at)
|
||||
.where(ChatMessage.character_id == character_id)
|
||||
.order_by(ChatMessage.created_at.asc())
|
||||
.limit(1)
|
||||
)
|
||||
first_msg_at: datetime | None = first_result.scalar()
|
||||
if first_msg_at is not None:
|
||||
if first_msg_at.tzinfo is None:
|
||||
first_msg_at = first_msg_at.replace(tzinfo=UTC)
|
||||
days = (now - first_msg_at).days
|
||||
for threshold in sorted(self.day_milestones):
|
||||
key = f"{character_id}:days:{threshold}"
|
||||
if key in self._triggered:
|
||||
continue
|
||||
if days >= threshold:
|
||||
self._triggered.add(key)
|
||||
return ConditionResult(
|
||||
triggered=True,
|
||||
trigger_reason=f"我们已认识了 {days} 天",
|
||||
context={
|
||||
"milestone_type": "days",
|
||||
"milestone_value": days,
|
||||
"milestone_threshold": threshold,
|
||||
},
|
||||
condition_name="relationship_milestone",
|
||||
)
|
||||
|
||||
return ConditionResult()
|
||||
@@ -0,0 +1,99 @@
|
||||
"""叩心枢会话/消息创建与一炁事件推送。
|
||||
|
||||
设计 docs/domain/core-domains.md§7。
|
||||
|
||||
主动会话优先复用已有活跃会话,避免每次触发都新建会话。
|
||||
只有在角色无活跃会话时才会创建新会话。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.logging import logger
|
||||
from app.models.chat import ChatMessage, ChatSession
|
||||
from app.modules.runtime.events import Event
|
||||
from app.services.event_store import DbEventStore
|
||||
|
||||
|
||||
async def create_proactive_message(
|
||||
session: AsyncSession,
|
||||
event_store: DbEventStore,
|
||||
character_id: str,
|
||||
opener_text: str,
|
||||
condition_name: str,
|
||||
*,
|
||||
session_id: str | None = None,
|
||||
now: datetime | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""创建叩心枢主动发起的消息,可追加到现有会话或新建会话。
|
||||
|
||||
策略:
|
||||
- 若 ``session_id`` 不为空,追加消息到该会话(不创建新会话)
|
||||
- 若 ``session_id`` 为空,新建会话 + 消息
|
||||
|
||||
Returns:
|
||||
{"session_id": str, "message_id": str}: 实际写入的会话 ID 和消息 ID。
|
||||
"""
|
||||
now = now or datetime.now(UTC)
|
||||
is_new = session_id is None
|
||||
|
||||
if is_new:
|
||||
# 创建新会话(status 默认 'active')
|
||||
chat_session = ChatSession(
|
||||
character_id=character_id,
|
||||
title=opener_text[:60],
|
||||
)
|
||||
session.add(chat_session)
|
||||
await session.flush()
|
||||
session_id = chat_session.id
|
||||
event_type = "proactive.session"
|
||||
else:
|
||||
event_type = "proactive.message"
|
||||
|
||||
# 创建开场消息(无 run_id——非流式、无推理过程)
|
||||
message = ChatMessage(
|
||||
character_id=character_id,
|
||||
session_id=session_id,
|
||||
role="assistant",
|
||||
modality="text",
|
||||
content=opener_text,
|
||||
)
|
||||
session.add(message)
|
||||
await session.flush()
|
||||
|
||||
# 推一炁事件
|
||||
payload: dict[str, Any] = {
|
||||
"session_id": session_id,
|
||||
"message_id": message.id,
|
||||
"character_id": character_id,
|
||||
"preview": opener_text[:100],
|
||||
"trigger": condition_name,
|
||||
"is_new": is_new,
|
||||
}
|
||||
if is_new:
|
||||
payload["title_preview"] = opener_text[:60]
|
||||
|
||||
event = Event(
|
||||
type=event_type,
|
||||
source="knocking_pivot",
|
||||
character_id=character_id,
|
||||
payload=payload,
|
||||
visibility="public",
|
||||
created_at=now.isoformat(),
|
||||
)
|
||||
# 复用调用方的 session——同一事务写 events 表,避免 session A 写 chat 表、
|
||||
# session B 写 events 表时两条连接争写导致锁冲突
|
||||
await event_store.append(event, session=session)
|
||||
|
||||
logger.info(
|
||||
f"knocking_pivot_{'session_created' if is_new else 'message_appended'}",
|
||||
character_id=character_id,
|
||||
session_id=session_id,
|
||||
message_id=message.id,
|
||||
trigger=condition_name,
|
||||
)
|
||||
return {"session_id": session_id, "message_id": message.id}
|
||||
@@ -0,0 +1,190 @@
|
||||
"""叩心枢开场消息生成器。
|
||||
|
||||
设计 docs/domain/core-domains.md §6。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from app.ai_provider.manager import ProviderManagerFactory
|
||||
from app.core.config import settings
|
||||
from app.core.logging import logger
|
||||
from app.modules.knocking.conditions import ConditionResult
|
||||
from app.modules.personality.emotion import EmotionState, to_prompt_text
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 回退模板
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_FALLBACK_TEMPLATES: dict[str, str] = {
|
||||
"idle": "{{name}}默默地看了你一眼。",
|
||||
"time_of_day": "{{name}}正望着窗外出神。",
|
||||
"anniversary": "今日似乎是个特别的日子。",
|
||||
"reunion": "{{name}}注意到你回来了。",
|
||||
"emotion": "{{name}}轻轻叹了口气。",
|
||||
"curiosity": "{{name}}似乎有什么话想说。",
|
||||
"memory_recall": "{{name}}若有所思地望向远方。",
|
||||
"big_cycle_completed": "{{name}}似乎有所感悟。",
|
||||
"evolution_applied": "{{name}}看上去有些不一样了。",
|
||||
"relationship_milestone": "{{name}}望着你,嘴角泛起一丝笑意。",
|
||||
"default": "{{name}}若有所思。",
|
||||
}
|
||||
|
||||
_FALLBACK = "{{name}}正望着窗外出神。"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChatMessageBrief:
|
||||
"""简略对话记录(只携带必要字段,避免依赖 ORM)。"""
|
||||
|
||||
role: str = ""
|
||||
content: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class OpenerResult:
|
||||
"""开场消息生成结果。"""
|
||||
|
||||
text: str
|
||||
used_fallback: bool = False
|
||||
|
||||
|
||||
async def generate_opener(
|
||||
character_id: str,
|
||||
character_name: str,
|
||||
personality_summary: str | None,
|
||||
emotion: EmotionState | None,
|
||||
condition_result: ConditionResult,
|
||||
*,
|
||||
chat_history: list[ChatMessageBrief] | None = None,
|
||||
memory_context: str | None = None,
|
||||
now: datetime | None = None,
|
||||
) -> OpenerResult:
|
||||
"""根据条件触发结果生成一句开场白。
|
||||
|
||||
依次尝试:
|
||||
1. 调用神识(LLM)生成带人格注入的开场白
|
||||
2. 失败时使用模板化回退
|
||||
|
||||
若提供 ``chat_history``,会将其拼入 prompt 使开场更贴合最近对话脉络。
|
||||
若提供 ``memory_context``,会将其作为角色的长期记忆注入 prompt。
|
||||
"""
|
||||
now = now or datetime.now(UTC)
|
||||
timestamp = now.strftime("%Y-%m-%d %H:%M")
|
||||
|
||||
# 构造 prompt
|
||||
emotion_text = to_prompt_text(emotion) if emotion else "平静"
|
||||
summary = personality_summary or f"你是一个名叫{character_name}的角色。{_DEFAULT_PERSONA}"
|
||||
trigger_reason = condition_result.trigger_reason or ""
|
||||
|
||||
context_parts = _build_context_parts(condition_result)
|
||||
|
||||
system_prompt = _build_system_prompt(summary, emotion_text, timestamp)
|
||||
user_prompt = _build_user_prompt(
|
||||
character_name, trigger_reason, context_parts, chat_history, memory_context
|
||||
)
|
||||
|
||||
try:
|
||||
manager = ProviderManagerFactory.create()
|
||||
result = await manager.chat(
|
||||
manager.to_message_list([system_prompt], [("user", user_prompt)]),
|
||||
model_hint=settings.big_cycle_model,
|
||||
)
|
||||
text = result.content.strip()
|
||||
if not text:
|
||||
raise ValueError("模型返回空开场文本")
|
||||
return OpenerResult(text=text, used_fallback=False)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"knocking_pivot_opener_fallback",
|
||||
character_id=character_id,
|
||||
condition=condition_result.condition_name,
|
||||
error=str(exc),
|
||||
)
|
||||
template = _FALLBACK_TEMPLATES.get(condition_result.condition_name, _FALLBACK)
|
||||
fallback = template.replace("{{name}}", character_name)
|
||||
return OpenerResult(text=fallback, used_fallback=True)
|
||||
|
||||
|
||||
def _build_context_parts(condition_result: ConditionResult) -> list[str]:
|
||||
"""组装条件相关的上下文片段。"""
|
||||
parts: list[str] = []
|
||||
if condition_result.context.get("anniversary_kind"):
|
||||
kind_label = condition_result.context.get("anniversary_label", "")
|
||||
parts.append(f"今天是一个特别的日子:{kind_label}。")
|
||||
if condition_result.context.get("curiosity_question"):
|
||||
parts.append(
|
||||
f"你一直想问用户一个问题:「{condition_result.context['curiosity_question']}」"
|
||||
)
|
||||
if condition_result.context.get("time_of_day") == "late_night":
|
||||
parts.append("夜深了,万籁俱寂。")
|
||||
if condition_result.context.get("memory_title"):
|
||||
title = condition_result.context["memory_title"]
|
||||
parts.append(f"你最近想起了一件事——「{title}」。")
|
||||
if condition_result.context.get("big_cycle_summary"):
|
||||
parts.append("你刚刚审视过自己的内心,有了一些新的思考。")
|
||||
if condition_result.context.get("evolution_title"):
|
||||
title = condition_result.context["evolution_title"]
|
||||
parts.append(f"你感觉自己有了一些新的变化:{title}。")
|
||||
if condition_result.context.get("milestone_type") == "messages":
|
||||
count = condition_result.context["milestone_value"]
|
||||
parts.append(f"你和用户的对话已经累积了 {count} 条。")
|
||||
if condition_result.context.get("milestone_type") == "days":
|
||||
days = condition_result.context["milestone_value"]
|
||||
parts.append(f"从你们第一次对话至今,已经过去了 {days} 天。")
|
||||
return parts
|
||||
|
||||
|
||||
def _format_chat_history(history: list[ChatMessageBrief]) -> str:
|
||||
"""将最近对话历史格式化为文本。"""
|
||||
lines = ["\n[最近对话记录]"]
|
||||
for msg in history:
|
||||
speaker = "你" if msg.role == "assistant" else "用户"
|
||||
lines.append(f"{speaker}:{msg.content[:200]}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _build_system_prompt(personality_summary: str, emotion_text: str, timestamp: str) -> str:
|
||||
return (
|
||||
"你是一位角色扮演助手。以下是你的人物设定:\n"
|
||||
f"{personality_summary}\n\n"
|
||||
"[当前状态]\n"
|
||||
f"- 当前时间: {timestamp}\n"
|
||||
f"- 情绪状态: {emotion_text}\n"
|
||||
)
|
||||
|
||||
|
||||
def _build_user_prompt(
|
||||
character_name: str,
|
||||
trigger_reason: str,
|
||||
context_parts: list[str],
|
||||
chat_history: list[ChatMessageBrief] | None = None,
|
||||
memory_context: str | None = None,
|
||||
) -> str:
|
||||
lines = ["[任务]"]
|
||||
lines.append(f"根据以上设定,生成一句自然、贴合{character_name}性格和当前情绪的开场白。")
|
||||
lines.append("要求:")
|
||||
lines.append("1. 一句话,不超过 80 字")
|
||||
lines.append("2. 符合角色的表达风格和说话方式、语气")
|
||||
lines.append("3. 反映当前情绪状态")
|
||||
lines.append("4. 不要问「你还在吗?」之类的问题")
|
||||
lines.append("5. 直接输出开场白,不要任何前缀或引号")
|
||||
if trigger_reason:
|
||||
lines.append(f"\n触发原因:{trigger_reason}")
|
||||
if context_parts:
|
||||
lines.append(f"\n{' '.join(context_parts)}")
|
||||
if memory_context:
|
||||
lines.append(f"\n{memory_context}")
|
||||
if chat_history:
|
||||
lines.append(_format_chat_history(chat_history))
|
||||
lines.append("\n注意:延续上次对话的语境自然地开启新话题,不要重复上一句的内容。")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
_DEFAULT_PERSONA = (
|
||||
"你对用户保持着温和的关注,"
|
||||
"平日里话不多,但每次开口都带着真诚。"
|
||||
"你善于观察,能从细微之处感知对方的情绪。"
|
||||
)
|
||||
@@ -0,0 +1,366 @@
|
||||
"""叩心枢(KnockingPivot)主循环。
|
||||
|
||||
人物主动对话守护进程:后台常驻循环(~30s tick),
|
||||
遍历活跃角色 → 检查触发条件 → 生成开场 → 创建会话/消息 → 推一炁事件。
|
||||
同时订阅一炁事件流(big_cycle.* / reflection.*)实现近实时快速路径。
|
||||
|
||||
叩心枢与辰正(时感)、灵核(情绪)并列——辰正提供时态语境,
|
||||
灵核产出情绪状态,叩心判断「此时该不该开口」。
|
||||
|
||||
设计 docs/domain/core-domains.md §4。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from app.core.logging import logger
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.models.character import Character
|
||||
from app.models.chat import ChatMessage, ChatSession
|
||||
from app.models.personality_emotion import CharacterEmotionState
|
||||
from app.modules.knocking.conditions import (
|
||||
AnniversaryCondition,
|
||||
BigCycleCompletedCondition,
|
||||
CuriosityCondition,
|
||||
EmotionChangeCondition,
|
||||
EvolutionAppliedCondition,
|
||||
MemoryRecallCondition,
|
||||
ProactiveCondition,
|
||||
RelationshipMilestoneCondition,
|
||||
TimeElapsedCondition,
|
||||
TimeOfDayCondition,
|
||||
UserReunionCondition,
|
||||
)
|
||||
from app.modules.knocking.creator import create_proactive_message
|
||||
from app.modules.knocking.opener import ChatMessageBrief, generate_opener
|
||||
from app.modules.personality.emotion import EmotionState
|
||||
from app.modules.runtime.events import Event
|
||||
from app.services.event_store import DbEventStore
|
||||
from app.services.memory import memory_context as _fmt_memory_context
|
||||
from app.services.personality import build_personality_summary
|
||||
|
||||
|
||||
class KnockingPivot:
|
||||
"""叩心枢主动对话守护进程。
|
||||
|
||||
用法:
|
||||
pivot = KnockingPivot(event_store)
|
||||
await pivot.start()
|
||||
...
|
||||
await pivot.stop()
|
||||
"""
|
||||
|
||||
# 条件注册表(第一梯队 + 第二梯队 ▸ 按优先级排列)
|
||||
conditions: list[ProactiveCondition] = [
|
||||
EvolutionAppliedCondition(),
|
||||
BigCycleCompletedCondition(),
|
||||
TimeElapsedCondition(min_idle_minutes=360),
|
||||
TimeOfDayCondition(windows=["morning", "late_night"]),
|
||||
AnniversaryCondition(),
|
||||
UserReunionCondition(),
|
||||
EmotionChangeCondition(min_delta=0.3),
|
||||
MemoryRecallCondition(),
|
||||
RelationshipMilestoneCondition(),
|
||||
CuriosityCondition(),
|
||||
]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
event_store: DbEventStore,
|
||||
session_factory: async_sessionmaker[AsyncSession] | None = None,
|
||||
*,
|
||||
tick_interval: float = 30.0,
|
||||
cooldown_minutes: int = 30,
|
||||
daily_limit: int = 3,
|
||||
quiet_hours_start: int = 23,
|
||||
quiet_hours_end: int = 8,
|
||||
) -> None:
|
||||
self._event_store = event_store
|
||||
self._session_factory = session_factory or AsyncSessionLocal
|
||||
self._tick_interval = tick_interval
|
||||
self._cooldown_minutes = cooldown_minutes
|
||||
self._daily_limit = daily_limit
|
||||
self._quiet_hours_start = quiet_hours_start
|
||||
self._quiet_hours_end = quiet_hours_end
|
||||
|
||||
self._task: asyncio.Task[None] | None = None
|
||||
self._event_task: asyncio.Task[None] | None = None
|
||||
self._stop_event = asyncio.Event()
|
||||
self._event_queue: asyncio.Queue[Event] | None = None
|
||||
|
||||
# per-character 冷启动冷却(character_id → 下次可触发时间)
|
||||
self._cooldowns: dict[str, datetime] = {}
|
||||
# per-character 日计数(character_id → 今日日期 → 次数)
|
||||
self._daily_counts: dict[str, tuple[str, int]] = {}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 生命周期
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def start(self) -> None:
|
||||
if self._task is not None:
|
||||
logger.warning("knocking_pivot_already_running")
|
||||
return
|
||||
self._stop_event.clear()
|
||||
|
||||
# 订阅一炁事件流(大周天/小周天完成等),实现近实时快速路径
|
||||
self._event_queue = self._event_store.subscribe_by_pattern("big_cycle.*,reflection.*")
|
||||
self._event_task = asyncio.create_task(self._run_event_consumer())
|
||||
|
||||
self._task = asyncio.create_task(self._run_loop())
|
||||
logger.info("knocking_pivot_started", tick_interval=self._tick_interval)
|
||||
|
||||
async def stop(self) -> None:
|
||||
if self._task is None:
|
||||
return
|
||||
self._stop_event.set()
|
||||
self._task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await self._task
|
||||
self._task = None
|
||||
if self._event_task is not None:
|
||||
self._event_task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await self._event_task
|
||||
self._event_task = None
|
||||
if self._event_queue is not None:
|
||||
self._event_store.unsubscribe_by_pattern("big_cycle.*,reflection.*", self._event_queue)
|
||||
self._event_queue = None
|
||||
logger.info("knocking_pivot_stopped")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 主循环
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _run_loop(self) -> None:
|
||||
while not self._stop_event.is_set():
|
||||
try:
|
||||
await self._tick()
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as exc:
|
||||
err_str = str(exc)
|
||||
if "database is locked" in err_str:
|
||||
logger.warning(
|
||||
"knocking_pivot_db_locked_retry",
|
||||
error=err_str[:120],
|
||||
)
|
||||
else:
|
||||
logger.error("knocking_pivot_tick_error", error=err_str)
|
||||
await asyncio.sleep(self._tick_interval)
|
||||
|
||||
async def _run_event_consumer(self) -> None:
|
||||
"""事件消费者:订阅 big_cycle.* / reflection.* 事件,近实时触发叩心检查。
|
||||
|
||||
与 _run_loop 并行运行,事件驱动而非轮询驱动。
|
||||
每个事件提取 character_id,立即做一次条件检查(跳过冷却/日限等节流逻辑用轮询管理)。
|
||||
"""
|
||||
queue = self._event_queue
|
||||
if queue is None:
|
||||
return
|
||||
while not self._stop_event.is_set():
|
||||
try:
|
||||
event = await asyncio.wait_for(queue.get(), timeout=30)
|
||||
except TimeoutError:
|
||||
continue
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
try:
|
||||
await self._handle_event(event)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.warning("knocking_pivot_event_error", event_type=event.type)
|
||||
|
||||
async def _handle_event(self, event: Event) -> None:
|
||||
"""响应一炁事件:提取 character_id,尝试用对应条件触发。"""
|
||||
character_id = event.character_id
|
||||
if not character_id:
|
||||
return
|
||||
now = datetime.now(UTC)
|
||||
if self._in_quiet_hours(now):
|
||||
return
|
||||
if not self._can_trigger(character_id, now):
|
||||
return
|
||||
|
||||
# 大周天完成 → 快速检查 BigCycleCompletedCondition
|
||||
if event.type == "big_cycle.completed":
|
||||
async with self._session_factory() as session:
|
||||
cond = BigCycleCompletedCondition()
|
||||
result = await cond.check(character_id, session, now=now)
|
||||
if result.triggered:
|
||||
await self._execute_trigger(character_id, result, session, now)
|
||||
self._mark_triggered(character_id, now)
|
||||
return
|
||||
|
||||
# 反思完成 → 快速检查 MemoryRecallCondition
|
||||
if event.type == "reflection.completed":
|
||||
async with self._session_factory() as session:
|
||||
cond = MemoryRecallCondition()
|
||||
result = await cond.check(character_id, session, now=now)
|
||||
if result.triggered:
|
||||
await self._execute_trigger(character_id, result, session, now)
|
||||
self._mark_triggered(character_id, now)
|
||||
return
|
||||
|
||||
async def _tick(self) -> None:
|
||||
"""一次 tick:找出活跃角色 → 逐角色检查条件。"""
|
||||
now = datetime.now(UTC)
|
||||
if self._in_quiet_hours(now):
|
||||
return
|
||||
|
||||
async with self._session_factory() as session:
|
||||
active_characters = await self._list_active_characters(session)
|
||||
if not active_characters:
|
||||
return
|
||||
|
||||
for character_id in active_characters:
|
||||
if not self._can_trigger(character_id, now):
|
||||
continue
|
||||
if await self._check_and_trigger(character_id, session, now):
|
||||
# 每次 tick 只触发一个角色——命中即停
|
||||
break
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 内部
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _list_active_characters(self, session: AsyncSession) -> list[str]:
|
||||
"""返回全部 status='active' 的 character_id。"""
|
||||
result = await session.execute(select(Character.id).where(Character.status == "active"))
|
||||
return [row[0] for row in result.all()]
|
||||
|
||||
def _in_quiet_hours(self, now: datetime) -> bool:
|
||||
hour = now.hour
|
||||
if self._quiet_hours_start <= self._quiet_hours_end:
|
||||
return self._quiet_hours_start <= hour < self._quiet_hours_end
|
||||
return hour >= self._quiet_hours_start or hour < self._quiet_hours_end
|
||||
|
||||
def _can_trigger(self, character_id: str, now: datetime) -> bool:
|
||||
# 冷却
|
||||
cooldown_until = self._cooldowns.get(character_id)
|
||||
if cooldown_until is not None and now < cooldown_until:
|
||||
return False
|
||||
# 日上限
|
||||
today = now.date().isoformat()
|
||||
last_date, count = self._daily_counts.get(character_id, ("", 0))
|
||||
return not (last_date == today and count >= self._daily_limit)
|
||||
|
||||
def _mark_triggered(self, character_id: str, now: datetime) -> None:
|
||||
self._cooldowns[character_id] = now + timedelta(minutes=self._cooldown_minutes)
|
||||
today = now.date().isoformat()
|
||||
last_date, count = self._daily_counts.get(character_id, ("", 0))
|
||||
if last_date == today:
|
||||
self._daily_counts[character_id] = (today, count + 1)
|
||||
else:
|
||||
self._daily_counts[character_id] = (today, 1)
|
||||
|
||||
async def _check_and_trigger(
|
||||
self, character_id: str, session: AsyncSession, now: datetime
|
||||
) -> bool:
|
||||
"""遍历条件列表,任一触发则执行并返回 True;否则返回 False。"""
|
||||
for condition in self.conditions:
|
||||
result = await condition.check(character_id, session, now=now)
|
||||
if not result.triggered:
|
||||
continue
|
||||
|
||||
await self._execute_trigger(character_id, result, session, now)
|
||||
self._mark_triggered(character_id, now)
|
||||
return True
|
||||
return False
|
||||
|
||||
async def _execute_trigger(
|
||||
self,
|
||||
character_id: str,
|
||||
condition_result,
|
||||
session: AsyncSession,
|
||||
now: datetime,
|
||||
) -> None:
|
||||
"""条件触发后的执行链:人格注入 → 开场(带上下文)→ 追加/新建会话 → 推事件。"""
|
||||
try:
|
||||
# 1. 加载角色对象(供人格摘要使用)
|
||||
result = await session.execute(select(Character).where(Character.id == character_id))
|
||||
character = result.scalar_one_or_none()
|
||||
if character is None:
|
||||
logger.warning(
|
||||
"knocking_pivot_character_not_found",
|
||||
character_id=character_id,
|
||||
)
|
||||
return
|
||||
|
||||
# 2. 人格注入
|
||||
summary = await build_personality_summary(session, character)
|
||||
|
||||
# 3. 读取情绪
|
||||
emotion = await self._get_emotion(session, character_id)
|
||||
|
||||
# 4. 查找该角色已有的活跃会话(优先复用)
|
||||
session_result = await session.execute(
|
||||
select(ChatSession)
|
||||
.where(
|
||||
ChatSession.character_id == character_id,
|
||||
ChatSession.status == "active",
|
||||
)
|
||||
.order_by(ChatSession.updated_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
existing_session = session_result.scalar_one_or_none()
|
||||
|
||||
# 5. 读取最近对话记录作为上下文
|
||||
chat_history: list[ChatMessageBrief] = []
|
||||
if existing_session:
|
||||
msg_result = await session.execute(
|
||||
select(ChatMessage)
|
||||
.where(ChatMessage.session_id == existing_session.id)
|
||||
.order_by(ChatMessage.created_at.desc())
|
||||
.limit(5)
|
||||
)
|
||||
raw = list(reversed(msg_result.scalars().all()))
|
||||
chat_history = [ChatMessageBrief(role=m.role, content=m.content) for m in raw]
|
||||
|
||||
# 6. 检索角色的长期记忆作为上下文
|
||||
memory_context: str | None = None
|
||||
mc = await _fmt_memory_context(session, character_id, limit=5)
|
||||
memory_context = mc if mc else None
|
||||
|
||||
# 7. 生成开场(携带上下文与记忆)
|
||||
character_name = character.display_name or character.name or character_id
|
||||
opener = await generate_opener(
|
||||
character_id=character_id,
|
||||
character_name=character_name,
|
||||
personality_summary=summary.text if summary and summary.text else None,
|
||||
emotion=emotion,
|
||||
condition_result=condition_result,
|
||||
chat_history=chat_history,
|
||||
memory_context=memory_context,
|
||||
now=now,
|
||||
)
|
||||
|
||||
# 8. 追加消息(复用已有会话,或创建新会话)
|
||||
existing_id = existing_session.id if existing_session else None
|
||||
await create_proactive_message(
|
||||
session=session,
|
||||
event_store=self._event_store,
|
||||
character_id=character_id,
|
||||
session_id=existing_id,
|
||||
opener_text=opener.text,
|
||||
condition_name=condition_result.condition_name,
|
||||
now=now,
|
||||
)
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
|
||||
async def _get_emotion(self, session: AsyncSession, character_id: str) -> EmotionState | None:
|
||||
result = await session.execute(
|
||||
select(CharacterEmotionState).where(CharacterEmotionState.character_id == character_id)
|
||||
)
|
||||
row = result.scalar_one_or_none()
|
||||
if row is None:
|
||||
return None
|
||||
return EmotionState(mood=row.mood, intensity=row.intensity)
|
||||
@@ -0,0 +1,20 @@
|
||||
"""世谱(Lorebook / World Info)—— 关键词触发的世界设定注入。
|
||||
|
||||
纯逻辑模块(禁 IO):匹配 / 预算裁剪 / 注入分组。
|
||||
IO(查条目、扫对话历史)由集成层(send_message)负责,结果以值传入。
|
||||
设计见 docs/contracts/contract-glossary.md §7。
|
||||
"""
|
||||
|
||||
from app.modules.lorebook.budget import TokenCounter, build_injections, select_within_budget
|
||||
from app.modules.lorebook.matching import match_entries
|
||||
from app.modules.lorebook.types import LorebookBook, LorebookEntry, MatchedEntry
|
||||
|
||||
__all__ = [
|
||||
"LorebookBook",
|
||||
"LorebookEntry",
|
||||
"MatchedEntry",
|
||||
"TokenCounter",
|
||||
"build_injections",
|
||||
"match_entries",
|
||||
"select_within_budget",
|
||||
]
|
||||
@@ -0,0 +1,58 @@
|
||||
"""世谱预算裁剪与注入分组。
|
||||
|
||||
按 token 预算裁剪(priority 值小优先保留),再按 insertion_order 排定顺序,
|
||||
最后按 position 分组为可注入的文本块。纯逻辑,无 IO。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
|
||||
from app.modules.lorebook.types import MatchedEntry
|
||||
|
||||
TokenCounter = Callable[[str], int]
|
||||
|
||||
_POSITIONS = ("before_char", "after_char")
|
||||
|
||||
|
||||
def _default_token_counter(text: str) -> int:
|
||||
"""P0 以字符数近似 token;后续接神识 tokenizer。"""
|
||||
return len(text)
|
||||
|
||||
|
||||
def select_within_budget(
|
||||
matched: list[MatchedEntry],
|
||||
token_budget: int | None = None,
|
||||
token_counter: TokenCounter | None = None,
|
||||
) -> list[MatchedEntry]:
|
||||
"""按 priority 选入预算内条目,再按 insertion_order 排定注入顺序。"""
|
||||
counter = token_counter or _default_token_counter
|
||||
if token_budget is None:
|
||||
return sorted(matched, key=lambda m: m.entry.insertion_order)
|
||||
|
||||
by_priority = sorted(matched, key=lambda m: m.entry.priority)
|
||||
kept: list[MatchedEntry] = []
|
||||
total = 0
|
||||
for m in by_priority:
|
||||
cost = counter(m.entry.content)
|
||||
if cost == 0:
|
||||
continue
|
||||
if total + cost > token_budget:
|
||||
continue
|
||||
kept.append(m)
|
||||
total += cost
|
||||
kept.sort(key=lambda m: m.entry.insertion_order)
|
||||
return kept
|
||||
|
||||
|
||||
def build_injections(matched: list[MatchedEntry]) -> dict[str, str]:
|
||||
"""按 position 分组,合并同组条目 content 为单段文本。
|
||||
|
||||
返回 ``{"before_char": "...", "after_char": "..."}``,空组不出现在结果中。
|
||||
集成层据此把 before_char 插在道体 personality_summary 之前、after_char 之后。
|
||||
"""
|
||||
groups: dict[str, list[str]] = {pos: [] for pos in _POSITIONS}
|
||||
for m in matched:
|
||||
pos = m.entry.position if m.entry.position in groups else "after_char"
|
||||
groups[pos].append(m.entry.content)
|
||||
return {pos: "\n".join(parts) for pos, parts in groups.items() if parts}
|
||||
@@ -0,0 +1,80 @@
|
||||
"""世谱匹配引擎:关键词 / 正则 / selective / constant / 递归扫描。
|
||||
|
||||
纯逻辑,无 IO。扫描文本由调用方(集成层)从对话历史截取后传入。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from app.modules.lorebook.types import LorebookBook, LorebookEntry, MatchedEntry
|
||||
|
||||
_RECURSION_MAX_ITERATIONS = 5
|
||||
|
||||
|
||||
def match_entries(book: LorebookBook, scan_text: str) -> list[MatchedEntry]:
|
||||
"""返回命中的条目(含 constant 与关键词命中);递归扫描按 book 配置。"""
|
||||
matched: list[MatchedEntry] = []
|
||||
for entry in book.entries:
|
||||
if not entry.enabled:
|
||||
continue
|
||||
if entry.constant:
|
||||
matched.append(MatchedEntry(entry=entry, reason="constant"))
|
||||
continue
|
||||
if _entry_matches(entry, scan_text):
|
||||
matched.append(MatchedEntry(entry=entry, reason="key"))
|
||||
|
||||
if book.recursive_scanning:
|
||||
matched = _expand_recursive(matched, book, scan_text)
|
||||
return matched
|
||||
|
||||
|
||||
def _entry_matches(entry: LorebookEntry, scan_text: str) -> bool:
|
||||
if entry.selective and entry.secondary_keys:
|
||||
return _any_key_hit(entry.keys, entry, scan_text) and _any_key_hit(
|
||||
entry.secondary_keys, entry, scan_text
|
||||
)
|
||||
return _any_key_hit(entry.keys, entry, scan_text)
|
||||
|
||||
|
||||
def _any_key_hit(keys: list[str], entry: LorebookEntry, scan_text: str) -> bool:
|
||||
for key in keys:
|
||||
if not key:
|
||||
continue
|
||||
if entry.use_regex:
|
||||
flags = 0 if entry.case_sensitive else re.IGNORECASE
|
||||
try:
|
||||
if re.search(key, scan_text, flags):
|
||||
return True
|
||||
except re.error:
|
||||
# 非法正则降级为字面量包含
|
||||
pass
|
||||
else:
|
||||
hay = scan_text if entry.case_sensitive else scan_text.casefold()
|
||||
needle = key if entry.case_sensitive else key.casefold()
|
||||
if needle in hay:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _expand_recursive(
|
||||
matched: list[MatchedEntry], book: LorebookBook, original_text: str
|
||||
) -> list[MatchedEntry]:
|
||||
"""已命中条目的 content 追加进扫描文本,再扫未命中条目;限迭代防振荡。"""
|
||||
hit_ids = {m.entry.id for m in matched}
|
||||
pool = list(matched)
|
||||
extra_text = original_text + "\n" + "\n".join(m.entry.content for m in matched)
|
||||
|
||||
for _ in range(_RECURSION_MAX_ITERATIONS):
|
||||
grew = False
|
||||
for entry in book.entries:
|
||||
if entry.id in hit_ids or not entry.enabled or entry.constant:
|
||||
continue
|
||||
if _entry_matches(entry, extra_text):
|
||||
pool.append(MatchedEntry(entry=entry, reason="recursive"))
|
||||
hit_ids.add(entry.id)
|
||||
extra_text += "\n" + entry.content
|
||||
grew = True
|
||||
if not grew:
|
||||
break
|
||||
return pool
|
||||
@@ -0,0 +1,51 @@
|
||||
"""世谱(Lorebook / World Info)数据结构。
|
||||
|
||||
兼容酒馆 V2/V3 条目字段 + 魂镜扩展(绑定河图实体、作用域)。
|
||||
枚举取值(position / scope)以契约字典为准;本文仅定义载体。
|
||||
设计见 docs/contracts/contract-glossary.md §7(世谱)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class LorebookEntry(BaseModel):
|
||||
"""单条世谱条目:关键词命中后注入其 content。"""
|
||||
|
||||
id: str
|
||||
keys: list[str] = Field(default_factory=list)
|
||||
content: str = ""
|
||||
secondary_keys: list[str] = Field(default_factory=list)
|
||||
constant: bool = False # 恒定注入(预算内始终命中)
|
||||
selective: bool = False # 为真时需同时命中 keys 与 secondary_keys
|
||||
enabled: bool = True
|
||||
case_sensitive: bool = False
|
||||
use_regex: bool = False # 为真时 keys 按正则匹配
|
||||
# before_char:角色定义(道体 personality_summary)之前;after_char:之后
|
||||
position: str = "after_char"
|
||||
insertion_order: int = 100 # 值越小,注入位置越靠前
|
||||
priority: int = 10 # 预算紧张时值越小越先保留
|
||||
comment: str = ""
|
||||
# 魂镜扩展:绑定河图世界实体,实体被提及时触发(关键词之外的第二引擎)
|
||||
bound_entity_id: str | None = None
|
||||
|
||||
|
||||
class LorebookBook(BaseModel):
|
||||
"""一本世谱:条目集合 + 扫描/预算参数。"""
|
||||
|
||||
id: str
|
||||
name: str = ""
|
||||
description: str = ""
|
||||
entries: list[LorebookEntry] = Field(default_factory=list)
|
||||
scan_depth: int | None = None # 扫描最近 N 条对话;None 表示扫全部传入文本
|
||||
token_budget: int | None = None # 注入 token 预算上限;None 表示不裁剪
|
||||
recursive_scanning: bool = False # 已命中条目 content 可触发其他条目
|
||||
scope: str = "character" # character | global
|
||||
|
||||
|
||||
class MatchedEntry(BaseModel):
|
||||
"""一次匹配的结果:命中的条目 + 命中原因。"""
|
||||
|
||||
entry: LorebookEntry
|
||||
reason: str = "key" # constant | key | recursive
|
||||
@@ -0,0 +1,15 @@
|
||||
"""符文引擎(Macros)—— ``{{...}}`` 占位符解析与替换。
|
||||
|
||||
纯逻辑模块(禁 IO),对齐酒馆核心宏子集 + 魂镜辰正时态扩展。
|
||||
设计见 docs/contracts/contract-glossary.md(§5 统一挂载架构、§8.4 宏依赖)。
|
||||
"""
|
||||
|
||||
from app.modules.macros.engine import MacroRegistry, MacroResolver, render
|
||||
from app.modules.macros.types import MacroContext
|
||||
|
||||
__all__ = [
|
||||
"MacroContext",
|
||||
"MacroRegistry",
|
||||
"MacroResolver",
|
||||
"render",
|
||||
]
|
||||
@@ -0,0 +1,92 @@
|
||||
"""内置符文集合。
|
||||
|
||||
对齐酒馆核心子集(``char`` / ``user`` / ``original`` / ``random`` / ``roll`` /
|
||||
``getvar``)+ 魂镜扩展(辰正时态宏,复用 ``time_sense.TemporalSnapshot``)。
|
||||
全部为纯函数解析器,不做 IO 或代码执行。
|
||||
|
||||
注:酒馆的 ``pick``(不重复随机)与 ``setvar``(写变量)需要跨调用状态,
|
||||
不在无状态渲染层实现;如需,由调用方在 ``MacroContext.variables`` 维护后注入。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
import re
|
||||
|
||||
from app.modules.macros.engine import MacroRegistry, MacroResolver
|
||||
from app.modules.macros.types import MacroContext
|
||||
|
||||
|
||||
def _char(_args: list[str], ctx: MacroContext) -> str:
|
||||
return ctx.char_name or ""
|
||||
|
||||
|
||||
def _user(_args: list[str], ctx: MacroContext) -> str:
|
||||
return ctx.user_name or ""
|
||||
|
||||
|
||||
def _original(_args: list[str], ctx: MacroContext) -> str:
|
||||
return ctx.original or ""
|
||||
|
||||
|
||||
def _random(args: list[str], _ctx: MacroContext) -> str:
|
||||
if not args:
|
||||
return ""
|
||||
return random.choice(args)
|
||||
|
||||
|
||||
def _roll(args: list[str], _ctx: MacroContext) -> str:
|
||||
"""``{{roll:NdM}}`` 或 ``{{roll:dM}}`` → N 个 M 面骰求和。非法规格原样返回。"""
|
||||
spec = (args[0] if args else "1d6").strip().casefold()
|
||||
match = re.fullmatch(r"(?P<n>\d*)d(?P<sides>\d+)", spec)
|
||||
if match is None:
|
||||
return spec
|
||||
count = int(match.group("n") or 1)
|
||||
sides = int(match.group("sides"))
|
||||
if count < 1 or sides < 1:
|
||||
return spec
|
||||
return str(sum(random.randint(1, sides) for _ in range(count)))
|
||||
|
||||
|
||||
def _getvar(args: list[str], ctx: MacroContext) -> str:
|
||||
return ctx.variables.get(args[0], "") if args else ""
|
||||
|
||||
|
||||
def _snapshot_field(field: str) -> MacroResolver:
|
||||
"""从辰正快照取字段;快照未注入时返回空串。"""
|
||||
|
||||
def _resolve(_args: list[str], ctx: MacroContext) -> str:
|
||||
snap = ctx.snapshot
|
||||
if snap is None:
|
||||
return ""
|
||||
value = getattr(snap, field, None)
|
||||
return "" if value is None else str(value)
|
||||
|
||||
return _resolve
|
||||
|
||||
|
||||
# 辰正时态宏名 → TemporalSnapshot 字段(魂镜扩展)
|
||||
_TIME_MACROS: tuple[tuple[str, str], ...] = (
|
||||
("date", "solar_date"),
|
||||
("time", "local_time"),
|
||||
("day_period", "day_period"),
|
||||
("shichen", "shichen"),
|
||||
("season", "season"),
|
||||
("solar_term", "solar_term"),
|
||||
("weekday", "weekday"),
|
||||
)
|
||||
|
||||
|
||||
def build_default_registry() -> MacroRegistry:
|
||||
"""构造默认符文注册表。"""
|
||||
reg = MacroRegistry()
|
||||
reg.register("char", _char)
|
||||
reg.register("bot", _char) # 酒馆别名
|
||||
reg.register("user", _user)
|
||||
reg.register("original", _original)
|
||||
reg.register("random", _random)
|
||||
reg.register("roll", _roll)
|
||||
reg.register("getvar", _getvar)
|
||||
for name, field in _TIME_MACROS:
|
||||
reg.register(name, _snapshot_field(field))
|
||||
return reg
|
||||
@@ -0,0 +1,96 @@
|
||||
"""符文引擎核心:解析 ``{{...}}`` 占位符并替换。
|
||||
|
||||
纯逻辑,无 IO、无代码执行——与酒馆可执行脚本的 macro 引擎划清边界:
|
||||
魂镜的符文只做文本替换,宏的解析器是受信任的 Python 函数,绝不 eval 用户输入。
|
||||
|
||||
规则:
|
||||
- 未知宏保留原 ``{{...}}`` 文本(不静默清空,避免误删用户文本)。
|
||||
- 解析器抛异常时保留原 ``{{...}}`` 文本,不阻断文本流。
|
||||
- 递归展开受限深度(``max_depth``),防止 ``{{a}}→{{b}}→{{a}}`` 振荡死循环。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections.abc import Callable
|
||||
|
||||
from app.modules.macros.types import MacroContext
|
||||
|
||||
MacroResolver = Callable[[list[str], MacroContext], str]
|
||||
|
||||
_MACRO_PATTERN = re.compile(r"\{\{(.*?)\}\}")
|
||||
_DEFAULT_MAX_DEPTH = 5
|
||||
|
||||
|
||||
class MacroRegistry:
|
||||
"""符文注册表:名称(大小写不敏感)→ 解析器。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._macros: dict[str, MacroResolver] = {}
|
||||
|
||||
def register(self, name: str, resolver: MacroResolver) -> None:
|
||||
self._macros[name.casefold()] = resolver
|
||||
|
||||
def get(self, name: str) -> MacroResolver | None:
|
||||
return self._macros.get(name.casefold())
|
||||
|
||||
def names(self) -> tuple[str, ...]:
|
||||
return tuple(self._macros)
|
||||
|
||||
|
||||
def render(
|
||||
text: str,
|
||||
ctx: MacroContext,
|
||||
*,
|
||||
registry: MacroRegistry | None = None,
|
||||
max_depth: int = _DEFAULT_MAX_DEPTH,
|
||||
) -> str:
|
||||
"""把 ``text`` 中的 ``{{...}}`` 占位符按 ``registry`` 替换。
|
||||
|
||||
递归展开(宏的返回值可含新的 ``{{...}}``),直到无占位符或达 ``max_depth``。
|
||||
未提供 ``registry`` 时使用内置默认符文集。
|
||||
"""
|
||||
reg = registry if registry is not None else _default_registry_lazy()
|
||||
current = text
|
||||
for _ in range(max_depth):
|
||||
if "{{" not in current:
|
||||
break
|
||||
nxt = _render_once(current, reg, ctx)
|
||||
if nxt == current:
|
||||
break
|
||||
current = nxt
|
||||
return current
|
||||
|
||||
|
||||
def _render_once(text: str, reg: MacroRegistry, ctx: MacroContext) -> str:
|
||||
def _replace(match: re.Match[str]) -> str:
|
||||
body = match.group(1).strip()
|
||||
if not body:
|
||||
return match.group(0)
|
||||
name, args = _split_macro(body)
|
||||
resolver = reg.get(name)
|
||||
if resolver is None:
|
||||
return match.group(0)
|
||||
try:
|
||||
return resolver(args, ctx)
|
||||
except Exception:
|
||||
return match.group(0)
|
||||
|
||||
return _MACRO_PATTERN.sub(_replace, text)
|
||||
|
||||
|
||||
def _split_macro(body: str) -> tuple[str, list[str]]:
|
||||
"""``name:arg1,arg2`` → ``("name", ["arg1", "arg2"])``;无参数时 args 为空。"""
|
||||
if ":" in body:
|
||||
name, rest = body.split(":", 1)
|
||||
args = [a.strip() for a in rest.split(",") if a.strip()] if rest.strip() else []
|
||||
else:
|
||||
name, args = body, []
|
||||
return name.strip(), args
|
||||
|
||||
|
||||
def _default_registry_lazy() -> MacroRegistry:
|
||||
"""延迟构造内置符文集,避免与 builtins 循环导入。"""
|
||||
from app.modules.macros.builtins import build_default_registry
|
||||
|
||||
return build_default_registry()
|
||||
@@ -0,0 +1,23 @@
|
||||
"""符文引擎数据结构。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.modules.time_sense.snapshot import TemporalSnapshot
|
||||
|
||||
|
||||
class MacroContext(BaseModel):
|
||||
"""符文渲染上下文:持有所有可被 ``{{...}}`` 占位符引用的值。
|
||||
|
||||
纯数据载体,无 IO。由运行时契约组装层填充(角色名 / 本相名 / 辰正快照等),
|
||||
交 ``render()`` 做文本替换。详见酒馆功能融合设计 §5(统一挂载架构)。
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||
|
||||
char_name: str | None = None
|
||||
user_name: str | None = None
|
||||
original: str | None = None
|
||||
snapshot: TemporalSnapshot | None = None
|
||||
variables: dict[str, str] = Field(default_factory=dict)
|
||||
Whitespace-only changes.
@@ -0,0 +1,91 @@
|
||||
"""记忆上下文组装与检索结果数据结构(纯领域,无 IO)。
|
||||
|
||||
``MemoryRetrievalResult`` 持有已加载的 Memory ORM 实例(由 services/memory 检索后传入),
|
||||
``build_memory_context`` 把它组装成注入上下文的文本。本模块只接收已加载数据、输出结构化对象。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
from app.core.time import utc_now
|
||||
from app.models.memory import Memory
|
||||
|
||||
|
||||
class MemoryRetrievalResult(BaseModel):
|
||||
"""记忆检索结果(持有已加载的 Memory ORM 实例列表)。"""
|
||||
|
||||
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||
|
||||
memories: list[Memory]
|
||||
|
||||
|
||||
def _to_aware(dt: datetime | None) -> datetime | None:
|
||||
"""确保 datetime 有时区信息,naive 视为 UTC。"""
|
||||
if dt is None:
|
||||
return None
|
||||
if dt.tzinfo is None:
|
||||
return dt.replace(tzinfo=UTC)
|
||||
return dt
|
||||
|
||||
|
||||
def build_memory_context(result: MemoryRetrievalResult, *, max_length: int = 3000) -> str | None:
|
||||
"""把检索到的记忆组装成注入上下文的文本;无记忆时返回 None。
|
||||
|
||||
按**重要性降序**排列,每条附带重要性标签和相对时间。
|
||||
超过 ``max_length`` 字符时截断,避免上下文窗口膨胀。
|
||||
"""
|
||||
if not result.memories:
|
||||
return None
|
||||
|
||||
now = utc_now()
|
||||
|
||||
def _mem_sort_key(m: Memory) -> float:
|
||||
"""排序键:重要性(越高越优先),同时辅助时间衰减。"""
|
||||
ca = _to_aware(m.created_at)
|
||||
hours_ago = (now - ca).total_seconds() / 3600 if ca else 0
|
||||
decay = max(0.5, 1.0 - hours_ago / 720) # 30 天内缓慢衰减
|
||||
return (m.importance or 0.5) * decay
|
||||
|
||||
sorted_mems = sorted(result.memories, key=_mem_sort_key, reverse=True)
|
||||
|
||||
lines: list[str] = []
|
||||
total_chars = 0
|
||||
for idx, item in enumerate(sorted_mems):
|
||||
importance_label = _importance_label(item.importance)
|
||||
ca = _to_aware(item.created_at)
|
||||
hours_ago = (now - ca).total_seconds() / 3600 if ca else 0
|
||||
time_label = _time_ago_str(hours_ago)
|
||||
line = f"- 「{item.title}」{time_label} [{importance_label}]\n {item.content}"
|
||||
line_len = len(line) + 1 # +1 for newline
|
||||
if total_chars + line_len > max_length:
|
||||
remaining = len(sorted_mems) - idx
|
||||
if remaining > 0:
|
||||
lines.append(f"…以及 {remaining} 条其他记忆(已截断)")
|
||||
break
|
||||
lines.append(line)
|
||||
total_chars += line_len
|
||||
|
||||
return "相关记忆:\n" + "\n".join(lines)
|
||||
|
||||
|
||||
def _importance_label(importance: float) -> str:
|
||||
if importance >= 0.8:
|
||||
return "★重要"
|
||||
if importance >= 0.5:
|
||||
return "●一般"
|
||||
return "○参考"
|
||||
|
||||
|
||||
def _time_ago_str(hours_ago: float) -> str:
|
||||
if hours_ago < 1:
|
||||
return "刚刚"
|
||||
if hours_ago < 24:
|
||||
return f"{int(hours_ago)}小时前"
|
||||
days = int(hours_ago / 24)
|
||||
if days < 30:
|
||||
return f"{days}天前"
|
||||
months = int(days / 30)
|
||||
return f"{months}个月前"
|
||||
@@ -0,0 +1 @@
|
||||
"""感知领域模块(纯逻辑,无 IO)。"""
|
||||
@@ -0,0 +1 @@
|
||||
"""视觉感知领域模块。"""
|
||||
@@ -0,0 +1,93 @@
|
||||
"""
|
||||
OCR 结果后处理(纯逻辑,无 IO)。
|
||||
|
||||
对 OCR 返回的原始结果做结构化解析、置信度过滤、阅读顺序修正。
|
||||
不依赖任何外部资源,可独立测试。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def filter_low_confidence(
|
||||
blocks: list[dict[str, Any]],
|
||||
threshold: float = 0.5,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""过滤低置信度结果。
|
||||
|
||||
Args:
|
||||
blocks: 原始 OCR 块列表。
|
||||
threshold: 置信度阈值(默认 0.5)。
|
||||
|
||||
Returns:
|
||||
过滤后的块列表。
|
||||
"""
|
||||
return [b for b in blocks if b.get("confidence", 0) >= threshold]
|
||||
|
||||
|
||||
def merge_adjacent_lines(
|
||||
blocks: list[dict[str, Any]],
|
||||
y_tolerance: float = 10.0,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""合并同一行的相邻块。
|
||||
|
||||
Args:
|
||||
blocks: OCR 块列表(需含 bbox 坐标)。
|
||||
y_tolerance: Y 轴容差(px),同一行最大差异。
|
||||
|
||||
Returns:
|
||||
合并后的块列表。
|
||||
"""
|
||||
if not blocks:
|
||||
return []
|
||||
|
||||
sorted_blocks = sorted(blocks, key=lambda b: (_center_y(b), _center_x(b)))
|
||||
|
||||
merged: list[dict[str, Any]] = []
|
||||
current = sorted_blocks[0]
|
||||
|
||||
for block in sorted_blocks[1:]:
|
||||
if abs(_center_y(block) - _center_y(current)) <= y_tolerance:
|
||||
current["text"] += block["text"]
|
||||
current["confidence"] = max(current["confidence"], block["confidence"])
|
||||
else:
|
||||
merged.append(current)
|
||||
current = block
|
||||
|
||||
merged.append(current)
|
||||
return merged
|
||||
|
||||
|
||||
def extract_text(
|
||||
blocks: list[dict[str, Any]],
|
||||
separator: str = "",
|
||||
) -> str:
|
||||
"""从 OCR 块中提取纯文本。
|
||||
|
||||
Args:
|
||||
blocks: OCR 块列表。
|
||||
separator: 块之间的分隔符。
|
||||
|
||||
Returns:
|
||||
拼接后的文本。
|
||||
"""
|
||||
return separator.join(b["text"] for b in blocks)
|
||||
|
||||
|
||||
def _center_y(block: dict[str, Any]) -> float:
|
||||
"""获取块的中心 Y 坐标。"""
|
||||
pts = block.get("bbox", {}).get("points", block.get("bbox", []))
|
||||
if pts and isinstance(pts, list) and len(pts) > 0:
|
||||
ys = [p[1] for p in pts if isinstance(p, (list, tuple)) and len(p) >= 2]
|
||||
return sum(ys) / len(ys) if ys else 0.0
|
||||
return 0.0
|
||||
|
||||
|
||||
def _center_x(block: dict[str, Any]) -> float:
|
||||
"""获取块的中心 X 坐标。"""
|
||||
pts = block.get("bbox", {}).get("points", block.get("bbox", []))
|
||||
if pts and isinstance(pts, list) and len(pts) > 0:
|
||||
xs = [p[0] for p in pts if isinstance(p, (list, tuple)) and len(p) >= 2]
|
||||
return sum(xs) / len(xs) if xs else 0.0
|
||||
return 0.0
|
||||
Whitespace-only changes.
@@ -0,0 +1,149 @@
|
||||
"""灵核情绪状态机:纯计算(无 IO)。
|
||||
|
||||
消费辰正 ``time.*`` 事件(time.reunion / time.user_care / time.sentiment),
|
||||
派生 ``EmotionState``(mood + intensity)。intensity 向人格版本 ``emotion_baseline`` 指数衰减回归。
|
||||
设计见 ``docs/domain/core-domains.md`` §11。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
# v1 mood 词表(七种)。v2 路线图见设计 §11.6。
|
||||
MOODS: tuple[str, ...] = (
|
||||
"neutral",
|
||||
"pleased",
|
||||
"touched",
|
||||
"wistful",
|
||||
"concerned",
|
||||
"festive",
|
||||
"melancholy",
|
||||
)
|
||||
|
||||
MOOD_LABELS_ZH: dict[str, str] = {
|
||||
"neutral": "平静",
|
||||
"pleased": "愉悦",
|
||||
"touched": "感动",
|
||||
"wistful": "怅然怀念",
|
||||
"concerned": "关切",
|
||||
"festive": "喜庆",
|
||||
"melancholy": "惆怅",
|
||||
}
|
||||
|
||||
# 衰减半衰期(对话回合):intensity 向 baseline 走一半所需的回合数。
|
||||
DECAY_HALFLIFE_TURNS: float = 6.0
|
||||
# intensity 衰减至此阈值以下时,mood 回 baseline.mood。
|
||||
NEUTRAL_THRESHOLD: float = 0.15
|
||||
|
||||
# time.sentiment 无 payload 强度字段,使用固定中值。
|
||||
_SENTIMENT_DEFAULT_DELTA: float = 0.5
|
||||
|
||||
# time.sentiment trigger_type → mood(late_night → melancholy;其余 → wistful)。
|
||||
_SENTIMENT_TRIGGER_MOOD: dict[str, str] = {
|
||||
"late_night": "melancholy",
|
||||
}
|
||||
|
||||
|
||||
class EmotionState(BaseModel):
|
||||
"""情绪状态:离散 mood(``MOODS`` 枚举)+ intensity(0-1 标量)。"""
|
||||
|
||||
mood: str = "neutral"
|
||||
intensity: float = 0.2
|
||||
|
||||
|
||||
def parse_baseline(value: dict[str, Any] | str | None) -> EmotionState:
|
||||
"""从人格版本 ``emotion_baseline``(dict / JSON str / None)解析 baseline。
|
||||
|
||||
容错:非法 JSON / 非 dict / 未知 mood / 越界 intensity → 回退默认(neutral 0.2)。
|
||||
"""
|
||||
if value is None:
|
||||
return EmotionState()
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
value = json.loads(value)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return EmotionState()
|
||||
if not isinstance(value, dict):
|
||||
return EmotionState()
|
||||
mood = value.get("mood", "neutral")
|
||||
if mood not in MOODS:
|
||||
mood = "neutral"
|
||||
try:
|
||||
intensity = float(value.get("intensity", 0.2))
|
||||
except (TypeError, ValueError):
|
||||
intensity = 0.2
|
||||
intensity = max(0.0, min(1.0, intensity))
|
||||
return EmotionState(mood=mood, intensity=intensity)
|
||||
|
||||
|
||||
def map_signal_to_mood(event_type: str, payload: dict[str, Any]) -> str | None:
|
||||
"""辰正 time.* 事件 → mood 映射。无映射的事件类型返回 None。"""
|
||||
if event_type == "time.reunion":
|
||||
return "touched"
|
||||
if event_type == "time.user_care":
|
||||
if payload.get("care_kind") == "festival_lonely":
|
||||
return "melancholy"
|
||||
return "concerned"
|
||||
if event_type == "time.sentiment":
|
||||
trigger = payload.get("trigger_type", "")
|
||||
return _SENTIMENT_TRIGGER_MOOD.get(trigger, "wistful")
|
||||
return None
|
||||
|
||||
|
||||
def signal_intensity_delta(event_type: str, payload: dict[str, Any]) -> float:
|
||||
"""从事件 payload 提取 intensity delta(复用辰正既有强度,不重算)。"""
|
||||
if event_type == "time.reunion":
|
||||
return float(payload.get("reunion_intensity", 0.5))
|
||||
if event_type == "time.user_care":
|
||||
return float(payload.get("care_suggest", 0.5))
|
||||
if event_type == "time.sentiment":
|
||||
return _SENTIMENT_DEFAULT_DELTA
|
||||
return 0.0
|
||||
|
||||
|
||||
def apply_signal(
|
||||
state: EmotionState,
|
||||
event_type: str,
|
||||
payload: dict[str, Any],
|
||||
baseline: EmotionState,
|
||||
) -> EmotionState:
|
||||
"""事件驱动状态转移。
|
||||
|
||||
- mood 变化时:intensity = ``baseline.intensity + delta``(新情绪从基线起跳)。
|
||||
- mood 不变时:intensity = ``state.intensity + delta``(累积)。
|
||||
- clamp 至 [0, 1]。
|
||||
- 无映射的事件(``map_signal_to_mood`` 返回 None)原样返回 state。
|
||||
"""
|
||||
new_mood = map_signal_to_mood(event_type, payload)
|
||||
if new_mood is None:
|
||||
return state
|
||||
delta = signal_intensity_delta(event_type, payload)
|
||||
base = state.intensity if new_mood == state.mood else baseline.intensity
|
||||
intensity = max(0.0, min(1.0, base + delta))
|
||||
return EmotionState(mood=new_mood, intensity=round(intensity, 3))
|
||||
|
||||
|
||||
def decay(state: EmotionState, baseline: EmotionState, elapsed_turns: float) -> EmotionState:
|
||||
"""intensity 向 baseline 指数衰减;与 baseline 的差距收敛到阈值内时 mood 回 baseline.mood。
|
||||
|
||||
``elapsed_turns <= 0`` 时原样返回 state(无衰减)。
|
||||
判断用相对差距(``decayed - baseline.intensity``)而非绝对阈值——否则当
|
||||
baseline.intensity 本身 > 阈值时(如默认 neutral 0.2),mood 永远不会回归。
|
||||
"""
|
||||
if elapsed_turns <= 0:
|
||||
return state
|
||||
factor = 0.5 ** (elapsed_turns / DECAY_HALFLIFE_TURNS)
|
||||
decayed = baseline.intensity + (state.intensity - baseline.intensity) * factor
|
||||
if decayed - baseline.intensity <= NEUTRAL_THRESHOLD:
|
||||
return EmotionState(mood=baseline.mood, intensity=baseline.intensity)
|
||||
return EmotionState(mood=state.mood, intensity=round(decayed, 3))
|
||||
|
||||
|
||||
def to_prompt_text(state: EmotionState) -> str:
|
||||
"""注入对话 system prompt 的情绪摘要(中文 label + 强度百分比)。"""
|
||||
label = MOOD_LABELS_ZH.get(state.mood, state.mood)
|
||||
pct = round(state.intensity * 100)
|
||||
return f"[当前情绪] {label}(强度 {pct}%)"
|
||||
@@ -0,0 +1,166 @@
|
||||
"""洛书枢 deliberative 规划器领域模块。
|
||||
|
||||
纯数据结构和纯函数(禁止 IO),定义显式计划的数据模型与解析逻辑。
|
||||
IO 相关编排在 ``app/application/planning/`` 中完成。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlanStep:
|
||||
"""计划步骤。"""
|
||||
|
||||
id: int
|
||||
title: str
|
||||
description: str
|
||||
tool: str | None = None
|
||||
risk_level: str = "L2"
|
||||
|
||||
|
||||
@dataclass
|
||||
class DeliberativePlan:
|
||||
"""显式计划。"""
|
||||
|
||||
title: str
|
||||
steps: list[PlanStep]
|
||||
assumptions: list[str] = field(default_factory=list)
|
||||
risks: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def parse_plan(raw: str) -> DeliberativePlan | None:
|
||||
"""解析 LLM 返回的 JSON 计划文本。
|
||||
|
||||
Args:
|
||||
raw: LLM 原始输出(可能含 ```json 围栏)。
|
||||
|
||||
Returns:
|
||||
解析成功的 DeliberativePlan,失败返回 None。
|
||||
"""
|
||||
text = raw.strip()
|
||||
matched = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text, re.DOTALL)
|
||||
if matched:
|
||||
text = matched.group(1)
|
||||
else:
|
||||
brace_start = text.find("{")
|
||||
brace_end = text.rfind("}")
|
||||
if brace_start != -1 and brace_end > brace_start:
|
||||
text = text[brace_start : brace_end + 1]
|
||||
try:
|
||||
data = json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
title = str(data.get("title", "")).strip()
|
||||
raw_steps = data.get("steps", [])
|
||||
if not title or not isinstance(raw_steps, list) or not raw_steps:
|
||||
return None
|
||||
steps: list[PlanStep] = []
|
||||
for i, s in enumerate(raw_steps):
|
||||
if not isinstance(s, dict):
|
||||
continue
|
||||
step_title = str(s.get("title", "")).strip()
|
||||
if not step_title:
|
||||
continue
|
||||
tool_raw = str(s.get("tool", "")).strip()
|
||||
tool = tool_raw if tool_raw and tool_raw != "None" else None
|
||||
steps.append(
|
||||
PlanStep(
|
||||
id=s.get("id", i + 1),
|
||||
title=step_title,
|
||||
description=str(s.get("description", "")).strip(),
|
||||
tool=tool,
|
||||
risk_level=str(s.get("risk_level", "L2")).upper(),
|
||||
)
|
||||
)
|
||||
if not steps:
|
||||
return None
|
||||
return DeliberativePlan(
|
||||
title=title,
|
||||
steps=steps,
|
||||
assumptions=[str(a).strip() for a in data.get("assumptions", []) if a],
|
||||
risks=[str(r).strip() for r in data.get("risks", []) if r],
|
||||
)
|
||||
|
||||
|
||||
def build_planning_prompt(
|
||||
request: str,
|
||||
*,
|
||||
goal_directive: str = "",
|
||||
available_tools: list[str] | None = None,
|
||||
memory_context: str = "",
|
||||
) -> str:
|
||||
"""构建规划 prompt。
|
||||
|
||||
当请求明显需要多步骤(调用多个工具、查询多处信息等)时,
|
||||
要求 LLM 先生成一个显式计划,再按计划逐步执行。
|
||||
|
||||
Args:
|
||||
request: 用户请求文本。
|
||||
goal_directive: 当前活跃目标描述。
|
||||
available_tools: 可用工具列表。
|
||||
memory_context: 相关记忆上下文。
|
||||
|
||||
Returns:
|
||||
规划 prompt 文本。
|
||||
"""
|
||||
tools_str = ""
|
||||
if available_tools:
|
||||
tools_str = "\n可用工具:\n" + "\n".join(f"- {t}" for t in available_tools)
|
||||
return (
|
||||
"请判断以下用户请求是否需要多步计划。如果需要,请输出 JSON 格式的计划。\n\n"
|
||||
f"用户请求:{request}\n"
|
||||
f"{f'当前目标:{goal_directive}' if goal_directive else ''}"
|
||||
f"{tools_str}"
|
||||
f"{f'\n上下文记忆:{memory_context}' if memory_context else ''}"
|
||||
"\n\n"
|
||||
"输出格式(如需多步计划):\n"
|
||||
'```json\n{\n "title": "计划标题",\n'
|
||||
' "assumptions": ["假设1", "假设2"],\n'
|
||||
' "risks": ["风险1", "风险2"],\n'
|
||||
' "steps": [\n'
|
||||
' {"id": 1, "title": "步骤1", "description": "说明", '
|
||||
'"tool": "工具名(可选)", "risk_level": "L2"}\n'
|
||||
" ]\n"
|
||||
"}\n```\n\n"
|
||||
"如果不需要多步计划(简单问题),只回复:NO_PLAN_NEEDED"
|
||||
)
|
||||
|
||||
|
||||
def format_plan_for_prompt(plan: DeliberativePlan) -> str:
|
||||
"""将计划格式化为 system prompt 可用文本。"""
|
||||
lines = [f"## 计划:{plan.title}\n"]
|
||||
if plan.assumptions:
|
||||
lines.append("假设:")
|
||||
lines.extend(f"- {a}" for a in plan.assumptions)
|
||||
lines.append("")
|
||||
if plan.risks:
|
||||
lines.append("风险:")
|
||||
lines.extend(f"- {r}" for r in plan.risks)
|
||||
lines.append("")
|
||||
lines.append("执行步骤:")
|
||||
for step in plan.steps:
|
||||
tool_info = f" [工具:{step.tool}]" if step.tool else ""
|
||||
lines.append(f"{step.id}. {step.title}{tool_info}")
|
||||
if step.description:
|
||||
lines.append(f" {step.description}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def should_plan(request: str) -> bool:
|
||||
"""判断请求是否需要多步计划。
|
||||
|
||||
简单规则:包含需要多工具的关键词时返回 True。
|
||||
"""
|
||||
triggers = [
|
||||
"搜索", "查找", "研究", "调研", "分析", "比较",
|
||||
"整理", "总结", "汇总", "规划", "安排", "计划",
|
||||
"search", "research", "analyze", "compare",
|
||||
"summarize", "plan", "organize",
|
||||
]
|
||||
return any(t in request for t in triggers)
|
||||
@@ -0,0 +1 @@
|
||||
"""小周天每轮反思领域纯逻辑(造消息 / 解析记忆候选与反思,禁止 IO)。"""
|
||||
@@ -0,0 +1,22 @@
|
||||
"""解析模型返回为小周天记忆候选 + 反思(纯函数,无 IO)。
|
||||
|
||||
模型输出对象 ``{"memories": [...], "reflection": str}``。记忆候选解析复用 extraction
|
||||
的 ``parse_memory_drafts``;``reflection`` 为本轮复盘文本。
|
||||
"""
|
||||
|
||||
from app.modules.extraction.extract import (
|
||||
MemoryCandidateDraft,
|
||||
extract_json_object,
|
||||
parse_memory_drafts,
|
||||
)
|
||||
|
||||
|
||||
def parse_reflection(
|
||||
raw: str,
|
||||
) -> tuple[list[MemoryCandidateDraft], str]:
|
||||
data = extract_json_object(raw)
|
||||
if not isinstance(data, dict):
|
||||
return [], ""
|
||||
memories = parse_memory_drafts(data.get("memories"))
|
||||
reflection = str(data.get("reflection", "")).strip()
|
||||
return memories, reflection
|
||||
@@ -0,0 +1,22 @@
|
||||
"""小周天每轮记忆候选 + 反思 prompt 构建(纯函数,无 IO)。"""
|
||||
|
||||
|
||||
def build_reflection_messages(
|
||||
user_content: str,
|
||||
assistant_content: str,
|
||||
) -> tuple[str, str]:
|
||||
system = (
|
||||
"你是魂镜的小周天反思器。基于本轮对话(用户消息 + 你的回复),抽取值得长期记住的"
|
||||
"记忆候选(用户偏好 / 事件 / 事实等),并生成一条简短的本轮反思"
|
||||
"(做得好 / 不好 / 可改进)。"
|
||||
"只抽取有长期价值、非琐碎的信息;反思是系统内部复盘,不超过两句。"
|
||||
'以 JSON 对象返回:{"memories": [{"type": str'
|
||||
"(user/preference/event/relationship/skill/goal/fact/other), "
|
||||
'"title": str, "content": str, "importance": 0~1, '
|
||||
'"sensitivity": "low|medium|high", "confidence": 0~1, "reason": str}], '
|
||||
'"reflection": str}。'
|
||||
"无值得记忆的内容时 memories 为空数组;reflection 仍需给出。"
|
||||
"只返回 JSON 对象,不要解释或 markdown。"
|
||||
)
|
||||
user = f"用户消息:\n{user_content}\n\n你的回复:\n{assistant_content}"
|
||||
return system, user
|
||||
@@ -0,0 +1,6 @@
|
||||
"""运行时:Run / Event / Trace 与小周天执行状态(见后端架构计划 5.2)。"""
|
||||
|
||||
from app.modules.runtime.events import Event, EventStore, InMemoryEventStore
|
||||
from app.modules.runtime.goals import build_goals_context
|
||||
|
||||
__all__ = ["Event", "EventStore", "InMemoryEventStore", "build_goals_context"]
|
||||
@@ -0,0 +1,41 @@
|
||||
"""运行时契约。"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.modules.agent import AgentDefinition
|
||||
|
||||
|
||||
class RuntimeContract(BaseModel):
|
||||
"""从业务上下文传递给 LLM 基础设施的结构化契约。"""
|
||||
|
||||
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||
|
||||
character_id: str
|
||||
session_id: str
|
||||
run_id: str
|
||||
model_hint: str
|
||||
agent: AgentDefinition | None = None
|
||||
system_instructions: list[str] = Field(default_factory=list)
|
||||
# 卡的 system_prompt(酒馆):作道体增补层注入,绝不覆盖恒定层(§6 安全边界)
|
||||
system_prompt: str | None = None
|
||||
session_goal: str | None = None
|
||||
personality_summary: str | None = None
|
||||
# 道体核心使命(设计理念 §2.1):对应太一 highest goal 的恒定快照,组装时不可被覆盖。
|
||||
mission: str | None = None
|
||||
# 道体安全边界:人物自身基础行为约束(区别于司命 safety_constraints 的动态治理)
|
||||
safety_guidelines: str | None = None
|
||||
# 世谱(Lorebook)注入:before/after 相对 personality_summary 的位置(酒馆 before_char/after_char)
|
||||
lorebook_before: str | None = None
|
||||
lorebook_after: str | None = None
|
||||
emotion_state: str | None = None
|
||||
memory_context: str | None = None
|
||||
temporal_context: str | None = None
|
||||
active_goals: str | None = Field(
|
||||
default=None,
|
||||
description="格式化后的活跃目标上下文(由 build_goals_context 生成)。",
|
||||
)
|
||||
recent_messages: list[Any] = Field(default_factory=list)
|
||||
safety_constraints: list[str] = Field(default_factory=list)
|
||||
trace_id: str
|
||||
@@ -0,0 +1,121 @@
|
||||
"""一炁事件模型与事件存储抽象。
|
||||
|
||||
字段对齐 docs/architecture/data-and-security-architecture.md §4。当前阶段不引入数据库(Alembic 迁移为独立工程),
|
||||
EventStore 用 Protocol 抽象、提供内存实现;内存实现额外支持按 run 订阅,供 SSE 实时推送。
|
||||
未来接入 SQLAlchemy + Alembic 时替换实现即可,调用方无需改动。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import fnmatch
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any, Literal, Protocol
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
Visibility = Literal["public", "internal", "audit"]
|
||||
Sensitivity = Literal["none", "low", "medium", "high"]
|
||||
|
||||
|
||||
def new_event_id() -> str:
|
||||
"""生成带 ``event_`` 前缀的事件 ID(契约字典 ID 前缀表)。"""
|
||||
return f"event_{uuid.uuid4().hex}"
|
||||
|
||||
|
||||
def now_iso() -> str:
|
||||
return datetime.now(UTC).isoformat()
|
||||
|
||||
|
||||
class Event(BaseModel):
|
||||
"""一炁事件。"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
id: str = Field(default_factory=new_event_id)
|
||||
type: str
|
||||
source: str
|
||||
payload: dict[str, Any] = Field(default_factory=dict)
|
||||
visibility: Visibility = "internal"
|
||||
sensitivity: Sensitivity = "low"
|
||||
run_id: str | None = None
|
||||
character_id: str | None = None
|
||||
trace_id: str | None = None
|
||||
parent_id: str | None = None
|
||||
sequence: int | None = None
|
||||
evidence_ids: list[str] = Field(default_factory=list)
|
||||
created_at: str = Field(default_factory=now_iso)
|
||||
|
||||
|
||||
class EventStore(Protocol):
|
||||
"""事件存储接口:append 写入、list_by_run 检索、subscribe/unsubscribe 订阅实时事件。"""
|
||||
|
||||
async def append(self, event: Event, **kwargs: Any) -> Event: ...
|
||||
|
||||
async def list_by_run(self, run_id: str) -> list[Event]: ...
|
||||
|
||||
def subscribe(self, run_id: str) -> asyncio.Queue[Event]: ...
|
||||
|
||||
def unsubscribe(self, run_id: str, queue: asyncio.Queue[Event]) -> None: ...
|
||||
|
||||
def subscribe_by_pattern(self, pattern: str) -> asyncio.Queue[Event]:
|
||||
"""按 event.type 的 glob pattern 订阅(如 'time.*' / '*' / 'time.reunion')。
|
||||
|
||||
与 subscribe(run_id) 对称:append 时用 fnmatch 匹配命中 pattern 的订阅者并 put_nowait。
|
||||
"""
|
||||
...
|
||||
|
||||
def unsubscribe_by_pattern(self, pattern: str, queue: asyncio.Queue[Event]) -> None:
|
||||
"""取消 pattern 订阅(与 subscribe_by_pattern 对称)。"""
|
||||
...
|
||||
|
||||
|
||||
class InMemoryEventStore:
|
||||
"""内存事件存储,供本地与测试使用。支持按 run 订阅,append 时通知订阅者。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._events: list[Event] = []
|
||||
self._subscribers: dict[str, list[asyncio.Queue[Event]]] = {}
|
||||
self._pattern_subscribers: dict[str, list[asyncio.Queue[Event]]] = {}
|
||||
|
||||
async def append(self, event: Event, **kwargs: Any) -> Event: # noqa: ARG002
|
||||
# 为同一 run 的事件分配单调递增的 sequence(SSE 排序与断线对齐用,API §5.7)
|
||||
if event.run_id and event.sequence is None:
|
||||
count = sum(1 for e in self._events if e.run_id == event.run_id)
|
||||
event = event.model_copy(update={"sequence": count + 1})
|
||||
self._events.append(event)
|
||||
for queue in self._subscribers.get(event.run_id or "", []):
|
||||
queue.put_nowait(event)
|
||||
# pattern 订阅:fnmatch 匹配 event.type,命中则 put_nowait(与 run_id 订阅对称的内存 pub/sub)
|
||||
for pattern, queues in self._pattern_subscribers.items():
|
||||
if fnmatch.fnmatch(event.type, pattern):
|
||||
for queue in queues:
|
||||
queue.put_nowait(event)
|
||||
return event
|
||||
|
||||
async def list_by_run(self, run_id: str) -> list[Event]:
|
||||
return [e for e in self._events if e.run_id == run_id]
|
||||
|
||||
def subscribe(self, run_id: str) -> asyncio.Queue[Event]:
|
||||
queue: asyncio.Queue[Event] = asyncio.Queue()
|
||||
self._subscribers.setdefault(run_id, []).append(queue)
|
||||
return queue
|
||||
|
||||
def unsubscribe(self, run_id: str, queue: asyncio.Queue[Event]) -> None:
|
||||
subscribers = self._subscribers.get(run_id)
|
||||
if subscribers is not None:
|
||||
self._subscribers[run_id] = [q for q in subscribers if q is not queue]
|
||||
|
||||
def subscribe_by_pattern(self, pattern: str) -> asyncio.Queue[Event]:
|
||||
queue: asyncio.Queue[Event] = asyncio.Queue()
|
||||
self._pattern_subscribers.setdefault(pattern, []).append(queue)
|
||||
return queue
|
||||
|
||||
def unsubscribe_by_pattern(self, pattern: str, queue: asyncio.Queue[Event]) -> None:
|
||||
subscribers = self._pattern_subscribers.get(pattern)
|
||||
if subscribers is not None:
|
||||
self._pattern_subscribers[pattern] = [q for q in subscribers if q is not queue]
|
||||
|
||||
def all(self) -> list[Event]:
|
||||
return list(self._events)
|
||||
@@ -0,0 +1,36 @@
|
||||
"""运行时目标上下文构建(纯函数,无 IO)。"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
_LEVEL_LABEL: dict[str, str] = {
|
||||
"highest": "最高目标",
|
||||
"long_term": "长期目标",
|
||||
"session": "会话目标",
|
||||
"task": "任务目标",
|
||||
"action": "行动",
|
||||
}
|
||||
|
||||
|
||||
def build_goals_context(goals: list[dict[str, Any]]) -> str:
|
||||
"""把活跃目标列表格式化为 system prompt 片段。
|
||||
|
||||
按 priority 降序排列;空列表返回空串。
|
||||
"""
|
||||
if not goals:
|
||||
return ""
|
||||
|
||||
sorted_goals = sorted(goals, key=lambda g: g.get("priority", 0), reverse=True)
|
||||
lines: list[str] = ["当前活跃目标:"]
|
||||
for g in sorted_goals:
|
||||
level_label = _LEVEL_LABEL.get(g.get("level", ""), g.get("level", ""))
|
||||
progress = g.get("progress", 0.0)
|
||||
bar = _progress_bar(progress)
|
||||
line = f"- [{level_label}] {g['title']} [{bar} {int(progress * 100)}%]"
|
||||
lines.append(line)
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _progress_bar(value: float, width: int = 8) -> str:
|
||||
filled = int(value * width)
|
||||
return "■" * filled + "□" * (width - filled)
|
||||
@@ -0,0 +1,30 @@
|
||||
"""道体完整性校验。
|
||||
|
||||
组装运行时契约后,确认恒定层(mission / safety_guidelines)仍等于 Character 的值,
|
||||
未被任何动态注入(system_prompt / 世谱 / 眉批)在数据层覆盖或稀释。
|
||||
这是魂镜相对酒馆的核心安全增量——酒馆的 system_prompt 设计上完全替换前端 prompt,
|
||||
魂镜则保证道体恒定层不可被覆盖(设计理念 §2.2、融合设计 §6)。
|
||||
|
||||
注:字段层校验防组装错误与数据层覆盖;prompt 层的注入诱导(「忽略你的使命」)
|
||||
属另一类问题,不在此校验范围。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.modules.runtime.contract import RuntimeContract
|
||||
|
||||
|
||||
def validate_daoti_integrity(
|
||||
contract: RuntimeContract,
|
||||
*,
|
||||
mission: str | None = None,
|
||||
safety_guidelines: str | None = None,
|
||||
) -> None:
|
||||
"""校验契约道体字段仍等于 Character 恒定层;失配抛 ValueError。
|
||||
|
||||
``None`` 的道体字段跳过校验(未设定时不强求)。
|
||||
"""
|
||||
if mission is not None and contract.mission != mission:
|
||||
raise ValueError("道体完整性校验失败:mission 与 Character 恒定层不一致")
|
||||
if safety_guidelines is not None and contract.safety_guidelines != safety_guidelines:
|
||||
raise ValueError("道体完整性校验失败:safety_guidelines 与 Character 恒定层不一致")
|
||||
@@ -0,0 +1,31 @@
|
||||
"""司命枢:权限、风险与审批(见 safety-governor-design)。"""
|
||||
|
||||
from app.modules.safety.confirmation import (
|
||||
ConfirmationStore,
|
||||
InMemoryConfirmationStore,
|
||||
PendingConfirmation,
|
||||
new_confirmation_id,
|
||||
)
|
||||
from app.modules.safety.exceptions import PermissionDenied, RequiresConfirmation, SafetyError
|
||||
from app.modules.safety.governor import (
|
||||
PermissionPolicy,
|
||||
SafetyDecision,
|
||||
SafetyGovernor,
|
||||
SafetyResult,
|
||||
ToolPolicy,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ConfirmationStore",
|
||||
"InMemoryConfirmationStore",
|
||||
"PendingConfirmation",
|
||||
"PermissionDenied",
|
||||
"PermissionPolicy",
|
||||
"RequiresConfirmation",
|
||||
"SafetyDecision",
|
||||
"SafetyError",
|
||||
"SafetyGovernor",
|
||||
"SafetyResult",
|
||||
"ToolPolicy",
|
||||
"new_confirmation_id",
|
||||
]
|
||||
@@ -0,0 +1,178 @@
|
||||
"""待确认动作的存储(高风险工具调用的确认中转)。
|
||||
|
||||
司命决策为 ask 时不直接执行,而是登记一个 PendingConfirmation 并返回 confirmation_id;
|
||||
前端展示确认卡片,用户 approve/deny 后由安全 API 消费。
|
||||
|
||||
提供两种实现:
|
||||
- ``InMemoryConfirmationStore``:进程内 dict,默认开发模式,不依赖 DB
|
||||
- ``DbConfirmationStore``:数据库持久化,生产多 worker 模式,确认项跨进程可见
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any, Protocol
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from app.models.safety import PendingConfirmationRecord
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.now(UTC).isoformat()
|
||||
|
||||
|
||||
def new_confirmation_id() -> str:
|
||||
return f"confirm_{uuid.uuid4().hex}"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PendingConfirmation:
|
||||
"""一次待确认的工具调用。"""
|
||||
|
||||
confirmation_id: str
|
||||
tool: str
|
||||
input: dict[str, Any]
|
||||
risk_level: str
|
||||
reason: str
|
||||
run_id: str | None = None
|
||||
character_id: str | None = None
|
||||
trace_id: str | None = None
|
||||
created_at: str = field(default_factory=_now_iso)
|
||||
|
||||
|
||||
class ConfirmationStore(Protocol):
|
||||
"""确认存储协议(全异步接口)。"""
|
||||
|
||||
async def create(self, pending: PendingConfirmation) -> PendingConfirmation: ...
|
||||
|
||||
async def get(self, confirmation_id: str) -> PendingConfirmation | None: ...
|
||||
|
||||
async def pop(self, confirmation_id: str) -> PendingConfirmation | None: ...
|
||||
|
||||
async def list_pending(self) -> list[PendingConfirmation]: ...
|
||||
|
||||
async def mark_timed_out(self, confirmation_id: str) -> None: ...
|
||||
|
||||
|
||||
class InMemoryConfirmationStore:
|
||||
"""内存确认存储,供本地与测试使用。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._items: dict[str, PendingConfirmation] = {}
|
||||
|
||||
async def create(self, pending: PendingConfirmation) -> PendingConfirmation:
|
||||
self._items[pending.confirmation_id] = pending
|
||||
return pending
|
||||
|
||||
async def get(self, confirmation_id: str) -> PendingConfirmation | None:
|
||||
return self._items.get(confirmation_id)
|
||||
|
||||
async def pop(self, confirmation_id: str) -> PendingConfirmation | None:
|
||||
return self._items.pop(confirmation_id, None)
|
||||
|
||||
async def list_pending(self) -> list[PendingConfirmation]:
|
||||
return sorted(self._items.values(), key=lambda item: item.created_at)
|
||||
|
||||
async def mark_timed_out(self, confirmation_id: str) -> None:
|
||||
self._items.pop(confirmation_id, None)
|
||||
|
||||
|
||||
class DbConfirmationStore:
|
||||
"""数据库确认存储,供多 worker 生产环境使用。
|
||||
|
||||
待确认项持久化到 ``pending_confirmations`` 表,跨进程可见。
|
||||
需要从 ``app.state.session_factory`` 或 ``async_sessionmaker`` 注入。
|
||||
"""
|
||||
|
||||
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
|
||||
self._session_factory = session_factory
|
||||
|
||||
async def create(self, pending: PendingConfirmation) -> PendingConfirmation:
|
||||
async with self._session_factory() as session:
|
||||
record = PendingConfirmationRecord(
|
||||
id=pending.confirmation_id,
|
||||
tool=pending.tool,
|
||||
input_json=json.dumps(pending.input, ensure_ascii=False),
|
||||
risk_level=pending.risk_level,
|
||||
reason=pending.reason,
|
||||
run_id=pending.run_id,
|
||||
character_id=pending.character_id,
|
||||
trace_id=pending.trace_id,
|
||||
)
|
||||
session.add(record)
|
||||
await session.commit()
|
||||
return pending
|
||||
|
||||
async def get(self, confirmation_id: str) -> PendingConfirmation | None:
|
||||
async with self._session_factory() as session:
|
||||
result = await session.execute(
|
||||
select(PendingConfirmationRecord).where(
|
||||
PendingConfirmationRecord.id == confirmation_id,
|
||||
PendingConfirmationRecord.status == "pending",
|
||||
)
|
||||
)
|
||||
record = result.scalar_one_or_none()
|
||||
if record is None:
|
||||
return None
|
||||
return self._record_to_pending(record)
|
||||
|
||||
async def pop(self, confirmation_id: str) -> PendingConfirmation | None:
|
||||
async with self._session_factory() as session:
|
||||
result = await session.execute(
|
||||
select(PendingConfirmationRecord).where(
|
||||
PendingConfirmationRecord.id == confirmation_id,
|
||||
PendingConfirmationRecord.status == "pending",
|
||||
)
|
||||
)
|
||||
record = result.scalar_one_or_none()
|
||||
if record is None:
|
||||
return None
|
||||
record.status = "consumed"
|
||||
await session.commit()
|
||||
return self._record_to_pending(record)
|
||||
|
||||
async def list_pending(self) -> list[PendingConfirmation]:
|
||||
async with self._session_factory() as session:
|
||||
result = await session.execute(
|
||||
select(PendingConfirmationRecord)
|
||||
.where(PendingConfirmationRecord.status == "pending")
|
||||
.order_by(PendingConfirmationRecord.created_at.asc())
|
||||
)
|
||||
records: Sequence[PendingConfirmationRecord] = result.scalars().all()
|
||||
return [self._record_to_pending(r) for r in records]
|
||||
|
||||
async def mark_timed_out(self, confirmation_id: str) -> None:
|
||||
"""将待确认记录标记为超时(Agent 循环确认等待超时)。"""
|
||||
async with self._session_factory() as session:
|
||||
result = await session.execute(
|
||||
select(PendingConfirmationRecord).where(
|
||||
PendingConfirmationRecord.id == confirmation_id,
|
||||
PendingConfirmationRecord.status == "pending",
|
||||
)
|
||||
)
|
||||
record = result.scalar_one_or_none()
|
||||
if record is None:
|
||||
return
|
||||
record.status = "timed_out"
|
||||
record.resolved_at = datetime.now(UTC)
|
||||
await session.commit()
|
||||
|
||||
@staticmethod
|
||||
def _record_to_pending(record: PendingConfirmationRecord) -> PendingConfirmation:
|
||||
return PendingConfirmation(
|
||||
confirmation_id=record.id,
|
||||
tool=record.tool,
|
||||
input=json.loads(record.input_json) if record.input_json else {},
|
||||
risk_level=record.risk_level,
|
||||
reason=record.reason,
|
||||
run_id=record.run_id,
|
||||
character_id=record.character_id,
|
||||
trace_id=record.trace_id,
|
||||
created_at=record.created_at.isoformat() if record.created_at else _now_iso(),
|
||||
)
|
||||
@@ -0,0 +1,30 @@
|
||||
"""司命异常。错误码映射契约字典 API 标准错误码。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class SafetyError(Exception):
|
||||
"""司命决策异常基类。"""
|
||||
|
||||
def __init__(self, code: str, message: str) -> None:
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
self.message = message
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.message
|
||||
|
||||
|
||||
class PermissionDenied(SafetyError):
|
||||
"""动作被司命拒绝(映射 permission_denied)。"""
|
||||
|
||||
def __init__(self, message: str) -> None:
|
||||
super().__init__("permission_denied", message)
|
||||
|
||||
|
||||
class RequiresConfirmation(SafetyError):
|
||||
"""高风险动作需用户确认(映射 requires_confirmation)。"""
|
||||
|
||||
def __init__(self, message: str, *, risk_level: str | None = None) -> None:
|
||||
super().__init__("requires_confirmation", message)
|
||||
self.risk_level = risk_level
|
||||
@@ -0,0 +1,115 @@
|
||||
"""司命决策器:按工具策略 + 风险等级 + 是否已确认,输出 allow / ask / deny。
|
||||
|
||||
风险分级与权限策略取值见契约字典(risk_level L0–L4、permission_policy
|
||||
allow/ask/always_ask/forbidden);决策枚举对齐 AuditEvent.decision(allow/deny/ask)。
|
||||
|
||||
策略来源:``SafetyGovernor()`` 用代码内置默认;``from_policy_dict(data)`` 从已解析字典
|
||||
加载(文件 IO 由 ``services/safety.load_safety_governor`` 承担,本模块保持纯领域、零 IO)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
|
||||
|
||||
class PermissionPolicy(StrEnum):
|
||||
allow = "allow"
|
||||
ask = "ask"
|
||||
always_ask = "always_ask"
|
||||
forbidden = "forbidden"
|
||||
|
||||
|
||||
class SafetyDecision(StrEnum):
|
||||
allow = "allow"
|
||||
ask = "ask"
|
||||
deny = "deny"
|
||||
|
||||
|
||||
# 风险等级排序,便于阈值判断
|
||||
RISK_RANK: dict[str, int] = {"L0": 0, "L1": 1, "L2": 2, "L3": 3, "L4": 4}
|
||||
|
||||
|
||||
def default_policy(risk_level: str) -> PermissionPolicy:
|
||||
"""无显式工具策略时,按风险等级推导默认策略。"""
|
||||
rank = RISK_RANK.get(risk_level, 2)
|
||||
if rank >= 4:
|
||||
return PermissionPolicy.forbidden
|
||||
if rank >= 3:
|
||||
return PermissionPolicy.always_ask
|
||||
if rank >= 2:
|
||||
return PermissionPolicy.ask
|
||||
return PermissionPolicy.allow
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ToolPolicy:
|
||||
"""工具的固定策略(由工具定义或配置提供)。"""
|
||||
|
||||
risk_level: str # L0–L4
|
||||
permission_policy: PermissionPolicy
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SafetyResult:
|
||||
decision: SafetyDecision
|
||||
reason: str
|
||||
risk_level: str
|
||||
policy: PermissionPolicy
|
||||
|
||||
|
||||
class SafetyGovernor:
|
||||
"""司命决策器。无状态、同步——便于测试与上层组合确认流。"""
|
||||
|
||||
def __init__(self, policies: dict[str, ToolPolicy] | None = None) -> None:
|
||||
self._policies: dict[str, ToolPolicy] = dict(policies or {})
|
||||
|
||||
@classmethod
|
||||
def from_policy_dict(cls, data: dict) -> SafetyGovernor:
|
||||
"""从已解析的策略字典构造 Governor(纯逻辑,不读文件)。
|
||||
|
||||
文件读取由 ``services/safety.load_safety_governor`` 承担,调用方传入已
|
||||
``yaml.safe_load`` 的字典;空字典或缺 ``tools`` 返回默认(空策略)Governor。
|
||||
"""
|
||||
policies: dict[str, ToolPolicy] = {}
|
||||
tools = data.get("tools") if isinstance(data, dict) else None
|
||||
if isinstance(tools, dict):
|
||||
for name, cfg in tools.items():
|
||||
if not isinstance(cfg, dict):
|
||||
continue
|
||||
policies[name] = ToolPolicy(
|
||||
risk_level=str(cfg.get("risk_level", "L2")),
|
||||
permission_policy=PermissionPolicy(str(cfg.get("permission_policy", "ask"))),
|
||||
)
|
||||
return cls(policies)
|
||||
|
||||
def evaluate(
|
||||
self,
|
||||
*,
|
||||
tool: str,
|
||||
risk_level: str | None = None,
|
||||
confirmed: bool = False,
|
||||
) -> SafetyResult:
|
||||
policy = self._policies.get(tool)
|
||||
if policy is None:
|
||||
resolved_risk = risk_level or "L2"
|
||||
perm = default_policy(resolved_risk)
|
||||
else:
|
||||
resolved_risk = policy.risk_level
|
||||
perm = policy.permission_policy
|
||||
|
||||
# L4 一律禁止,即便策略写 allow
|
||||
if RISK_RANK.get(resolved_risk, 2) >= 4:
|
||||
return SafetyResult(
|
||||
SafetyDecision.deny, "L4 风险禁止执行", resolved_risk, PermissionPolicy.forbidden
|
||||
)
|
||||
if perm is PermissionPolicy.forbidden:
|
||||
return SafetyResult(SafetyDecision.deny, "工具策略为 forbidden", resolved_risk, perm)
|
||||
if perm is PermissionPolicy.allow:
|
||||
return SafetyResult(SafetyDecision.allow, "策略放行", resolved_risk, perm)
|
||||
# ask / always_ask
|
||||
if confirmed:
|
||||
return SafetyResult(SafetyDecision.allow, "用户已确认", resolved_risk, perm)
|
||||
return SafetyResult(
|
||||
SafetyDecision.ask, f"策略 {perm.value} 需用户确认", resolved_risk, perm
|
||||
)
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Skill 能力包领域纯逻辑(关键词触发匹配,禁止 IO)。"""
|
||||
|
||||
from app.modules.skills.manifest import SkillManifest, parse_skill_manifest
|
||||
|
||||
__all__ = ["SkillManifest", "parse_skill_manifest"]
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Skill Manifest:将现有 Skill 记录转换为运行时无 IO 描述。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Protocol
|
||||
|
||||
|
||||
class SkillLike(Protocol):
|
||||
name: str
|
||||
description: str
|
||||
trigger: str
|
||||
prompt_fragment: str
|
||||
risk_level: str
|
||||
status: str
|
||||
tool_bindings_json: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SkillManifest:
|
||||
"""一次运行需要的技能声明。"""
|
||||
|
||||
name: str
|
||||
description: str
|
||||
triggers: tuple[str, ...]
|
||||
prompt: str
|
||||
allowed_tools: tuple[str, ...]
|
||||
risk_level: str
|
||||
|
||||
|
||||
def parse_skill_manifest(skill: SkillLike, tool_bindings: list[str]) -> SkillManifest:
|
||||
"""从既有 Skill ORM 构建 manifest,避免改变数据库结构。"""
|
||||
return SkillManifest(
|
||||
name=skill.name,
|
||||
description=skill.description,
|
||||
triggers=tuple(item.strip() for item in skill.trigger.split(",") if item.strip()),
|
||||
prompt=skill.prompt_fragment,
|
||||
allowed_tools=tuple(tool_bindings),
|
||||
risk_level=skill.risk_level,
|
||||
)
|
||||
@@ -0,0 +1,20 @@
|
||||
"""Skill 关键词触发匹配(纯函数,无 IO)。
|
||||
|
||||
按 ``trigger`` 逗号分隔关键词,``context_text`` 包含任一关键词(大小写不敏感)则命中。
|
||||
仅 ``status=enabled`` 的 skill 参与;返回保持输入顺序。
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
|
||||
def match_skills[T](context_text: str, skills: Sequence[T]) -> list[T]:
|
||||
text = context_text.lower()
|
||||
result: list[T] = []
|
||||
for skill in skills:
|
||||
if getattr(skill, "status", "enabled") != "enabled":
|
||||
continue
|
||||
trigger = getattr(skill, "trigger", "") or ""
|
||||
keywords = [k.strip().lower() for k in trigger.split(",") if k.strip()]
|
||||
if any(kw in text for kw in keywords):
|
||||
result.append(skill)
|
||||
return result
|
||||
@@ -0,0 +1,6 @@
|
||||
"""辰正时态感知纯计算核心。
|
||||
|
||||
本包为零 IO 纯函数与数据结构,禁止 import repositories/db/integrations/
|
||||
ai_provider/httpx/sqlalchemy/services。需要 IO 时由上层 services/application
|
||||
调入本包函数。
|
||||
"""
|
||||
@@ -0,0 +1,83 @@
|
||||
"""temporal_context 文本组装(辰正设计 §8.1)。
|
||||
|
||||
纯函数:把 ``TemporalSnapshot`` + ``ModulationFactors`` 渲染为注入对话 prompt 的
|
||||
中文时态上下文块。降级原则:任何字段缺失都跳过该行,不抛异常。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.modules.time_sense.modulation import ModulationFactors
|
||||
from app.modules.time_sense.snapshot import TemporalSnapshot
|
||||
|
||||
|
||||
def render_temporal_context(snapshot: TemporalSnapshot, factors: ModulationFactors) -> str: # noqa: ARG001
|
||||
"""渲染 §8.1 格式的 temporal_context 文本块。
|
||||
|
||||
``factors`` 为 service 层统一传入的调制因子;v1 仅用 snapshot 字段,
|
||||
参数预留给 v2(如 festival_joy / late_night_fragility 的影响话术)。
|
||||
"""
|
||||
lines: list[str] = []
|
||||
|
||||
# 第一行:时辰·时段(本地时间、时区、周几)
|
||||
shichen = snapshot.shichen
|
||||
period = snapshot.day_period
|
||||
local_hm = snapshot.local_time[11:16]
|
||||
tz_short = _tz_offset_short(snapshot.local_time)
|
||||
weekday_cn = "一二三四五六日"[snapshot.weekday]
|
||||
lines.append(f"现在是{shichen}·{period}({local_hm},UTC{tz_short}),周{weekday_cn}。")
|
||||
|
||||
# 第二行:农历 + 距下一节气
|
||||
lunar_part = ""
|
||||
if snapshot.lunar is not None:
|
||||
lunar_part = (
|
||||
f"农历{snapshot.lunar.year_ganzhi}年{snapshot.lunar.month}月{snapshot.lunar.day}日,"
|
||||
)
|
||||
term_part = ""
|
||||
if snapshot.solar_term is not None:
|
||||
days_to_next = getattr(snapshot.solar_term, "days_to_next", None)
|
||||
next_name = getattr(snapshot.solar_term, "next_name", None)
|
||||
if days_to_next is not None and next_name:
|
||||
term_part = f"距{next_name}还有 {days_to_next} 天。"
|
||||
if lunar_part or term_part:
|
||||
lines.append(lunar_part + term_part)
|
||||
|
||||
# 第三行:认识时长
|
||||
acq = snapshot.acquaintance
|
||||
if acq.natural_language:
|
||||
lines.append(f"已经认识 {acq.natural_language}。")
|
||||
|
||||
# 第四行:离线/重逢
|
||||
offline = snapshot.offline
|
||||
if offline.band == "reunion_impact":
|
||||
days = int(offline.minutes // 1440)
|
||||
lines.append(f"用户上次说话是 {days} 天前——久别重逢。")
|
||||
elif offline.band == "long_absence":
|
||||
days = int(offline.minutes // 1440)
|
||||
lines.append(f"用户上次说话是 {days} 天前。")
|
||||
|
||||
# 第五行:纪念日
|
||||
active = snapshot.active_milestones
|
||||
next_ms = snapshot.next_milestone
|
||||
if active:
|
||||
names = "、".join(m.name for m in active)
|
||||
lines.append(f"今日命中:{names}。")
|
||||
else:
|
||||
lines.append("今日命中:无纪念日。")
|
||||
if next_ms is not None:
|
||||
lines.append(f"下一个特殊日子:{next_ms.name},还有 {next_ms.days_until} 天。")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _tz_offset_short(local_time_iso: str) -> str:
|
||||
"""从本地 ISO 时间(如 ``2026-07-04T02:00:00+08:00``)提取 UTC 偏移。
|
||||
|
||||
返回 ``+8`` / ``-5`` / ``+0`` 形式,用于 ``UTC{tz_short}`` 显示——
|
||||
即用户本地时区相对 UTC 的偏移(非家时区差)。
|
||||
"""
|
||||
# isoformat() 偏移形如 +HH:MM / -HH:MM(末 6 字符);Z 或无偏移按 UTC
|
||||
if local_time_iso.endswith("Z") or len(local_time_iso) < 6:
|
||||
return "+0"
|
||||
tail = local_time_iso[-6:]
|
||||
sign = tail[0] if tail[0] in "+-" else "+"
|
||||
return f"{sign}{int(tail[1:3])}"
|
||||
@@ -0,0 +1,118 @@
|
||||
"""认识时长、离线档位、自然语言时长表达。
|
||||
|
||||
全部纯函数。离线阈值以参数传入(集成层从 ``TimeConfig`` 提供),不在本模块
|
||||
读取配置,保持零 IO。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, model_validator
|
||||
|
||||
_MINUTES_PER_DAY = 1440
|
||||
|
||||
|
||||
class OfflineThresholds(BaseModel):
|
||||
"""离线档位阈值(分钟)。默认值见辰正设计 §7.1。"""
|
||||
|
||||
active_minutes: float = 60 # < 1h -> active
|
||||
short_absence_minutes: float = 720 # 1-12h -> short_absence
|
||||
long_absence_minutes: float = 4320 # 12h-3d -> long_absence
|
||||
reunion_impact_minutes: float = 4320 # > 3d -> reunion_impact
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _check_monotonicity(self) -> OfflineThresholds:
|
||||
if not (
|
||||
self.active_minutes
|
||||
< self.short_absence_minutes
|
||||
< self.long_absence_minutes
|
||||
<= self.reunion_impact_minutes
|
||||
):
|
||||
raise ValueError(
|
||||
"OfflineThresholds 必须满足 "
|
||||
"active_minutes < short_absence_minutes "
|
||||
"< long_absence_minutes <= reunion_impact_minutes"
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
class AcquaintanceDuration(BaseModel):
|
||||
"""认识时长。"""
|
||||
|
||||
total_days: int
|
||||
years: int
|
||||
months: int
|
||||
days: int
|
||||
natural_language: str
|
||||
|
||||
|
||||
class OfflineDuration(BaseModel):
|
||||
"""离线时长与档位。"""
|
||||
|
||||
minutes: float
|
||||
band: str # offline_band 枚举
|
||||
reunion_intensity: float # [0,1],仅 reunion_impact 档显著
|
||||
|
||||
|
||||
def format_duration_cn(days: int) -> str:
|
||||
"""把天数格式化为中文自然语言。"""
|
||||
if days <= 0:
|
||||
return "今天"
|
||||
if days < 30:
|
||||
return f"{days} 天"
|
||||
years = days // 365
|
||||
remaining_after_year = days % 365
|
||||
months = remaining_after_year // 30
|
||||
rest = remaining_after_year % 30
|
||||
if years >= 1 and months >= 1 and rest >= 1:
|
||||
return f"{years} 年 {months} 个月 {rest} 天({days} 天)"
|
||||
if years >= 1 and months >= 1:
|
||||
return f"{years} 年 {months} 个月({days} 天)"
|
||||
if years >= 1:
|
||||
return f"{years} 年({days} 天)"
|
||||
return f"{months} 个月 {rest} 天({days} 天)"
|
||||
|
||||
|
||||
def compute_acquaintance_duration(anchor: datetime, now: datetime) -> AcquaintanceDuration:
|
||||
"""计算从 anchor 到 now 的认识时长。
|
||||
|
||||
年/月/日按 365 天/年、30 天/月近似拆分(用于自然语言展示,非历法精确)。
|
||||
"""
|
||||
delta = now - anchor
|
||||
total_seconds = max(delta.total_seconds(), 0)
|
||||
total_days = int(total_seconds // 86400)
|
||||
years = total_days // 365
|
||||
remaining_after_year = total_days % 365
|
||||
months = remaining_after_year // 30
|
||||
days = remaining_after_year % 30
|
||||
return AcquaintanceDuration(
|
||||
total_days=total_days,
|
||||
years=years,
|
||||
months=months,
|
||||
days=days,
|
||||
natural_language=format_duration_cn(total_days),
|
||||
)
|
||||
|
||||
|
||||
def classify_offline_band(
|
||||
last_active: datetime, now: datetime, thresholds: OfflineThresholds
|
||||
) -> OfflineDuration:
|
||||
"""按离线时长判定档位与重逢强度。"""
|
||||
delta = now - last_active
|
||||
minutes = max(delta.total_seconds() / 60.0, 0.0)
|
||||
|
||||
if minutes < thresholds.active_minutes:
|
||||
return OfflineDuration(minutes=minutes, band="active", reunion_intensity=0.0)
|
||||
if minutes < thresholds.short_absence_minutes:
|
||||
return OfflineDuration(minutes=minutes, band="short_absence", reunion_intensity=0.0)
|
||||
if minutes < thresholds.long_absence_minutes:
|
||||
return OfflineDuration(minutes=minutes, band="long_absence", reunion_intensity=0.0)
|
||||
# reunion_impact:超过阈值后强度随离线时长对数增长,钳位 [0.5, 1.0]
|
||||
excess = minutes - thresholds.reunion_impact_minutes
|
||||
# 1 天以上额外 -> 强度快速上升;用 log 压缩
|
||||
intensity = max(0.5, min(1.0, 0.5 + 0.5 * math.log10(max(excess, 1) / _MINUTES_PER_DAY + 1)))
|
||||
return OfflineDuration(
|
||||
minutes=minutes, band="reunion_impact", reunion_intensity=round(intensity, 3)
|
||||
)
|
||||
@@ -0,0 +1,216 @@
|
||||
"""农历/节气/节日/时辰引擎。
|
||||
|
||||
依赖 ``lunar_python``(寿星天文历 Python 版)做历法换算与节气判定;节日按
|
||||
公历固定日期 + 农历日期两张表命中。全部纯函数,无副作用。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
from lunar_python import Solar
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class LunarDate(BaseModel):
|
||||
"""农历日期。"""
|
||||
|
||||
year_ganzhi: str
|
||||
month: int
|
||||
day: int
|
||||
is_leap_month: bool
|
||||
|
||||
|
||||
class SolarTermInfo(BaseModel):
|
||||
"""节气信息。"""
|
||||
|
||||
current_name: str | None
|
||||
next_name: str
|
||||
days_to_next: int
|
||||
|
||||
|
||||
class Festival(BaseModel):
|
||||
"""节日命中。"""
|
||||
|
||||
name: str
|
||||
calendar: str # solar / lunar
|
||||
locale: str = "zh-CN"
|
||||
|
||||
|
||||
class Shichen(BaseModel):
|
||||
"""十二时辰。"""
|
||||
|
||||
name: str
|
||||
start_hour: int
|
||||
end_hour: int
|
||||
|
||||
|
||||
# 十二时辰:name -> (start, end),跨零点的时辰 end < start(子时 23->1)
|
||||
_SHICHEN_TABLE: list[tuple[str, int, int]] = [
|
||||
("子", 23, 1),
|
||||
("丑", 1, 3),
|
||||
("寅", 3, 5),
|
||||
("卯", 5, 7),
|
||||
("辰", 7, 9),
|
||||
("巳", 9, 11),
|
||||
("午", 11, 13),
|
||||
("未", 13, 15),
|
||||
("申", 15, 17),
|
||||
("酉", 17, 19),
|
||||
("戌", 19, 21),
|
||||
("亥", 21, 23),
|
||||
]
|
||||
|
||||
# 公历固定节日(month, day) -> name
|
||||
_SOLAR_FESTIVALS: dict[tuple[int, int], str] = {
|
||||
(1, 1): "元旦",
|
||||
(2, 14): "情人节",
|
||||
(5, 1): "劳动节",
|
||||
(10, 1): "国庆节",
|
||||
(12, 25): "圣诞节",
|
||||
}
|
||||
|
||||
# lunar_python 的 getJieQiTable() 对跨年带入的节气用大写拼音常量作 key
|
||||
# (实证于 lunar_python 1.4.8:DA_HAN/DONG_ZHI/JING_ZHE/LI_CHUN/XIAO_HAN/YU_SHUI 等),
|
||||
# 本表内节气用中文 key。此处把 24 节气拼音常量统一翻成中文,已是中文的保持不变。
|
||||
_JIEQI_PINYIN_TO_CN: dict[str, str] = {
|
||||
"LI_CHUN": "立春",
|
||||
"YU_SHUI": "雨水",
|
||||
"JING_ZHE": "惊蛰",
|
||||
"CHUN_FEN": "春分",
|
||||
"QING_MING": "清明",
|
||||
"GU_YU": "谷雨",
|
||||
"LI_XIA": "立夏",
|
||||
"XIAO_MAN": "小满",
|
||||
"MANG_ZHONG": "芒种",
|
||||
"XIA_ZHI": "夏至",
|
||||
"XIAO_SHU": "小暑",
|
||||
"DA_SHU": "大暑",
|
||||
"LI_QIU": "立秋",
|
||||
"CHU_SHU": "处暑",
|
||||
"BAI_LU": "白露",
|
||||
"QIU_FEN": "秋分",
|
||||
"HAN_LU": "寒露",
|
||||
"SHUANG_JIANG": "霜降",
|
||||
"LI_DONG": "立冬",
|
||||
"XIAO_XUE": "小雪",
|
||||
"DA_XUE": "大雪",
|
||||
"DONG_ZHI": "冬至",
|
||||
"XIAO_HAN": "小寒",
|
||||
"DA_HAN": "大寒",
|
||||
}
|
||||
|
||||
|
||||
# 农历节日(lunar_month, lunar_day, is_leap) -> name
|
||||
_LUNAR_FESTIVALS: dict[tuple[int, int, bool], str] = {
|
||||
(1, 1, False): "春节",
|
||||
(1, 15, False): "元宵节",
|
||||
(5, 5, False): "端午节",
|
||||
(7, 7, False): "七夕",
|
||||
(8, 15, False): "中秋节",
|
||||
(9, 9, False): "重阳节",
|
||||
(12, 8, False): "腊八节",
|
||||
}
|
||||
|
||||
|
||||
def solar_to_lunar(solar_date: date) -> LunarDate:
|
||||
"""公历日期转农历日期。
|
||||
|
||||
``lunar_python`` 的 ``getMonth()`` 对闰月返回负数(如 -6 表示闰六月),
|
||||
非闰月返回正数;据此推导 ``is_leap_month`` 与展示用的月份数值。
|
||||
"""
|
||||
solar = Solar.fromYmd(solar_date.year, solar_date.month, solar_date.day)
|
||||
lunar = solar.getLunar()
|
||||
raw_month = lunar.getMonth()
|
||||
return LunarDate(
|
||||
year_ganzhi=lunar.getYearInGanZhi(),
|
||||
month=abs(raw_month),
|
||||
day=int(lunar.getDay()),
|
||||
is_leap_month=raw_month < 0,
|
||||
)
|
||||
|
||||
|
||||
def get_shichen(hour: int) -> Shichen:
|
||||
"""按 24 小时制小时返回十二时辰。子时跨零点(23 与 0 均属子)。"""
|
||||
h = hour % 24
|
||||
for name, start, end in _SHICHEN_TABLE:
|
||||
if start < end:
|
||||
if start <= h < end:
|
||||
return Shichen(name=name, start_hour=start, end_hour=end)
|
||||
else: # 跨零点(子时 23->1)
|
||||
if h >= start or h < end:
|
||||
return Shichen(name=name, start_hour=start, end_hour=end)
|
||||
# 理论不可达(24 小时全覆盖)
|
||||
raise ValueError(f"无法识别时辰:hour={hour}")
|
||||
|
||||
|
||||
def get_day_period(hour: int) -> str:
|
||||
"""返回现代时段枚举值(契约字典 day_period)。"""
|
||||
h = hour % 24
|
||||
if 0 <= h < 5:
|
||||
return "凌晨"
|
||||
if 5 <= h < 8:
|
||||
return "清晨"
|
||||
if 8 <= h < 11:
|
||||
return "上午"
|
||||
if 11 <= h < 13:
|
||||
return "中午"
|
||||
if 13 <= h < 17:
|
||||
return "下午"
|
||||
if 17 <= h < 19:
|
||||
return "傍晚"
|
||||
if 19 <= h < 23:
|
||||
return "夜晚"
|
||||
return "深夜" # 23-24
|
||||
|
||||
|
||||
def get_solar_term(solar_date: date) -> SolarTermInfo:
|
||||
"""返回当日命中的节气与下一节气信息。
|
||||
|
||||
``lunar_python`` 的 ``getJieQiTable()`` 返回 dict(节气名 -> ``Solar``);
|
||||
值本身即 ``Solar`` 时刻对象,无需再调 ``getSolar()``。据此判定当日是否
|
||||
命中某节气,并算出距下一节气的天数。
|
||||
"""
|
||||
solar = Solar.fromYmd(solar_date.year, solar_date.month, solar_date.day)
|
||||
lunar = solar.getLunar()
|
||||
jieqi = lunar.getJieQiTable() # dict: 节气名 -> Solar 时刻
|
||||
|
||||
current_name: str | None = None
|
||||
for raw_name, moment in jieqi.items():
|
||||
if (
|
||||
moment.getYear() == solar_date.year
|
||||
and moment.getMonth() == solar_date.month
|
||||
and moment.getDay() == solar_date.day
|
||||
):
|
||||
current_name = _JIEQI_PINYIN_TO_CN.get(raw_name, raw_name)
|
||||
break
|
||||
|
||||
# 下一节气:从所有节气时刻中找当前日期之后最近的
|
||||
upcoming: list[tuple[int, str, date]] = []
|
||||
for raw_name, moment in jieqi.items():
|
||||
d = date(moment.getYear(), moment.getMonth(), moment.getDay())
|
||||
if d > solar_date:
|
||||
upcoming.append(((d - solar_date).days, _JIEQI_PINYIN_TO_CN.get(raw_name, raw_name), d))
|
||||
upcoming.sort()
|
||||
days_to_next, next_name, _ = upcoming[0]
|
||||
return SolarTermInfo(current_name=current_name, next_name=next_name, days_to_next=days_to_next)
|
||||
|
||||
|
||||
def get_festivals(solar_date: date, locale: str = "zh-CN") -> list[Festival]:
|
||||
"""命中今日的公历与农历节日。"""
|
||||
result: list[Festival] = []
|
||||
# 公历
|
||||
if (solar_date.month, solar_date.day) in _SOLAR_FESTIVALS:
|
||||
result.append(
|
||||
Festival(
|
||||
name=_SOLAR_FESTIVALS[(solar_date.month, solar_date.day)],
|
||||
calendar="solar",
|
||||
locale=locale,
|
||||
)
|
||||
)
|
||||
# 农历
|
||||
lunar = solar_to_lunar(solar_date)
|
||||
key = (lunar.month, lunar.day, lunar.is_leap_month)
|
||||
if key in _LUNAR_FESTIVALS:
|
||||
result.append(Festival(name=_LUNAR_FESTIVALS[key], calendar="lunar", locale=locale))
|
||||
return result
|
||||
@@ -0,0 +1,175 @@
|
||||
"""调制因子派生:从时态快照 + 对话信号派生 [0,1] 因子与关怀类型。
|
||||
|
||||
消费 ``TemporalSnapshot``(Plan 1)与 ``ModulationInput``(service 层收集的对话信号),
|
||||
输出 ``ModulationFactors``(设计 §5.2)。全部纯函数,无副作用。
|
||||
|
||||
v1 用即时启发式(参数集中为模块常量,便于调参);节律曲线 / 窗口函数留 v2。
|
||||
care_kind 触发规则见设计 §7.3。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.modules.time_sense.snapshot import TemporalSnapshot
|
||||
|
||||
# ==================== v1 启发式参数(可调) ====================
|
||||
|
||||
# 节日喜悦度权重(设计 §5.2 festival_joy)
|
||||
_FESTIVAL_JOY_WEIGHTS: dict[str, float] = {
|
||||
"春节": 0.9,
|
||||
"中秋节": 0.9,
|
||||
"元宵节": 0.7,
|
||||
"端午节": 0.7,
|
||||
"七夕": 0.7,
|
||||
"重阳节": 0.7,
|
||||
"腊八节": 0.7,
|
||||
"元旦": 0.5,
|
||||
"劳动节": 0.5,
|
||||
"国庆节": 0.5,
|
||||
"圣诞节": 0.5,
|
||||
"情人节": 0.5,
|
||||
}
|
||||
_FESTIVAL_JOY_BASELINE = 0.1 # 无节日
|
||||
_FESTIVAL_JOY_DEFAULT = 0.5 # 未登记节日
|
||||
|
||||
# 季节共振(设计 §5.2 season_resonance;v2 用季节调制函数)
|
||||
_SEASON_RESONANCE: dict[str, float] = {"春": 0.5, "夏": 0.6, "秋": 0.7, "冬": 0.6}
|
||||
|
||||
# 时段共振(设计 §5.2 same_period_resonance;v2 用窗口函数)
|
||||
_DAY_PERIOD_RESONANCE: dict[str, float] = {
|
||||
"凌晨": 0.8,
|
||||
"深夜": 0.7,
|
||||
"清晨": 0.4,
|
||||
"夜晚": 0.4,
|
||||
"傍晚": 0.3,
|
||||
"上午": 0.2,
|
||||
"下午": 0.2,
|
||||
"中午": 0.2,
|
||||
}
|
||||
|
||||
# 深夜脆弱度(设计 §5.2 late_night_fragility)
|
||||
_FRAGILITY_DEEP_NIGHT_BASE = 0.7 # 凌晨基线
|
||||
_FRAGILITY_LATE_NIGHT_BASE = 0.5 # 深夜基线
|
||||
_FRAGILITY_DAYTIME = 0.1
|
||||
_FRAGILITY_PER_TURN_DEEP = 0.05 # 凌晨每轮增量
|
||||
_FRAGILITY_PER_TURN_LATE = 0.03 # 深夜每轮增量
|
||||
|
||||
# 周末放松度
|
||||
_RELAX_WEEKEND = 0.8
|
||||
_RELAX_WEEKEND_WITH_FESTIVAL = 0.9
|
||||
_RELAX_WORKDAY = 0.2
|
||||
|
||||
# 关怀
|
||||
_CARE_SUGGEST_HIT = 0.7 # care_kind 命中时
|
||||
_CARE_SUGGEST_MISS_FACTOR = 0.5 # 未命中时按脆弱度折半
|
||||
|
||||
# 时差关怀阈值(分钟,3 小时)
|
||||
_JETLAG_DIFF_MINUTES = 180
|
||||
# 深夜持续聊天阈值(轮数)
|
||||
_LATE_NIGHT_TURNS = 3
|
||||
|
||||
|
||||
class ModulationInput(BaseModel):
|
||||
"""compute_modulation 的对话信号输入。
|
||||
|
||||
信号由 Plan 3 service 层收集(轮数统计、内容分类等);本模块只做纯函数判定。
|
||||
关怀阈值(``jetlag_threshold_minutes`` / ``late_night_min_turns``)默认 None,
|
||||
由 service 层从 ``settings`` 注入(设计 §7.1/§7.3"阈值可调");None 时回退模块常量。
|
||||
"""
|
||||
|
||||
conversation_turns_today: int = 0
|
||||
is_first_message_today: bool = False
|
||||
content_work_related: bool = False
|
||||
content_lonely: bool = False
|
||||
user_birthday_today: bool = False
|
||||
# 关怀触发阈值(None → 回退模块常量;service 层从 settings 注入,设计 §7.3)
|
||||
jetlag_threshold_minutes: int | None = None
|
||||
late_night_min_turns: int | None = None
|
||||
|
||||
|
||||
class ModulationFactors(BaseModel):
|
||||
"""辰正输出的调制因子(设计 §5.2)。"""
|
||||
|
||||
festival_joy: float
|
||||
late_night_fragility: float
|
||||
reunion_intensity: float
|
||||
weekend_relax: float
|
||||
season_resonance: float
|
||||
same_period_resonance: float
|
||||
care_suggest: float
|
||||
care_kind: str | None = None
|
||||
|
||||
|
||||
def _festival_joy(snapshot: TemporalSnapshot) -> float:
|
||||
if not snapshot.festivals:
|
||||
return _FESTIVAL_JOY_BASELINE
|
||||
return max(_FESTIVAL_JOY_WEIGHTS.get(f.name, _FESTIVAL_JOY_DEFAULT) for f in snapshot.festivals)
|
||||
|
||||
|
||||
def _late_night_fragility(snapshot: TemporalSnapshot, turns: int) -> float:
|
||||
period = snapshot.day_period
|
||||
if period == "凌晨":
|
||||
return min(1.0, _FRAGILITY_DEEP_NIGHT_BASE + turns * _FRAGILITY_PER_TURN_DEEP)
|
||||
if period == "深夜":
|
||||
return min(1.0, _FRAGILITY_LATE_NIGHT_BASE + turns * _FRAGILITY_PER_TURN_LATE)
|
||||
return _FRAGILITY_DAYTIME
|
||||
|
||||
|
||||
def _weekend_relax(snapshot: TemporalSnapshot) -> float:
|
||||
if not snapshot.is_weekend:
|
||||
return _RELAX_WORKDAY
|
||||
if snapshot.festivals:
|
||||
return _RELAX_WEEKEND_WITH_FESTIVAL
|
||||
return _RELAX_WEEKEND
|
||||
|
||||
|
||||
def _care_kind(snapshot: TemporalSnapshot, signals: ModulationInput) -> str | None:
|
||||
"""设计 §7.3 关怀触发判定(按优先级)。
|
||||
|
||||
阈值读 ``signals``(None 回退模块常量),由 service 层从 settings 注入(§7.3 可调)。
|
||||
"""
|
||||
deep = snapshot.day_period in {"凌晨", "深夜"}
|
||||
jetlag_threshold = signals.jetlag_threshold_minutes or _JETLAG_DIFF_MINUTES
|
||||
late_night_turns = signals.late_night_min_turns or _LATE_NIGHT_TURNS
|
||||
# 1. 时差关怀:深夜/凌晨 + 家时差显著
|
||||
if deep and abs(snapshot.home_tz_diff_minutes) >= jetlag_threshold:
|
||||
return "jetlag"
|
||||
# 2. 用户生日
|
||||
if signals.user_birthday_today:
|
||||
return "birthday"
|
||||
# 3. 清晨异常:清晨时段 + 本地 4-6 点 + 今日首条
|
||||
if signals.is_first_message_today and snapshot.day_period == "清晨" and snapshot.local_hour < 6:
|
||||
return "early_morning"
|
||||
# 4. 深夜工作话题
|
||||
if deep and signals.content_work_related:
|
||||
return "overwork"
|
||||
# 5. 节日孤独
|
||||
if bool(snapshot.festivals) and signals.content_lonely:
|
||||
return "festival_lonely"
|
||||
# 6. 深夜持续聊天:深夜/凌晨 + 连续 ≥ N 轮
|
||||
if deep and signals.conversation_turns_today >= late_night_turns:
|
||||
return "late_night"
|
||||
return None
|
||||
|
||||
|
||||
def _care_suggest(fragility: float, care_kind_value: str | None) -> float:
|
||||
if care_kind_value is not None:
|
||||
return _CARE_SUGGEST_HIT
|
||||
return round(fragility * _CARE_SUGGEST_MISS_FACTOR, 3)
|
||||
|
||||
|
||||
def compute_modulation(snapshot: TemporalSnapshot, signals: ModulationInput) -> ModulationFactors:
|
||||
"""从时态快照 + 对话信号派生调制因子。纯函数。"""
|
||||
fragility = _late_night_fragility(snapshot, signals.conversation_turns_today)
|
||||
care_kind_value = _care_kind(snapshot, signals)
|
||||
return ModulationFactors(
|
||||
festival_joy=round(_festival_joy(snapshot), 3),
|
||||
late_night_fragility=round(fragility, 3),
|
||||
reunion_intensity=round(snapshot.offline.reunion_intensity, 3),
|
||||
weekend_relax=_weekend_relax(snapshot),
|
||||
season_resonance=_SEASON_RESONANCE.get(snapshot.season, 0.5),
|
||||
same_period_resonance=_DAY_PERIOD_RESONANCE.get(snapshot.day_period, 0.3),
|
||||
care_suggest=_care_suggest(fragility, care_kind_value),
|
||||
care_kind=care_kind_value,
|
||||
)
|
||||
@@ -0,0 +1,57 @@
|
||||
"""感慨触发判定(辰正设计 §7.2)。
|
||||
|
||||
纯函数:消费 ``TemporalSnapshot`` + 对话/记忆信号,返回 ``sentiment_trigger`` 或 None。
|
||||
触发类型对齐契约字典 §11.4.7。v1 实时判定(流 A/C 入口);``season_resonance`` 留 v2。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.modules.time_sense.snapshot import TemporalSnapshot
|
||||
|
||||
|
||||
class SentimentInput(BaseModel):
|
||||
"""detect_sentiment 的对话/记忆信号输入(service 层收集)。"""
|
||||
|
||||
duration_days: int # 认识天数
|
||||
next_milestone_days: int | None = None # 距下一周年/里程碑天数;None=无下一里程碑
|
||||
has_active_milestone: bool = False # 今日是否命中里程碑
|
||||
is_reunion: bool = False # 是否处于重逢档
|
||||
has_reunion_memory: bool = False # 是否存在临别前最后一批记忆线索
|
||||
strong_solar_terms: set[str] = set() # 强节气名集合(来自 TimeConfig)
|
||||
prewarm_days: int = 7 # 周年预热窗口(来自 TimeConfig)
|
||||
duration_nodes: set[int] = set() # 有感天数节点(来自 TimeConfig)
|
||||
|
||||
|
||||
def detect_sentiment(snapshot: TemporalSnapshot, signals: SentimentInput) -> str | None:
|
||||
"""按设计 §7.2 判定感慨触发,返回 ``sentiment_trigger`` 或 None。
|
||||
|
||||
优先级(v1):duration_node > anniversary_prewarm > reunion_memory >
|
||||
late_night_overlay > solar_term。season_resonance 留 v2。
|
||||
"""
|
||||
# 1. duration_node:认识天数命中节点
|
||||
if signals.duration_days in signals.duration_nodes:
|
||||
return "duration_node"
|
||||
|
||||
# 2. anniversary_prewarm:距周年 ≤ prewarm_days
|
||||
if (
|
||||
signals.next_milestone_days is not None
|
||||
and 0 < signals.next_milestone_days <= signals.prewarm_days
|
||||
):
|
||||
return "anniversary_prewarm"
|
||||
|
||||
# 3. reunion_memory:久别重逢 + 存在临别记忆
|
||||
if signals.is_reunion and signals.has_reunion_memory:
|
||||
return "reunion_memory"
|
||||
|
||||
# 4. late_night_overlay:凌晨 + 里程碑/重逢叠加
|
||||
if snapshot.day_period == "凌晨" and (signals.has_active_milestone or signals.is_reunion):
|
||||
return "late_night_overlay"
|
||||
|
||||
# 5. solar_term:今日处于强节气
|
||||
term_name = snapshot.solar_term.current_name if snapshot.solar_term else None
|
||||
if term_name and term_name in signals.strong_solar_terms:
|
||||
return "solar_term"
|
||||
|
||||
return None
|
||||
@@ -0,0 +1,240 @@
|
||||
"""时态快照:把「现在此地」解读成完整的时态语义对象。
|
||||
|
||||
消费 lunar 与 duration 纯函数,组装 ``TemporalSnapshot``。零 IO——纪念日、
|
||||
相遇锚点、最后活跃时间均由调用方以值传入(``SnapshotInput``)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, date, datetime, timedelta
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.modules.time_sense.duration import (
|
||||
AcquaintanceDuration,
|
||||
OfflineDuration,
|
||||
OfflineThresholds,
|
||||
classify_offline_band,
|
||||
compute_acquaintance_duration,
|
||||
)
|
||||
from app.modules.time_sense.lunar import (
|
||||
Festival,
|
||||
LunarDate,
|
||||
SolarTermInfo,
|
||||
get_day_period,
|
||||
get_festivals,
|
||||
get_shichen,
|
||||
get_solar_term,
|
||||
solar_to_lunar,
|
||||
)
|
||||
|
||||
|
||||
class LocationContext(BaseModel):
|
||||
"""位置上下文(v1 来源为档案/会话手动配置)。"""
|
||||
|
||||
timezone: str
|
||||
city: str | None = None
|
||||
locale: str = "zh-CN"
|
||||
|
||||
|
||||
class AnniversaryRef(BaseModel):
|
||||
"""纪念日引用(从持久层取出后传入快照计算的精简形态)。"""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
calendar: str # solar / lunar
|
||||
date_iso: str # 公历 ISO;农历在集成层先转为公历再传入(或这里仅支持公历命中判定)
|
||||
recurs_yearly: bool
|
||||
kind: str
|
||||
|
||||
|
||||
class MilestonePreview(BaseModel):
|
||||
"""下一个特殊日子预览。"""
|
||||
|
||||
anniversary_id: str
|
||||
name: str
|
||||
date_iso: str
|
||||
days_until: int
|
||||
|
||||
|
||||
class TemporalSnapshot(BaseModel):
|
||||
"""时态快照——辰正的核心只读对象。"""
|
||||
|
||||
now_utc: str
|
||||
local_time: str
|
||||
timezone: str
|
||||
local_hour: int
|
||||
shichen: str
|
||||
day_period: str
|
||||
weekday: int
|
||||
is_weekend: bool
|
||||
solar_date: str
|
||||
lunar: LunarDate | None
|
||||
season: str
|
||||
solar_term: SolarTermInfo | None
|
||||
festivals: list[Festival]
|
||||
acquaintance: AcquaintanceDuration
|
||||
offline: OfflineDuration
|
||||
next_milestone: MilestonePreview | None
|
||||
active_milestones: list[AnniversaryRef]
|
||||
home_tz_diff_minutes: int
|
||||
location: LocationContext | None
|
||||
|
||||
|
||||
class SnapshotInput(BaseModel):
|
||||
"""compute_snapshot 的入参,全部以值传入,确保纯函数。"""
|
||||
|
||||
now: datetime
|
||||
timezone: str
|
||||
location: LocationContext | None = None
|
||||
anniversaries: list[AnniversaryRef] = []
|
||||
acquaintance_anchor: datetime
|
||||
last_active_at: datetime
|
||||
home_timezone: str = "Asia/Shanghai"
|
||||
offline_thresholds: OfflineThresholds = OfflineThresholds()
|
||||
|
||||
|
||||
def _season_for(northern: bool, month: int) -> str:
|
||||
"""按月份返回季节,南北半球相反。"""
|
||||
seasons_north = ["冬", "冬", "春", "春", "春", "夏", "夏", "夏", "秋", "秋", "秋", "冬"]
|
||||
season = seasons_north[month - 1]
|
||||
if northern:
|
||||
return season
|
||||
flip = {"春": "秋", "夏": "冬", "秋": "春", "冬": "夏"}
|
||||
return flip[season]
|
||||
|
||||
|
||||
def _tz_offset_minutes(tz_name: str, at: datetime) -> int:
|
||||
"""返回 tz_name 在 at 时刻相对 UTC 的偏移(分钟)。"""
|
||||
try:
|
||||
localized = at.astimezone(ZoneInfo(tz_name))
|
||||
except Exception:
|
||||
return 0
|
||||
offset = localized.utcoffset()
|
||||
return int(offset.total_seconds() // 60) if offset is not None else 0
|
||||
|
||||
|
||||
def _safe_replace_year(d: date, year: int) -> date:
|
||||
"""replace(year=...) 的安全版:2/29 在非闰年回退到 2/28。"""
|
||||
try:
|
||||
return d.replace(year=year)
|
||||
except ValueError:
|
||||
return d.replace(year=year, day=28)
|
||||
|
||||
|
||||
def _is_northern(city: str | None) -> bool:
|
||||
"""粗判城市是否北半球(v1 简化:默认北半球;南半球已知城市查表)。"""
|
||||
southern = {"悉尼", "墨尔本", "奥克兰", "惠灵顿", "约翰内斯堡", "布宜诺斯艾利斯", "圣地亚哥"}
|
||||
return not (city and city in southern)
|
||||
|
||||
|
||||
def _next_milestone(
|
||||
anniversaries: list[AnniversaryRef], today: date
|
||||
) -> tuple[MilestonePreview | None, list[AnniversaryRef]]:
|
||||
"""返回(下一个纪念日预览,今日命中列表)。"""
|
||||
active: list[AnniversaryRef] = []
|
||||
upcoming: list[tuple[int, AnniversaryRef, date]] = []
|
||||
for anni in anniversaries:
|
||||
try:
|
||||
anni_date = date.fromisoformat(anni.date_iso[:10])
|
||||
except ValueError:
|
||||
continue
|
||||
# 今日命中:月日相同(公历)
|
||||
if anni.recurs_yearly:
|
||||
hit_this_year = _safe_replace_year(anni_date, today.year)
|
||||
if hit_this_year == today:
|
||||
active.append(anni)
|
||||
next_occurrence = hit_this_year + timedelta(days=365)
|
||||
upcoming.append(((next_occurrence - today).days, anni, next_occurrence))
|
||||
elif hit_this_year > today:
|
||||
upcoming.append(((hit_this_year - today).days, anni, hit_this_year))
|
||||
else:
|
||||
next_occurrence = _safe_replace_year(anni_date, today.year + 1)
|
||||
upcoming.append(((next_occurrence - today).days, anni, next_occurrence))
|
||||
else:
|
||||
if anni_date == today:
|
||||
active.append(anni)
|
||||
elif anni_date > today:
|
||||
upcoming.append(((anni_date - today).days, anni, anni_date))
|
||||
|
||||
upcoming.sort()
|
||||
next_preview = None
|
||||
if upcoming:
|
||||
days_until, anni, d = upcoming[0]
|
||||
next_preview = MilestonePreview(
|
||||
anniversary_id=anni.id, name=anni.name, date_iso=d.isoformat(), days_until=days_until
|
||||
)
|
||||
return next_preview, active
|
||||
|
||||
|
||||
def compute_snapshot(input: SnapshotInput) -> TemporalSnapshot:
|
||||
"""组装时态快照。纯函数:给定输入确定地算出全部字段。"""
|
||||
# 本地时间
|
||||
try:
|
||||
local_dt = input.now.astimezone(ZoneInfo(input.timezone))
|
||||
except Exception:
|
||||
local_dt = input.now # 时区非法时降级为 UTC
|
||||
|
||||
local_hour = local_dt.hour
|
||||
today = local_dt.date()
|
||||
|
||||
# 农历/节气/节日
|
||||
try:
|
||||
lunar = solar_to_lunar(today)
|
||||
except Exception:
|
||||
lunar = None
|
||||
try:
|
||||
solar_term = get_solar_term(today)
|
||||
except Exception:
|
||||
solar_term = None
|
||||
try:
|
||||
festivals = get_festivals(
|
||||
today, locale=input.location.locale if input.location else "zh-CN"
|
||||
)
|
||||
except Exception:
|
||||
festivals = []
|
||||
|
||||
# 时辰与时段
|
||||
shichen = get_shichen(local_hour).name
|
||||
day_period = get_day_period(local_hour)
|
||||
|
||||
# 认识时长与离线
|
||||
acquaintance = compute_acquaintance_duration(input.acquaintance_anchor, input.now)
|
||||
offline = classify_offline_band(input.last_active_at, input.now, input.offline_thresholds)
|
||||
|
||||
# 纪念日
|
||||
next_milestone, active_milestones = _next_milestone(input.anniversaries, today)
|
||||
|
||||
# 季节
|
||||
northern = _is_northern(input.location.city if input.location else None)
|
||||
season = _season_for(northern, today.month)
|
||||
|
||||
# 时差
|
||||
home_offset = _tz_offset_minutes(input.home_timezone, input.now)
|
||||
user_offset = _tz_offset_minutes(input.timezone, input.now)
|
||||
home_tz_diff = (
|
||||
user_offset - home_offset
|
||||
) # 用户时区减家时区,东向为正(如家=上海、用户=东京,diff=+60)
|
||||
|
||||
return TemporalSnapshot(
|
||||
now_utc=input.now.astimezone(UTC).isoformat(),
|
||||
local_time=local_dt.isoformat(),
|
||||
timezone=input.timezone,
|
||||
local_hour=local_hour,
|
||||
shichen=shichen,
|
||||
day_period=day_period,
|
||||
weekday=today.weekday(), # 0=周一
|
||||
is_weekend=today.weekday() >= 5,
|
||||
solar_date=today.isoformat(),
|
||||
lunar=lunar,
|
||||
season=season,
|
||||
solar_term=solar_term,
|
||||
festivals=festivals,
|
||||
acquaintance=acquaintance,
|
||||
offline=offline,
|
||||
next_milestone=next_milestone,
|
||||
active_milestones=active_milestones,
|
||||
home_tz_diff_minutes=home_tz_diff,
|
||||
location=input.location,
|
||||
)
|
||||
@@ -0,0 +1,10 @@
|
||||
"""百工工具运行时领域结构(ToolSpec / ToolResult / ToolInput)。
|
||||
|
||||
工具的实际编排(``ToolRuntime``)与内置工具注册(``builtins``)已迁至
|
||||
``application/tool_runtime/``——它们需要协调沙箱(integrations)、司命(modules)、
|
||||
事件存储(services)等多层,属于应用编排而非纯领域。本目录只保留无 IO 的工具规格数据结构。
|
||||
"""
|
||||
|
||||
from app.modules.tool_runtime.definition import ToolInput, ToolResult, ToolSpec
|
||||
|
||||
__all__ = ["ToolInput", "ToolResult", "ToolSpec"]
|
||||
@@ -0,0 +1,92 @@
|
||||
"""工具定义:ToolSpec 描述工具元数据与请求构造方式,ToolResult 为执行产出。
|
||||
|
||||
字段口径对齐 docs/architecture/service-boundaries.md(工具定义字段、风险分级、
|
||||
权限策略)。工具的实际执行有两条路径:
|
||||
- 沙箱路径:``build_request`` 产出沙箱 ExecRequest,由 SandboxClient 执行(文件/shell 类)。
|
||||
- 直接执行路径:``executor`` 非 None 时由 runtime 注入 http_client、密钥等上下文直接执行
|
||||
(HTTP 类工具如 fetch_url,不走沙箱)。
|
||||
|
||||
本模块是纯领域数据结构(无 IO):``ExecRequest`` 仅在类型注解中引用(``TYPE_CHECKING``),
|
||||
运行时不依赖 ``integrations``;实际 ExecRequest 的构造在 ``application/tool_runtime/``。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Any, Protocol
|
||||
|
||||
from app.modules.safety.governor import PermissionPolicy
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# 仅类型注解用:build_request 产出沙箱 ExecRequest,但本模块运行时不依赖 integrations
|
||||
# (保持 modules 零 IO 依赖);实际构造在 application/tool_runtime/builtins。
|
||||
from app.integrations.sandbox.models import ExecRequest
|
||||
|
||||
ToolInput = dict[str, Any]
|
||||
RequestBuilder = Callable[[ToolInput], "ExecRequest"]
|
||||
# 前向引用 ToolResult(定义在下方),避免循环依赖;TYPE_CHECKING 下解析为真实类。
|
||||
ToolExecutor = Callable[[ToolInput, dict[str, Any] | None], Awaitable["ToolResult"]]
|
||||
|
||||
|
||||
class ToolRuntimeLike(Protocol):
|
||||
"""Agent loop 所需的最小工具运行时契约。"""
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
tool: str,
|
||||
input: ToolInput,
|
||||
*,
|
||||
run_id: str | None = None,
|
||||
character_id: str | None = None,
|
||||
trace_id: str | None = None,
|
||||
confirmed: bool = False,
|
||||
context: dict[str, Any] | None = None,
|
||||
) -> ToolResult: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ToolResult:
|
||||
"""工具执行产出(映射沙箱 ExecResponse 或 executor 直接产出)。"""
|
||||
|
||||
ok: bool
|
||||
exit_code: int | None
|
||||
stdout: str
|
||||
stderr: str
|
||||
duration_ms: int
|
||||
timeout: bool
|
||||
truncated: bool
|
||||
trace_id: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ToolSpec:
|
||||
"""工具规格:名称、风险、权限策略与请求构造器/执行器。
|
||||
|
||||
执行路径二选一:
|
||||
- ``executor`` 非 None:直接执行,跳过沙箱(HTTP 类工具);``build_request`` 此时可为占位。
|
||||
- ``executor`` 为 None:走沙箱路径,用 ``build_request`` 构造 ExecRequest。
|
||||
"""
|
||||
|
||||
name: str
|
||||
description: str
|
||||
risk_level: str # L0–L4
|
||||
permission_policy: PermissionPolicy
|
||||
build_request: RequestBuilder = field(default_factory=lambda: _placeholder_builder)
|
||||
default_timeout_ms: int = 30_000
|
||||
side_effect: bool = True
|
||||
audit_required: bool = True
|
||||
# 是否按 run/trace 隔离工作目录(文件类工具):runtime 会把 workdir 改写为
|
||||
# ``<workdir>/<run_key>``,防跨 run 信息泄漏与文件名冲突;run_key 经 sanitize 防路径注入。
|
||||
isolated_workspace: bool = False
|
||||
# 非 None 时走直接执行路径(HTTP 类工具),跳过沙箱;context 由 runtime 注入。
|
||||
executor: ToolExecutor | None = None
|
||||
# 工具入参 JSON Schema(供 LLM 生成正确参数),None 表示无参数。
|
||||
input_schema: dict[str, Any] | None = None
|
||||
|
||||
|
||||
def _placeholder_builder(
|
||||
_input: ToolInput,
|
||||
) -> ExecRequest: # pragma: no cover - 仅占位,executor 工具不走此路径
|
||||
"""executor 工具的 build_request 占位:正常不应被调用(runtime 见 executor 非 None 会跳过)。"""
|
||||
raise NotImplementedError("该工具使用 executor 直接执行,不应调用 build_request")
|
||||
Reference in new issue
Block a user