初始化提交

This commit is contained in:
chenxin committed 2026-08-21 16:00:13 +08:00
commit 887c1f449e
963 files changed
+166869

No files matched your search

+1
View File
@@ -0,0 +1 @@
"""领域模块:认知对象与上下文组装(见后端架构计划 modules 节)。"""
+19
View File
@@ -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",
]
+44
View File
@@ -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="你是魂镜当前默认人物。请使用中文,保持诚实、克制、清晰。",
)
+46
View File
@@ -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)。"""
+114
View File
@@ -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
+78
View File
@@ -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)
+154
View File
@@ -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.
+18
View File
@@ -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"}
]
+11
View File
@@ -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", "单条文本消息超过大小限制。")
+1
View File
@@ -0,0 +1 @@
"""大周天内驱力领域纯逻辑(造消息 / 解析好奇问题与次生目标草稿,禁止 IO)。"""
+127
View File
@@ -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))
+55
View File
@@ -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)
+18
View File
@@ -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",
]
+57
View File
@@ -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)。"""
+140
View File
@@ -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))
+46
View File
@@ -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}"
+5
View File
@@ -0,0 +1,5 @@
"""资料导入领域逻辑(纯函数,无 IO)。"""
from app.modules.imports.parsing import ParsedItemDraft, parse_import
__all__ = ["ParsedItemDraft", "parse_import"]
+120
View File
@@ -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,
}
+143
View File
@@ -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"
+66
View File
@@ -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
+113
View File
@@ -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"
+85
View File
@@ -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
+40
View File
@@ -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",
]
+573
View File
@@ -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()
+99
View File
@@ -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}
+190
View File
@@ -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 = (
"你对用户保持着温和的关注,"
"平日里话不多,但每次开口都带着真诚。"
"你善于观察,能从细微之处感知对方的情绪。"
)
+366
View File
@@ -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)
+20
View File
@@ -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",
]
+58
View File
@@ -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}
+80
View File
@@ -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
+51
View File
@@ -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
+15
View File
@@ -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",
]
+92
View File
@@ -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
+96
View File
@@ -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()
+23
View File
@@ -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.
+91
View File
@@ -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.
+149
View File
@@ -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}%)"
+166
View File
@@ -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)。"""
+22
View File
@@ -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
+22
View File
@@ -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
+6
View File
@@ -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"]
+41
View File
@@ -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
+121
View File
@@ -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)
+36
View File
@@ -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)
+30
View File
@@ -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 恒定层不一致")
+31
View File
@@ -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",
]
+178
View File
@@ -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(),
)
+30
View File
@@ -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
+115
View File
@@ -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
)
+5
View File
@@ -0,0 +1,5 @@
"""Skill 能力包领域纯逻辑(关键词触发匹配,禁止 IO)。"""
from app.modules.skills.manifest import SkillManifest, parse_skill_manifest
__all__ = ["SkillManifest", "parse_skill_manifest"]
+40
View File
@@ -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,
)
+20
View File
@@ -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])}"
+118
View File
@@ -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)
)
+216
View File
@@ -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
+240
View File
@@ -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")