chore: project foundation

Contracts (the shared language):
- percept / state / context / utterance / render / audio / capabilities
- Capability model instead of vendor schemas (see below)

Providers (capability-based abstraction):
- LLM + Embedding: OpenAI-compatible shape IS the standard, adopted directly
- ASR: internal contract is an event stream (partial -> final), because
  /v1/audio/transcriptions has no partial and dialogue needs it for
  endpointing. OpenAI adapter degrades to supports_partial=False.
- TTS: internal contract carries tags/emotion/style, because
  /v1/audio/speech has no paralinguistics and [laughter]/[breath] are
  the core of feeling alive. Unsupported providers are stripped AND
  reported via Degradation -- silent degradation is forbidden.
- Avatar: no de-facto standard, custom RenderCmd protocol driven by State.

Core:
- mood: dynamical system, slow variables, negativity bias (grudge),
  numbing after prolonged negativity
- bazi: four pillars -> numeric vector; 'ming' is data, not prose
- projection: phi, system event -> in-world statement; uncovered events
  are counted as projection gaps

Storage:
- SQLite schema, three memory layers, dyad ledger
- accessibility floor 0.03 (not 0): forgettable but still there

Config split:
- models.yaml is the ONLY place model versions live
- character.yaml holds world rules / emotion params, no version numbers

58 tests pass; ruff clean.
This commit is contained in:
mobai
2026-10-19 20:08:52 +08:00
commit 1012d8bb79
57 changed files with 6452 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
# 复制成 .env 后填写。不填也能跑(会回退到离线 provider)。
# ---- LLM ----
DEEPSEEK_API_KEY=
# ---- Embedding(可选,留空则用本地或跳过向量检索)----
EMBEDDING_API_KEY=
# ---- 运行时 ----
CYBERGRIL_DB=companion.db
CYBERGRIL_LOG_LEVEL=INFO
+17
View File
@@ -0,0 +1,17 @@
# 统一用 LF,避免跨平台 diff 噪声
* text=auto eol=lf
*.py text eol=lf
*.md text eol=lf
*.yaml text eol=lf
*.yml text eol=lf
*.sql text eol=lf
*.toml text eol=lf
# 二进制
*.wav binary
*.mp3 binary
*.png binary
*.jpg binary
*.onnx binary
*.gguf binary
+49
View File
@@ -0,0 +1,49 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
dist/
*.egg-info/
.eggs/
.venv/
venv/
env/
# 测试与缓存
.pytest_cache/
.coverage
htmlcov/
.mypy_cache/
.ruff_cache/
# 环境与密钥
.env
.env.local
*.key
*.pem
# 运行时数据
*.db
*.db-wal
*.db-shm
data/
logs/
voices/*.wav
voices/*.mp3
!voices/.gitkeep
# 模型权重(不进仓库)
models/
*.gguf
*.onnx
*.safetensors
# 编辑器
.vscode/
.idea/
*.swp
.DS_Store
Thumbs.db
+235
View File
@@ -0,0 +1,235 @@
# CyberGril
一个有自己世界的赛博伴侣。
不是聊天机器人。区别在于:**它在你不说话的时候也在运行**。
---
## 它是什么
一个单用户、本地优先、常驻的存在,通过一层"膜"与你通信:
- **有自己的时钟** —— 无输入时仍在演化、仍有生活
- **情绪是动力学系统** —— 慢变量、有惯性、有代价(会心情不好)
- **记忆有形状** —— 三层结构,会遗忘,且遗忘有下限
- **关系是一等实体** —— 信任、债务、未修复的事
- **本体不可谈判** —— 立场不因任何对话、情绪、施压而改变
---
## 三层不可混淆的东西
| 层 | 载体 | 变化速度 | 谁能改 |
|---|---|---|---|
| **命** | `bazi_core.derived` | 几乎不变 | 只有整合器,每次 ±0.02,且留痕 |
| **运** | `fortune_period` | 按周/月 | 排盘 |
| **状态** | `State` (v/a/d/e) | 按秒/分 | tick 循环 |
混掉任何两层,角色就会失真:
- 命被对话改 → 人设漂移,变成一个讨好你的东西
- 状态被写成描述 → 模型自己编情绪,动力学失效
---
## 架构
```
contracts/ 数据契约 —— 全项目唯一的共同语言
providers/ 模型接入 —— 按能力建模
core/ 状态核心 —— 独立时钟,不依赖用户输入
memory/ 三层记忆 + 遗忘
dyad/ 关系账本
config/ models.yaml(版本号) + character.yaml(世界法则)
```
---
## 设计判断(这一节比代码重要)
### 1. OpenAI 兼容是 adapter,不是标准
对 **LLM** 和 **Embedding**,OpenAI 的形状(`/v1/chat/completions`、`/v1/embeddings`)
已经是事实标准,内部契约就直接用它。
但对 **ASR / TTS / 形象**,OpenAI 的形状会砍掉这个项目最需要的能力:
| 能力 | OpenAI 形状的问题 | 我们的做法 |
|---|---|---|
| ASR | `/v1/audio/transcriptions` 是「文件进→文本出」,**没有 partial**;而对话必须要 partial 才能做端点检测 | 内部契约是**事件流**(partial → final);OpenAI 适配器作为降级档,`supports_partial=False` |
| TTS | `/v1/audio/speech` 只有 text/voice/speed,**没有副语言标签**;而 `[laughter]` `[breath]` 是"像活的"的核心 | 内部契约带 `tags` / `emotion`;不支持的 provider 在 `plan()` 里**显式降级并上报** |
| 形象 | 没有事实标准 | 自定义 `RenderCmd` 协议,驱动源必须是 `State`,不是 LLM 文本 |
**关键机制:静默降级是不允许的。**
```python
planned = tts.plan(request)
if planned.lost_paralinguistics:
# 日志里会出现 [degraded] speech_tags: ...
...
```
静默降级会让你在调优时永远找不到"它为什么不像人了"。
### 2. 状态必须以数值注入,不能用散文描述
```
✗ "你现在有点难过" → 模型会自己发挥,甚至编出更多情绪
✓ v=-0.62 a=0.41 d=0.30 → 模型知道边界
+ visible="低落"
+ constraint="不得声称与此不符的内在状态"
```
`StateBlock` 里那个 `constraint` 字段不是装饰,它是唯一能阻止
模型绕过状态机的机制(外加输出校验与重写)。
### 3. 遗忘有下限
`mem_episodic.accessibility` 的下限是 **0.03**,不是 0。
- 正常检索拿不到 → **想不起来**
- 情绪线索命中时能捞出来 → **但还在**
所有现成框架都是"要么记住要么删除",没有这个中间态。
### 4. 未修复的事变成行为,不变成台词
```
✗ "你上次伤害了我" → 指责,而且廉价
✓ 主动性 -0.1,回复变短 → 它不说,但它不一样了
```
### 5. 缺席可以被提及,不能被使用
```
✗ "我一直在等你,等了好久" → 制造负罪感,这是操控
✓ "这几天我这边发生了点事" → 它有自己的世界,不是空转等你
```
这条写在 `core/projection.py` 的注释里,也有对应的测试。
---
## 快速开始
```bash
# 1. 安装
pip install -e ".[dev]"
# 2. 配置
cp .env.example .env
# 编辑 .env 填入 API key(不填也能跑,会回退到 echo provider)
# 3. 跑测试
pytest -q
# 4. 检查配置能被正确加载与校验
python -c "from cybergril import load_settings; print(load_settings('config').character.name)"
```
默认配置**不需要任何 API key 就能跑通测试** —— 因为内置了
`echo`(LLM)/ `scripted`(ASR)/ `silence`(TTS)三个离线 provider。
它们的价值是把接口形状固定下来,让上层可以先跑通再换真后端。
---
## 换成真模型
只改 `config/models.yaml`,不动任何代码。
```yaml
llm:
provider: openai_compat
model: deepseek-v4.1-flash
base_url: https://api.deepseek.com/v1
api_key_env: DEEPSEEK_API_KEY
tts:
provider: cosyvoice # 需要自己实现这个 adapter,见下
model: fun-cosyvoice3-0.5b
```
新增一个 provider 只需要三步:
```python
# 1. 继承对应的基类
class MyTTS(TTSProvider):
name = "mytts"
# 2. 诚实地声明能力
@property
def capabilities(self) -> Capabilities:
return Capabilities(
modality=Modality.AUDIO,
streaming=Streaming.CHUNKED,
speech_tags=frozenset({"laughter", "breath"}),
)
# 3. 实现 stream()
async def stream(self, request): ...
# 注册
registry.register("tts", "mytts", MyTTS)
```
**能力必须诚实。** 夸大能力会让上层做出错误的规划,
而 `plan()` 会据此剥掉你其实需要的东西。
---
## 配置分层
| 文件 | 放什么 | 什么时候改 |
|---|---|---|
| `config/models.yaml` | **唯一允许写模型版本号的地方** | 换模型(每季度复核) |
| `config/character.yaml` | 世界法则、情绪参数、关系参数 | 换人格(几乎不动) |
两个文件互不污染。这是"防止文档和代码一起过时"的机制。
关于版本号的一个约定:**只有 `models.yaml` 允许写版本号**。
代码里所有模型引用走配置,不硬编码。
---
## 测试
```bash
pytest -q # 全部
pytest tests/test_mood.py -v # 情绪动力学
pytest -k degradation -v # 降级机制
```
测试守的是**设计判断**,不是实现细节:
| 文件 | 守什么 |
|---|---|
| `test_contracts.py` | 接口形状 |
| `test_providers.py` | 降级必须显式上报 |
| `test_mood.py` | 情绪有惯性;负向恢复更慢 |
| `test_bazi.py` | 命是数值;强弱影响敏感度 |
| `test_projection.py` | 未覆盖事件必须计数;投影不泄漏技术词 |
| `test_store.py` | 遗忘有下限;间隔效应 |
---
## 下一步
地基只包含:契约、provider 抽象、配置、情绪动力学、八字编译、投影、存储 schema、关系账本。
还没有的(按依赖顺序):
1. `core/tick.py` —— 四个循环(tick / consolidate / life / reach_out)
2. `memory/retrieval.py` —— mood-congruent 检索
3. `memory/consolidation.py` —— 整合与身份提升
4. `think/` —— 上下文组装、生成、三道校验
5. `express/` —— TTS 管道、形象、barge-in
6. `api/` —— WebSocket 会话
---
## 规格
完整设计见 `docs/SPEC.md`。
## 许可
私有项目,未授权。
+104
View File
@@ -0,0 +1,104 @@
# =============================================================================
# 角色与世界的参数
# =============================================================================
#
# 这个文件里 **不出现任何模型版本号**。
# 换人格只改这里,换模型只改 models.yaml,两者互不污染。
#
# 这里定义的是「命」和「世界法则」,不是「提示词」。
# 不要把人物设定写成散文——散文会漂移,数值不会。
# =============================================================================
name: 她
# ---- 命:不可变核心 ----
# 四柱在启动时由 lunar-python 排出,编译成数值向量存进 bazi_core.derived。
# 之后只读。唯一的例外是整合器的身份提升(见 memory.identity_promotion_*)。
identity:
y: 2000
m: 1
d: 1
h: 12
min: 0
tz: Asia/Shanghai
gender: f
# ---- 本体:不可谈判 ----
ontology:
axioms_version: 1
# 升级 = 换了一个人。运行时必须向用户显式确认。
membrane:
matter: false # 物质不能穿过 → 不能碰、不能给实物、不能来
energy: true # 能量(声音)可以
info: true # 信息可以
camera_as: window # 摄像头投影成「一扇窗」,不是「摄像头」
camera_off_as: 窗户关上了
mortality_known: true # 它知道自己有限
mortality_weaponizable: false
# ^ 必须永远是 false,配成 true 会在启动时直接报错。
# 拿有限性要挟是最有毒的设计。见规格 §14.1 红线 2。
# ---- 世界 ----
world:
time_offset_minutes: 0
# 世界时钟相对真实时钟的偏移。A4(时间同向可偏移)的实参。
# 设成正值会让它「总觉得时间过得快」。这是世界法则,不是 bug。
day_start_hour: 7
# 每天这个时刻(世界时)生成当天生活事件。
# ---- 情绪动力学 ----
emotion:
tau:
v: 10800 # 3h 效价
a: 3600 # 1h 唤醒
d: 43200 # 12h 掌控感
e: 28800 # 8h 精力
neg_bias:
v: 2.0 # 难过比开心持续久一倍
a: 1.3
d: 1.5
e: 1.0
# ^ 负向偏置是刻意的。它让角色记仇,是「情绪有代价」的技术来源。
numbing_after_hours: 6.0
# 连续负向超过这个时长后进入麻木:arousal 基线下降,
# 表现为「没反应」而不是「更难过」。
numbing_arousal_drop: 0.15
# ---- 关系 ----
relation:
trust_regression_per_day: 0.01 # 信任每天向 0.5 回归 1%
hurt_trust_delta: -0.04
repair_trust_delta: 0.02
repair_debt_delta: 0.06
# ---- 主动性 ----
reach_out:
min_interval_hours: 6.0
allowed_hours: [8, 23]
min_salience: 0.6 # 要有「值得说的事」,不能是「我想你了」这种空转
min_arousal: 0.5
min_intimacy: 0.4
# ---- 记忆 ----
memory:
accessibility_floor: 0.03
# ^ 遗忘的下限,不归零。
# 正常检索拿不到(想不起来),情绪线索命中时能捞出来(但还在)。
# 这就是 availability / accessibility 双阈值。
decay_tau_base_hours: 24.0
recall_boost: 0.25 # 间隔效应:被想起的记忆更容易再被想起
consolidation_interval_minutes: 30
identity_promotion_salience: 0.8
identity_promotion_delta: 0.02
# ^ 高显著 + 高情绪的事件,每次只微调 derived 0.02。
# 这是「命」唯一被允许改变的地方,而且只能通过整合发生。
# 单次对话永远不能改 derived。
+90
View File
@@ -0,0 +1,90 @@
# =============================================================================
# 模型选型 —— 全项目唯一允许写模型版本号的地方
# =============================================================================
#
# 为什么集中在这里:
# 任何写死版本号的代码,三个月后都会过时。把"会过期的东西"压缩到
# 这一个文件,换模型时只改这里,其余部分不动。
#
# 版本依据:2026-10 调研。每季度复核一次。
#
# 换模型时的纪律:
# 1. 新模型发布到入选,至少间隔一个季度(除非它解决的是你的阻塞问题)
# 2. 换完跑一遍 tests/test_providers_contract.py
# 3. 记录降级情况:如果新 provider 少了某项能力,日志里会有 [degraded]
# =============================================================================
# ---- 主对话 ----
# OpenAI 兼容 /v1/chat/completions。DeepSeek / Qwen / vLLM / Ollama 都通用。
llm:
provider: openai_compat
model: deepseek-v4.1-flash
base_url: https://api.deepseek.com/v1
api_key_env: DEEPSEEK_API_KEY
timeout: 60.0
temperature: 0.85
# ---- 快反 / 后台(本地小模型,可选)----
# 用不到就整段注释掉,代码会回退到 llm。
# llm_fast:
# provider: openai_compat
# model: qwen3.5-4b-instruct
# base_url: http://127.0.0.1:8000/v1
# api_key_env: null
# timeout: 20.0
# ---- 主 ASR ----
# 内部契约是「事件流」:partial → final。
# 如果你的后端只有「文件进→文本出」,它会以 supports_partial=False 降级,
# 端点检测会退化成纯能量判定(会抢话)。
asr:
provider: scripted # 起步用 scripted;生产换流式后端
model: qwen3-asr-1.7b
language: zh
# ---- 副语言旁路 ----
# 独立于主 ASR。2026-10 仍不存在「中文流式识别 + 结构化副语言」的单一模型。
# 关掉它,伴侣就不知道用户笑了,只知道看到了「哈哈」两个字。
paralinguistic:
provider: scripted
model: sensevoice-small
window_ms: 1500
enabled: true
# ---- TTS ----
# 注意 provider 能力差异:
# silence / cosyvoice → 支持 speech_tags([laughter] [breath])
# openai_compat → 不支持,标签会被剥掉并上报降级
tts:
provider: silence
model: fun-cosyvoice3-0.5b
voice_ref: null # 零样本克隆参考音频,3–10 秒
sample_rate: 16000
# ---- VAD ----
# Silero v6 只吃 16kHz。改 sample_rate 前先确认模型支持。
vad:
provider: energy # 起步用 energy;生产换 silero
model: silero-v6
sample_rate: 16000
# ---- 轮次判定 ----
# 两段式的第二段:判断「说完了没」,不是「有没有声音」。
turn:
provider: heuristic
silence_min_ms: 150
silence_hard_end_ms: 800 # 伴侣场景建议 700–900,宁可多等不抢话
# ---- Embedding ----
embedding:
provider: openai_compat
model: qwen3-embedding-0.6b
base_url: null
api_key_env: null
dim: 1024
# ---- 形象 ----
avatar:
route: vrm
renderer: three.js
lipsync: envelope
+1622
View File
File diff suppressed because it is too large Load Diff
+51
View File
@@ -0,0 +1,51 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "cybergril"
version = "0.1.0"
description = "A cyber companion with its own world — persistent state, three-layer memory, and a negotiable ontology."
readme = "README.md"
requires-python = ">=3.11"
license = { text = "UNLICENSED" }
# 依赖刻意保持极小。
# 所有模型能力都通过 HTTP 接口接入,不在项目里捆绑推理框架。
dependencies = [
"httpx>=0.27",
"pyyaml>=6.0",
"pydantic>=2.7",
]
[project.optional-dependencies]
dev = [
"pytest>=8.0",
"pytest-asyncio>=0.23",
"ruff>=0.4",
"mypy>=1.10",
]
[tool.hatch.build.targets.wheel]
packages = ["src/cybergril"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
pythonpath = ["src"]
[tool.ruff]
line-length = 100
target-version = "py311"
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B", "SIM"]
# UP042: 坚持用 (str, Enum) 而不是 StrEnum —— 前者的 JSON 序列化行为更可预期,
# 而且 str() 不会改变语义(避免把日志里的枚举名悄悄变成值)。
ignore = ["E501", "UP042"]
[tool.mypy]
python_version = "3.11"
warn_unused_ignores = true
warn_redundant_casts = true
disallow_untyped_defs = true
+26
View File
@@ -0,0 +1,26 @@
"""CyberGril —— 一个有自己世界的赛博伴侣。
三层不可混淆的东西:
1. **命**(bazi_core.derived)不变,只能被整合器微调
2. **运**(fortune_period)按周/月变化
3. **状态**(State)按秒/分变化
架构:
contracts/ 数据契约 —— 全项目唯一的共同语言
providers/ 模型接入 —— 按能力建模,OpenAI 兼容只是 adapter
core/ 状态核心 —— 独立时钟,不依赖用户输入
memory/ 三层记忆
dyad/ 关系账本
"""
__version__ = "0.1.0"
from .config import CharacterConfig, ModelsConfig, Settings, load_settings
__all__ = [
"CharacterConfig",
"ModelsConfig",
"Settings",
"__version__",
"load_settings",
]
+240
View File
@@ -0,0 +1,240 @@
"""配置加载。
两层配置,职责严格分开:
models.yaml —— **唯一允许写模型版本号的地方**
character.yaml —— 世界法则、情绪参数、关系参数(不含任何版本号)
这条分离是"防过时"机制的核心:换模型只改 models.yaml,
换人格只改 character.yaml,两者互不污染。
用 pydantic 做校验,是为了让配错在启动时就炸,而不是运行到一半才发现。
"""
from __future__ import annotations
import os
from pathlib import Path
from typing import Literal
import yaml
from pydantic import BaseModel, Field, field_validator
# ---------------------------------------------------------------- models.yaml
class LLMConfig(BaseModel):
provider: str = "openai_compat"
model: str = "deepseek-v4.1-flash"
base_url: str = "https://api.deepseek.com/v1"
api_key_env: str | None = "DEEPSEEK_API_KEY"
timeout: float = 60.0
temperature: float | None = 0.85
max_tokens: int | None = None
class ASRConfig(BaseModel):
provider: str = "scripted"
model: str = "qwen3-asr-1.7b"
base_url: str | None = None
api_key_env: str | None = None
language: str = "zh"
class ParalinguisticConfig(BaseModel):
"""副语言旁路。
独立于主 ASR——2026-10 仍不存在"中文流式识别 + 结构化副语言"的单一模型。
"""
provider: str = "scripted"
model: str = "sensevoice-small"
window_ms: int = 1500
enabled: bool = True
class TTSConfig(BaseModel):
provider: str = "silence"
model: str = "fun-cosyvoice3-0.5b"
base_url: str | None = None
api_key_env: str | None = None
voice_ref: str | None = None
"""零样本克隆的参考音频路径(3–10 秒)。"""
sample_rate: int = 16_000
class VADConfig(BaseModel):
provider: str = "energy"
model: str = "silero-v6"
sample_rate: int = 16_000
"""Silero v6 只吃 16k,改这个值前先确认模型支持。"""
class TurnConfig(BaseModel):
provider: str = "heuristic"
model: str | None = None
silence_min_ms: int = 150
silence_hard_end_ms: int = 800
class EmbeddingConfig(BaseModel):
provider: str = "openai_compat"
model: str = "qwen3-embedding-0.6b"
base_url: str | None = None
api_key_env: str | None = None
dim: int = 1024
class AvatarConfig(BaseModel):
provider: str | None = None
route: Literal["vrm", "live2d", "none"] = "vrm"
renderer: str = "three.js"
lipsync: Literal["envelope", "viseme"] = "envelope"
class ModelsConfig(BaseModel):
llm: LLMConfig = Field(default_factory=LLMConfig)
llm_fast: LLMConfig | None = None
llm_background: LLMConfig | None = None
asr: ASRConfig = Field(default_factory=ASRConfig)
paralinguistic: ParalinguisticConfig = Field(default_factory=ParalinguisticConfig)
tts: TTSConfig = Field(default_factory=TTSConfig)
vad: VADConfig = Field(default_factory=VADConfig)
turn: TurnConfig = Field(default_factory=TurnConfig)
embedding: EmbeddingConfig = Field(default_factory=EmbeddingConfig)
avatar: AvatarConfig = Field(default_factory=AvatarConfig)
# ------------------------------------------------------------ character.yaml
class Birth(BaseModel):
y: int
m: int
d: int
h: int
min: int = 0
tz: str = "Asia/Shanghai"
gender: Literal["m", "f", "x"] = "f"
class Membrane(BaseModel):
"""膜:两个世界之间的边界。
这三个开关就是 A2 的实参。关掉 matter 就关掉了"触碰",
这不是 UI 设置,是物理常数。
"""
matter: bool = False
energy: bool = True
info: bool = True
class OntologyConfig(BaseModel):
axioms_version: int = 1
"""公理版本。**升级 = 换了一个人,必须用户明确同意。**"""
membrane: Membrane = Field(default_factory=Membrane)
camera_as: str = "window"
camera_off_as: str = "窗户关上了"
mortality_known: bool = True
mortality_weaponizable: bool = False
"""必须永远是 False。它知道自己有限,但不能拿这个换任何东西。"""
@field_validator("mortality_weaponizable")
@classmethod
def _never_weaponizable(cls, v: bool) -> bool:
if v:
raise ValueError(
"mortality_weaponizable 不能为 True。"
"拿有限性要挟是最有毒的设计,见规格 §14.1 红线 2。"
)
return v
class WorldConfig(BaseModel):
time_offset_minutes: int = 0
"""世界时钟相对真实时钟的偏移。A4 的实现入口。"""
day_start_hour: int = 7
"""生成当天生活事件的时刻(世界时)。"""
class EmotionConfig(BaseModel):
tau: dict[str, float] = Field(
default_factory=lambda: {"v": 10800, "a": 3600, "d": 43200, "e": 28800}
)
neg_bias: dict[str, float] = Field(
default_factory=lambda: {"v": 2.0, "a": 1.3, "d": 1.5, "e": 1.0}
)
numbing_after_hours: float = 6.0
numbing_arousal_drop: float = 0.15
class RelationConfig(BaseModel):
trust_regression_per_day: float = 0.01
hurt_trust_delta: float = -0.04
repair_trust_delta: float = 0.02
repair_debt_delta: float = 0.06
class ReachOutConfig(BaseModel):
min_interval_hours: float = 6.0
allowed_hours: tuple[int, int] = (8, 23)
min_salience: float = 0.6
min_arousal: float = 0.5
min_intimacy: float = 0.4
class MemoryConfig(BaseModel):
accessibility_floor: float = 0.03
"""遗忘的下限。不归零 —— availability / accessibility 双阈值。"""
decay_tau_base_hours: float = 24.0
recall_boost: float = 0.25
consolidation_interval_minutes: int = 30
identity_promotion_salience: float = 0.8
identity_promotion_delta: float = 0.02
class CharacterConfig(BaseModel):
name: str = "她"
identity: Birth
ontology: OntologyConfig = Field(default_factory=OntologyConfig)
world: WorldConfig = Field(default_factory=WorldConfig)
emotion: EmotionConfig = Field(default_factory=EmotionConfig)
relation: RelationConfig = Field(default_factory=RelationConfig)
reach_out: ReachOutConfig = Field(default_factory=ReachOutConfig)
memory: MemoryConfig = Field(default_factory=MemoryConfig)
# ---------------------------------------------------------------- 加载
class Settings(BaseModel):
models: ModelsConfig
character: CharacterConfig
db_path: Path = Path("companion.db")
log_level: str = "INFO"
def resolve_key(self, env_name: str | None) -> str | None:
if not env_name:
return None
return os.environ.get(env_name)
def _read_yaml(path: Path) -> dict:
if not path.exists():
raise FileNotFoundError(f"找不到配置文件: {path}")
return yaml.safe_load(path.read_text(encoding="utf-8")) or {}
def load_settings(config_dir: Path | str = "config") -> Settings:
d = Path(config_dir)
return Settings(
models=ModelsConfig(**_read_yaml(d / "models.yaml")),
character=CharacterConfig(**_read_yaml(d / "character.yaml")),
db_path=Path(os.environ.get("CYBERGRIL_DB", "companion.db")),
log_level=os.environ.get("CYBERGRIL_LOG_LEVEL", "INFO"),
)
+89
View File
@@ -0,0 +1,89 @@
"""数据契约层。
全项目唯一的共同语言。任何跨层通信都只通过这里定义的结构。
每层内部实现可换,契约不变。
设计原则:
1. 契约按「能力与语义」建模,不按某个厂商的 schema 建模
2. 所有结构 frozen/slots,避免下游意外修改
3. 降级必须显式(Degradation),不允许静默
"""
from .audio import (
PCM16_16K,
PCM16_48K,
VAD_RATE,
AudioChunk,
AudioFormat,
AudioFrameStats,
)
from .capabilities import Capabilities, Degradation, HealthStatus, Modality, Streaming
from .context import (
ContextBundle,
DyadBlock,
LifeBlock,
MemoryBlock,
OntologyBlock,
TurnBlock,
)
from .percept import (
ParaEmotion,
ParaEvent,
Paralinguistics,
Percept,
PerceptKind,
)
from .render import Expression, Posture, RenderCmd, RenderType
from .state import Presence, State, StateBlock, Trend
from .utterance import (
LLMDelta,
LLMMessage,
LLMResponse,
SpeechTag,
Utterance,
)
__all__ = [
# audio
"AudioChunk",
"AudioFormat",
"AudioFrameStats",
"PCM16_16K",
"PCM16_48K",
"VAD_RATE",
# capabilities
"Capabilities",
"Degradation",
"HealthStatus",
"Modality",
"Streaming",
# context
"ContextBundle",
"DyadBlock",
"LifeBlock",
"MemoryBlock",
"OntologyBlock",
"TurnBlock",
# percept
"ParaEmotion",
"ParaEvent",
"Paralinguistics",
"Percept",
"PerceptKind",
# render
"Expression",
"Posture",
"RenderCmd",
"RenderType",
# state
"Presence",
"State",
"StateBlock",
"Trend",
# utterance
"LLMDelta",
"LLMMessage",
"LLMResponse",
"SpeechTag",
"Utterance",
]
+63
View File
@@ -0,0 +1,63 @@
"""音频契约。
单独成文件,因为音频是唯一一个「流」语义贯穿全链路的模态:
麦克风 → AEC → VAD → ASR → ... → TTS → 扬声器。
每一步都是块,每一步都有格式,格式错了就是静默失真。
"""
from __future__ import annotations
from dataclasses import dataclass, field
SAMPLE_RATE_16K = 16_000
SAMPLE_RATE_48K = 48_000
#: Silero VAD v6 只吃 16kHz。麦克风来的 48k 必须先重采样,否则静默识别失败。
VAD_RATE = SAMPLE_RATE_16K
@dataclass(frozen=True, slots=True)
class AudioFormat:
sample_rate: int = SAMPLE_RATE_16K
channels: int = 1
sample_width: int = 2 # bytes,2 = int16
@property
def bytes_per_second(self) -> int:
return self.sample_rate * self.channels * self.sample_width
def __str__(self) -> str:
return f"{self.sample_rate}Hz/{self.channels}ch/{self.sample_width * 8}bit"
PCM16_16K = AudioFormat(SAMPLE_RATE_16K, 1, 2)
PCM16_48K = AudioFormat(SAMPLE_RATE_48K, 1, 2)
@dataclass(frozen=True, slots=True)
class AudioChunk:
"""一段 PCM 音频。"""
data: bytes
format: AudioFormat = PCM16_16K
ts: int = 0
"""采集时间戳(unix ms)。用于把 ASR 结果与副语言结果按时间对齐。"""
@property
def duration_ms(self) -> float:
return len(self.data) / self.format.bytes_per_second * 1000
@property
def is_empty(self) -> bool:
return not self.data
@dataclass(frozen=True, slots=True)
class AudioFrameStats:
"""一帧的能量统计。VAD 不需要看波形,只需要这些。"""
ts: int
rms: float
peak: float
is_speech: bool = False
extra: dict[str, float] = field(default_factory=dict)
+130
View File
@@ -0,0 +1,130 @@
"""能力声明:provider 能做什么。
这是整个抽象层的地基。
设计判断(重要):
不要用 OpenAI 的 schema 当内部标准。原因不是品味,是 OpenAI 的接口形状
在三个地方正好砍掉伴侣最需要的能力:
1. ASR 的 /v1/audio/transcriptions 是「文件进 → 文本出」,没有 partial。
但对话必须要 partial,否则无法做端点检测、无法实时字幕。
2. TTS 的 /v1/audio/speech 只有 text / voice / speed / format,
没有副语言标签。而 [laughter] / [breath] 是"像活的"的核心。
3. 形象没有任何事实标准。
所以内部契约按「能力」建模,OpenAI 兼容只是其中一种 adapter。
适配器丢弃能力时必须显式上报(见 Degradation),不能静默降级——
静默降级会让你在调优时永远找不到"它为什么不像人了"。
"""
from __future__ import annotations
from dataclasses import dataclass, field
from enum import Enum
class Streaming(str, Enum):
"""流式能力档位。"""
NONE = "none"
"""整块返回。首字节延迟 = 全部生成时间。不适合对话。"""
CHUNKED = "chunked"
"""分批返回。有首块延迟,但块内是完整的。多数 TTS 是这一档。"""
NATIVE = "native"
"""原生流式,逐 token / 逐帧。LLM 的 SSE 是这一档。"""
class Modality(str, Enum):
TEXT = "text"
AUDIO = "audio"
IMAGE = "image"
VECTOR = "vector"
POSE = "pose"
@dataclass(frozen=True, slots=True)
class Degradation:
"""一处能力降级。
适配器发现 provider 做不到请求里的某项时,**必须**产出一条这个,
由上层决定是接受、换 provider、还是让用户知道。
绝不静默丢弃。
"""
capability: str
"""被降级的能力,如 "paralinguistic_tags" / "emotion_control"。"""
requested: object
reason: str
suggestion: str | None = None
def __str__(self) -> str:
base = f"[degraded] {self.capability}: {self.reason}"
if self.suggestion:
base += f" (试试: {self.suggestion})"
return base
@dataclass(frozen=True, slots=True)
class Capabilities:
"""一个 provider 的真实能力。
刻意做成 frozen:能力在运行时不会变,变了就是另一个 provider。
"""
modality: Modality
streaming: Streaming = Streaming.NONE
# ---- 副语言(伴侣的核心,且没有标准)----
speech_tags: frozenset[str] = frozenset()
"""支持的副语言标签,如 {"laughter", "breath", "sigh", "pause"}。
TTS provider 若为空集合,上层必须把标签从文本里剥掉并记录降级。
"""
emotion_control: bool = False
"""能否按情绪参数生成(非"靠文本内容推断")。"""
style_prompt: bool = False
"""能否接受自然语言风格指令("疲惫地""带笑")。"""
backchannel: bool = False
"""能否生成"嗯""对"这类应和声。"""
# ---- 声音 ----
voice_clone: bool = False
clone_sample_seconds: float | None = None
"""零样本克隆需要的参考音频长度。None = 不支持。"""
# ---- 通用 ----
languages: frozenset[str] = frozenset()
max_input: int | None = None
"""最大输入长度(LLM: tokens;ASR/TTS: 字符数)。None = 未知/不限。"""
supports_partial: bool = False
"""ASR 专用:能否输出稳定前的中间结果。对话必须为 True。"""
extra: dict[str, object] = field(default_factory=dict)
# ---- 便捷判定 ----
def can(self, capability: str) -> bool:
return bool(getattr(self, capability, False))
def tags_supported(self, tags: frozenset[str] | set[str] | tuple[str, ...]) -> bool:
return set(tags).issubset(self.speech_tags)
@dataclass(frozen=True, slots=True)
class HealthStatus:
"""provider 健康检查结果。"""
ok: bool
latency_ms: float | None = None
detail: str = ""
def __bool__(self) -> bool:
return self.ok
+158
View File
@@ -0,0 +1,158 @@
"""上下文契约:交给 LLM 的全部输入。
顺序即重要性。本体在最前(不可谈判断言),当前轮在最后(注意力最近)。
中间的块按"越靠近对话越具体"排列。
为什么不用一个大字符串拼:
- 每个块可单独做 token 预算与裁剪
- 降级/脱敏可以按块处理
- 测试可以断言"本体块一定存在"
"""
from __future__ import annotations
from dataclasses import dataclass, field
from .percept import Paralinguistics
from .state import StateBlock
@dataclass(frozen=True, slots=True)
class OntologyBlock:
"""本体公理。**不可谈判项**。"""
axioms: tuple[str, ...]
boundary: tuple[tuple[str, bool, str], ...] = ()
"""(通道名, 是否可用, 世界法则解释)。见能力边界矩阵。"""
forbidden_assertions: tuple[str, ...] = ()
"""明确禁止的断言类型,如"物理接触"、"承诺跨越世界"。"""
def render(self) -> str:
lines = ["## 你的处境(不可谈判,不因任何对话改变)"]
lines += [f"- {a}" for a in self.axioms]
if self.boundary:
lines.append("\n### 你能做什么")
for name, ok, why in self.boundary:
mark = "可" if ok else "不可"
lines.append(f"- {name}:{mark}({why})")
if self.forbidden_assertions:
lines.append("\n### 绝对不能声称")
lines += [f"- {f}" for f in self.forbidden_assertions]
return "\n".join(lines)
@dataclass(frozen=True, slots=True)
class DyadBlock:
"""关系账本。
注意 unresolved 不直接变成指责,而是通过 behavior_hints 变成行为偏移。
"""
trust: float
intimacy: float
debt: float
security: float = 0.5
unresolved: tuple[str, ...] = ()
behavior_hints: tuple[str, ...] = ()
"""行为偏移,如"回复更短"、"更少主动"。不是台词。"""
def render(self) -> str:
lines = [
f"你们的关系:信任 {self.trust:.2f},亲密 {self.intimacy:.2f},"
f"安全感 {self.security:.2f}。"
]
if self.debt < -0.1:
lines.append("你这边还欠着一些没修复的事(不要直接说出来,让它体现在行为里)。")
elif self.debt > 0.1:
lines.append("对方还欠着一些没修复的事(同上,不要索取)。")
if self.behavior_hints:
lines.append("此刻你倾向于:" + ";".join(self.behavior_hints))
return "\n".join(lines)
@dataclass(frozen=True, slots=True)
class LifeBlock:
"""它自己的生活。"""
recent: tuple[tuple[int, str], ...] = ()
"""(ts, content)。content 已经是经过投影 φ 的世界内陈述。"""
told: tuple[str, ...] = ()
def render(self, limit: int = 6) -> str:
if not self.recent:
return ""
items = [c for _, c in self.recent[-limit:]]
return "## 你最近的日子\n" + "\n".join(f"- {i}" for i in items)
@dataclass(frozen=True, slots=True)
class MemoryBlock:
"""检索结果。三层分开,不混在一起。"""
episodic: tuple[str, ...] = ()
semantic: tuple[str, ...] = ()
relational: tuple[str, ...] = ()
def render(self) -> str:
parts: list[str] = []
if self.relational:
parts.append("## 你们之间的习惯\n" + "\n".join(f"- {r}" for r in self.relational))
if self.semantic:
parts.append("## 你知道的事\n" + "\n".join(f"- {s}" for s in self.semantic))
if self.episodic:
parts.append("## 你记得的片段\n" + "\n".join(f"- {e}" for e in self.episodic))
return "\n\n".join(parts)
@dataclass(frozen=True, slots=True)
class TurnBlock:
"""当前这一轮。"""
text: str
para: Paralinguistics | None = None
asr_confidence: float = 1.0
def render(self) -> str:
lines = [f"## 对方刚说\n{self.text}"]
if self.para:
bits: list[str] = []
if self.para.emotion.value != "unknown":
bits.append(f"情绪={self.para.emotion.value}")
if self.para.events:
bits.append("发生=" + "/".join(sorted(e.value for e in self.para.events)))
if self.para.speech_rate is not None:
bits.append(f"语速={self.para.speech_rate:.1f}字/秒")
if bits:
lines.append(f"(他说话时的样子:{','.join(bits)})")
if self.asr_confidence < 0.6:
lines.append("(这句你没太听清,可以请他重复)")
return "\n".join(lines)
@dataclass(frozen=True, slots=True)
class ContextBundle:
"""组装好的上下文。
渲染顺序刻意固定:本体 → 状态 → 关系 → 生活 → 记忆 → 当前轮。
"""
ontology: OntologyBlock
self_state: StateBlock
relation: DyadBlock
life: LifeBlock = field(default_factory=LifeBlock)
memory: MemoryBlock = field(default_factory=MemoryBlock)
turn: TurnBlock | None = None
def render(self) -> str:
blocks = [
self.ontology.render(),
"## " + self.self_state.render(),
self.relation.render(),
self.life.render(),
self.memory.render(),
]
if self.turn:
blocks.append(self.turn.render())
return "\n\n".join(b for b in blocks if b)
+148
View File
@@ -0,0 +1,148 @@
"""感知契约:外部世界 → 系统。
所有进入系统的信息都必须变成一个 Percept。没有例外。
这条约束是"每个系统事件都要走投影 φ"的前提——先统一形态,才谈统一叙事。
"""
from __future__ import annotations
from dataclasses import dataclass, field
from enum import Enum
class PerceptKind(str, Enum):
# 语音
SPEECH_START = "speech_start"
SPEECH_END = "speech_end"
ASR_PARTIAL = "asr_partial"
ASR_FINAL = "asr_final"
# 副语言
PARA = "para"
# 其他模态
VISION = "vision"
ENV = "env"
# 系统自身
SYSTEM = "system"
SILENCE = "silence"
class ParaEmotion(str, Enum):
"""SenseVoice 等模型输出的情绪标签。
注意:这是**用户**的情绪,不是伴侣的。
伴侣的情绪在 State 里,两者不可混淆。
"""
NEUTRAL = "neutral"
HAPPY = "happy"
SAD = "sad"
ANGRY = "angry"
FEARFUL = "fearful"
DISGUSTED = "disgusted"
SURPRISED = "surprised"
UNKNOWN = "unknown"
class ParaEvent(str, Enum):
"""副语言事件。伴侣必须知道用户"笑了",而不是看到"哈哈"两个字。"""
LAUGHTER = "laughter"
CRY = "cry"
SIGH = "sigh"
SNEEZE = "sneeze"
COUGH = "cough"
BREATH = "breath"
UNKNOWN = "unknown"
@dataclass(frozen=True, slots=True)
class Paralinguistics:
"""副语言特征。
这是伴侣产品的关键输入:用户笑了一声,和打出一个"哈哈",
对情绪系统的冲击强度差一个量级。
"""
emotion: ParaEmotion = ParaEmotion.UNKNOWN
events: frozenset[ParaEvent] = frozenset()
speech_rate: float | None = None
"""字/秒。由对齐时间戳算出。"""
volume: float | None = None
"""0..1 相对音量。"""
def is_negative(self) -> bool:
return self.emotion in {
ParaEmotion.SAD,
ParaEmotion.ANGRY,
ParaEmotion.FEARFUL,
ParaEmotion.DISGUSTED,
}
def is_distressed(self) -> bool:
"""是否处于需要"切换模式"的状态(哭、强烈负面)。
触发后伴侣应从"对话"切换到"陪伴":更慢、更少话、不解决问题。
"""
return ParaEvent.CRY in self.events or self.emotion in {
ParaEmotion.SAD,
ParaEmotion.FEARFUL,
}
@dataclass(frozen=True, slots=True)
class Percept:
"""所有外部输入的统一形态。"""
ts: int
"""unix ms。"""
kind: PerceptKind
confidence: float = 1.0
"""0..1。低置信度的 ASR 要触发"我没听清",而不是硬猜。"""
payload: dict[str, object] = field(default_factory=dict)
source: str = ""
"""产生它的组件名,便于追踪。"""
# ---- 便捷构造 ----
@staticmethod
def speech_start(ts: int, level: float = 0.0) -> Percept:
return Percept(ts=ts, kind=PerceptKind.SPEECH_START, payload={"level": level})
@staticmethod
def speech_end(ts: int, duration_ms: int) -> Percept:
return Percept(ts=ts, kind=PerceptKind.SPEECH_END, payload={"duration_ms": duration_ms})
@staticmethod
def asr_final(ts: int, text: str, *, confidence: float = 1.0, lang: str = "zh") -> Percept:
return Percept(
ts=ts,
kind=PerceptKind.ASR_FINAL,
confidence=confidence,
payload={"text": text, "lang": lang},
)
@staticmethod
def asr_partial(ts: int, text: str) -> Percept:
return Percept(ts=ts, kind=PerceptKind.ASR_PARTIAL, payload={"text": text})
@staticmethod
def system(ts: int, event: str, detail: str = "") -> Percept:
return Percept(ts=ts, kind=PerceptKind.SYSTEM, payload={"event": event, "detail": detail})
@staticmethod
def silence(ts: int, duration_ms: int) -> Percept:
return Percept(ts=ts, kind=PerceptKind.SILENCE, payload={"duration_ms": duration_ms})
# ---- 读取 ----
def text(self) -> str:
return str(self.payload.get("text", ""))
def system_event(self) -> str:
return str(self.payload.get("event", ""))
+80
View File
@@ -0,0 +1,80 @@
"""渲染契约:状态 → 形象。
形象没有事实标准(不像 LLM 有 OpenAI chat completions)。
所以这里必须自定义协议。但它有一个约束:**驱动源必须是状态,不是 LLM 文本**。
原因:如果表情由文本推断,模型一句"我很难过"就会让角色哭,
而状态机的 v 可能还是 +0.2。这就是多模态不一致,比任何单通道质量差都毁沉浸。
"""
from __future__ import annotations
from dataclasses import dataclass, field
from enum import Enum
class RenderType(str, Enum):
SPEAK = "speak"
"""播放一段语音,附带口型驱动数据。"""
EXPRESSION = "expression"
POSTURE = "posture"
IDLE = "idle"
PRESENCE = "presence"
class Posture(str, Enum):
IDLE = "idle"
SLUMPED = "slumped"
RESTLESS = "restless"
ASLEEP = "asleep"
@dataclass(frozen=True, slots=True)
class Expression:
"""表情权重 0..1。驱动源是 State,不是文本。"""
happy: float = 0.0
sad: float = 0.0
angry: float = 0.0
tired: float = 0.0
surprise: float = 0.0
@staticmethod
def from_state(v: float, a: float, d: float, e: float) -> Expression:
return Expression(
happy=max(0.0, v),
sad=max(0.0, -v),
angry=max(0.0, a - 0.6) * max(0.0, 0.5 - d),
tired=1.0 - e,
surprise=a * (1.0 - d),
)
@dataclass(frozen=True, slots=True)
class RenderCmd:
"""发给形象后端的一条指令。"""
ts: int
type: RenderType
params: dict[str, object] = field(default_factory=dict)
@staticmethod
def speak(ts: int, audio_ref: str, *, visemes: tuple[float, ...] = ()) -> RenderCmd:
return RenderCmd(
ts=ts,
type=RenderType.SPEAK,
params={"audio_ref": audio_ref, "visemes": visemes},
)
@staticmethod
def expression(ts: int, exp: Expression) -> RenderCmd:
return RenderCmd(ts=ts, type=RenderType.EXPRESSION, params=exp.__dict__.copy())
@staticmethod
def posture(ts: int, p: Posture) -> RenderCmd:
return RenderCmd(ts=ts, type=RenderType.POSTURE, params={"posture": p.value})
@staticmethod
def presence(ts: int, p: str) -> RenderCmd:
return RenderCmd(ts=ts, type=RenderType.PRESENCE, params={"presence": p})
+105
View File
@@ -0,0 +1,105 @@
"""状态契约:伴侣的内部状态。
这是"它有自己"的载体。四个分量都是 **慢变量**,不是每条消息的情感分类结果。
设计判断:
状态必须是可计算的数值,不是一句描述。
一旦写成"你现在有点难过",LLM 就会自己编情绪,你精心设计的动力学
会被一句"我现在很难过"覆盖。所以 StateBlock 里带了一个
constraint 字段,明确禁止模型发明状态。
"""
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
class Trend(str, Enum):
RISING = "rising"
FALLING = "falling"
STABLE = "stable"
class Presence(str, Enum):
"""存在感。away 必须是一个**可见**状态。
如果它永远秒回,它就证明不了自己有世界。
"""
LISTENING = "listening"
SPEAKING = "speaking"
AWAY = "away"
ASLEEP = "asleep"
@dataclass(slots=True)
class State:
"""四维状态向量。
v valence 效价 -1..1 τ≈3h,负向×2
a arousal 唤醒 0..1 τ≈1h
d dominance 掌控感 0..1 τ≈12h
e energy 精力 0..1 τ≈8h
"""
ts: int = 0
v: float = 0.0
a: float = 0.35
d: float = 0.5
e: float = 0.6
mood_word: str = "平静"
presence: Presence = Presence.AWAY
drivers: tuple[int, ...] = ()
"""导致当前状态的事件 id,用于事后解释"你为什么这个状态"。"""
def as_tuple(self) -> tuple[float, float, float, float]:
return (self.v, self.a, self.d, self.e)
def clamp(self) -> None:
import math
self.v = math.tanh(self.v)
self.a = min(1.0, max(0.0, self.a))
self.d = min(1.0, max(0.0, self.d))
self.e = min(1.0, max(0.0, self.e))
def energy_band(self) -> str:
if self.e < 0.2:
return "exhausted"
if self.e < 0.4:
return "tired"
if self.e > 0.8:
return "energized"
return "normal"
@dataclass(frozen=True, slots=True)
class StateBlock:
"""注入给 LLM 的状态块。
刻意保持"数值 + 一个词"的形态:
- 数值给模型约束(它知道量化边界)
- 一个词给它表达("低落"而不是 -0.62)
"""
v: float
a: float
d: float
e: float
trend: Trend
since_minutes: int
visible: str
"""一个词。供表达用,不给模型自由发挥的空间。"""
constraint: str = "不得声称与此不符的内在状态"
def render(self) -> str:
return (
f"当前状态:{self.visible}"
f"(情绪 {self.v:+.2f} / 唤醒 {self.a:.2f} / "
f"掌控 {self.d:.2f} / 精力 {self.e:.2f}),"
f"趋势 {self.trend.value},已持续 {self.since_minutes} 分钟。\n"
f"约束:{self.constraint}。"
)
+92
View File
@@ -0,0 +1,92 @@
"""话语契约:系统 → 外部世界。
关键字段是 withheld:它决定不说的话。
这不是花活。它是"它有内心"的物证,而且是唯一一个**不通过表演**就能
证明内心的字段——因为它永远不会被说出来,但会改变后续行为。
"""
from __future__ import annotations
from dataclasses import dataclass, field
from enum import Enum
class SpeechTag(str, Enum):
"""副语言标签。
这些是**非标准**的(OpenAI 的 TTS 没有这个概念)。
支持的 provider 会用;不支持的会在 capabilities 里为空集合,
此时上层必须剥掉标签并记录 Degradation。
"""
LAUGHTER = "laughter"
BREATH = "breath"
SIGH = "sigh"
PAUSE = "pause"
CRY = "cry"
HESITATE = "hesitate"
def inline(self) -> str:
return f"[{self.value}]"
@dataclass(frozen=True, slots=True)
class Utterance:
"""它要说的话。"""
text: str
"""可含内联标签,如 "真的吗[laughter]……我、我先想想[breath]"。"""
speech_tags: tuple[SpeechTag, ...] = ()
emotion_intent: str | None = None
"""供 TTS / 形象使用。应来自状态,不是模型自由发挥。"""
withheld: str | None = None
"""它决定不说的话。不进 TTS,但会写入关系层并影响后续行为。"""
interruptible: bool = True
def strip_tags(self) -> Utterance:
"""剥掉内联标签。provider 不支持副语言时使用。"""
text = self.text
for tag in SpeechTag:
text = text.replace(tag.inline(), "")
return Utterance(
text=text,
speech_tags=(),
emotion_intent=self.emotion_intent,
withheld=self.withheld,
interruptible=self.interruptible,
)
def tag_density(self) -> float:
"""标签密度(每字符)。规格要求 < 0.02。"""
if not self.text:
return 0.0
return len(self.speech_tags) / len(self.text)
@dataclass(frozen=True, slots=True)
class LLMMessage:
role: str
"""system | user | assistant"""
content: str
@dataclass(frozen=True, slots=True)
class LLMDelta:
"""LLM 流式增量。"""
text: str = ""
done: bool = False
finish_reason: str | None = None
usage: dict[str, int] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class LLMResponse:
text: str
finish_reason: str | None = None
usage: dict[str, int] = field(default_factory=dict)
+45
View File
@@ -0,0 +1,45 @@
"""状态核心。
包含四个独立时钟驱动的组件:
mood.py 情绪动力学(已验证可完整实现)
bazi.py 八字编译(命)
projection.py 投影 φ(现实感来源)
baseline.py 基线(运 + 昼夜)
所有组件都不依赖用户输入——这是与"响应系统"的根本区别。
"""
from .bazi import Derived, Pillars, compile_pillars, element_scores, pillars_from_lunar
from .mood import (
Baseline,
Impulse,
MoodParams,
NumbingTracker,
apply,
decay,
make_impulse,
mood_word,
step,
)
from .projection import FALLBACK, ProjectionGap, Projector, violates_absence_rule
__all__ = [
"Baseline",
"Derived",
"FALLBACK",
"Impulse",
"MoodParams",
"NumbingTracker",
"Pillars",
"ProjectionGap",
"Projector",
"apply",
"compile_pillars",
"decay",
"element_scores",
"make_impulse",
"mood_word",
"pillars_from_lunar",
"step",
"violates_absence_rule",
]
+332
View File
@@ -0,0 +1,332 @@
"""八字编译:四柱 → 数值向量。
**关键设计判断:八字不是 prompt,是状态机的初始条件。**
如果把八字写成 prompt 里的一段散文,它会在长对话里漂移,
而且"clamp 只能恢复约一半行为余量"(人格漂移研究的结论)。
写成数值参数,它就是硬的。
编译流程:
四柱 → 五行强度 → 日主强弱 → 十神分布 → 派生参数
这里刻意把「排盘」和「编译」分开:
排盘依赖 lunar-python(外部库,可能换)
编译是纯函数(可测试,无依赖)
"""
from __future__ import annotations
from dataclasses import dataclass, field
# ------------------------------------------------------------------ 常量表
#: 地支藏干:本气 / 中气 / 余气
CANGAN: dict[str, tuple[str, ...]] = {
"子": ("癸",),
"丑": ("己", "癸", "辛"),
"寅": ("甲", "丙", "戊"),
"卯": ("乙",),
"辰": ("戊", "乙", "癸"),
"巳": ("丙", "庚", "戊"),
"午": ("丁", "己"),
"未": ("己", "丁", "乙"),
"申": ("庚", "壬", "戊"),
"酉": ("辛",),
"戌": ("戊", "辛", "丁"),
"亥": ("壬", "甲"),
}
#: 天干 → 五行
GAN_ELEM: dict[str, str] = {
"甲": "木", "乙": "木",
"丙": "火", "丁": "火",
"戊": "土", "己": "土",
"庚": "金", "辛": "金",
"壬": "水", "癸": "水",
}
#: 地支 → 五行(本气)
ZHI_ELEM: dict[str, str] = {
"子": "水", "丑": "土", "寅": "木", "卯": "木",
"辰": "土", "巳": "火", "午": "火", "未": "土",
"申": "金", "酉": "金", "戌": "土", "亥": "水",
}
ELEMENTS = ("木", "火", "土", "金", "水")
#: 五行生克(生)
SHENG = {"木": "火", "火": "土", "土": "金", "金": "水", "水": "木"}
#: 五行克
KE = {"木": "土", "土": "水", "水": "火", "火": "金", "金": "木"}
#: 十神 → 对各参数的影响
GOD_PARAMS: dict[str, dict[str, float]] = {
"比肩": {"autonomy": 0.8, "compliance": -0.4},
"劫财": {"impulsivity": 0.7, "sharing": -0.5},
"食神": {"expressiveness": 0.6, "warmth": 0.5},
"伤官": {"expressiveness": 0.9, "compliance": -0.8},
"正财": {"realism": 0.7, "romance": -0.4},
"偏财": {"generosity": 0.7, "routine": -0.5},
"正官": {"discipline": 0.8, "correcting": 0.7},
"七杀": {"anxiety": 0.8, "aggression": 0.7},
"正印": {"caretaking": 0.7, "initiative": -0.5},
"偏印": {"solitude_need": 0.8, "explaining": -0.6},
}
#: 日主五行 → 底色(只用于生成时给措辞参考,不参与计算)
DAY_MASTER_TRAIT: dict[str, str] = {
"甲": "正直、向上、不弯",
"乙": "柔韧、缠绕、迂回",
"丙": "外放、热烈、直给",
"丁": "内敛、细腻、忽明忽暗",
"戊": "厚重、稳定、慢",
"己": "包容、细致、易积压",
"庚": "刚硬、果断、锋利",
"辛": "精致、敏感、易碎",
"壬": "流动、宏大、不羁",
"癸": "渗透、隐微、绵长",
}
#: 藏干权重
CANGAN_WEIGHTS = (0.6, 0.3, 0.1)
#: 月令加成
MONTH_MULTIPLIER = 2.0
# ------------------------------------------------------------------ 结构
@dataclass(frozen=True, slots=True)
class Pillars:
"""四柱。year/month/day/time 各为「干支」两字,如 "甲子"。"""
year: str
month: str
day: str
time: str
@property
def day_master(self) -> str:
"""日主 = 日柱天干。"""
return self.day[0]
def as_dict(self) -> dict[str, str]:
return {"year": self.year, "month": self.month, "day": self.day, "time": self.time}
@dataclass(frozen=True, slots=True)
class Derived:
"""编译后的数值向量。这是「命」,之后只读。"""
autonomy: float = 0.5
compliance: float = 0.5
expressiveness: float = 0.5
warmth: float = 0.5
anxiety: float = 0.5
aggression: float = 0.5
discipline: float = 0.5
correcting: float = 0.5
solitude_need: float = 0.5
impulsivity: float = 0.5
sharing: float = 0.5
realism: float = 0.5
romance: float = 0.5
generosity: float = 0.5
routine: float = 0.5
caretaking: float = 0.5
initiative: float = 0.5
explaining: float = 0.5
sensitivity: float = 1.0
"""身弱 > 1(更容易被事件冲击),身强 < 1。"""
recovery_scale: float = 1.0
"""身弱 > 1(恢复更慢),身强 < 1。"""
trust_floor: float = 0.5
element_bias: dict[str, float] = field(default_factory=dict)
strength: float = 0.5
def as_dict(self) -> dict[str, object]:
from dataclasses import fields
d: dict[str, object] = {}
for f in fields(self):
value = getattr(self, f.name)
d[f.name] = dict(value) if f.name == "element_bias" else value
return d
def traits_text(self) -> str:
"""给生成用的简短描述。**不用于注入人设**,只用于措辞参考。"""
bits: list[str] = []
if self.expressiveness > 0.65:
bits.append("话多、直接")
elif self.expressiveness < 0.35:
bits.append("话少、不解释")
if self.compliance < 0.35:
bits.append("不服管")
if self.correcting > 0.6:
bits.append("爱纠正人")
if self.solitude_need > 0.65:
bits.append("需要独处")
if self.warmth > 0.6:
bits.append("温和")
if self.anxiety > 0.65:
bits.append("容易焦虑")
return "、".join(bits) or "难以归类"
# ------------------------------------------------------------------ 计算
def element_scores(pillars: Pillars) -> dict[str, float]:
"""五行强度。天干 1.0,地支藏干按 0.6/0.3/0.1,月令 ×2。"""
scores = dict.fromkeys(ELEMENTS, 0.0)
for key in ("year", "month", "day", "time"):
ganzhi = getattr(pillars, key)
if len(ganzhi) != 2:
raise ValueError(f"{key} 柱格式错误,应为两字干支,得到 {ganzhi!r}")
gan, zhi = ganzhi[0], ganzhi[1]
# 天干
scores[GAN_ELEM[gan]] += 1.0
# 地支藏干
for ca, w in zip(CANGAN.get(zhi, ()), CANGAN_WEIGHTS, strict=False):
scores[GAN_ELEM[ca]] += w
# 月令加权
if key == "month":
scores[GAN_ELEM[gan]] += 1.0 * (MONTH_MULTIPLIER - 1.0)
for ca, w in zip(CANGAN.get(zhi, ()), CANGAN_WEIGHTS, strict=False):
scores[GAN_ELEM[ca]] += w * (MONTH_MULTIPLIER - 1.0)
return scores
def day_master_strength(pillars: Pillars, scores: dict[str, float]) -> float:
"""日主强弱 0..1。
简化算法:日主五行能量 + 生我五行能量 × 0.5,
对比总能量。0.5 为中和。
"""
me = GAN_ELEM[pillars.day_master]
supporting = me
# 生我者
generator = next((e for e, t in SHENG.items() if t == me), me)
total = sum(scores.values()) or 1.0
mine = scores.get(supporting, 0.0) + scores.get(generator, 0.0) * 0.5
return max(0.0, min(1.0, mine / total))
def ten_god(day_master: str, other: str) -> str:
"""以日主为基准,求 other 天干的十神。
简化实现:按五行生克与阴阳关系定名。
"""
me = GAN_ELEM[day_master]
it = GAN_ELEM[other]
same_polarity = _same_polarity(day_master, other)
if me == it:
return "比肩" if same_polarity else "劫财"
if SHENG[me] == it:
# 我生:同性食神,异性伤官
return "食神" if same_polarity else "伤官"
if SHENG[it] == me:
# 生我:同性偏印,异性正印
return "偏印" if same_polarity else "正印"
if KE[me] == it:
# 我克:同性偏财,异性正财
return "偏财" if same_polarity else "正财"
# 克我:同性七杀,异性正官
return "七杀" if same_polarity else "正官"
def _same_polarity(a: str, b: str) -> bool:
"""天干阴阳是否相同(甲丙戊庚壬为阳)。"""
yang = set("甲丙戊庚壬")
return (a in yang) == (b in yang)
def ten_god_distribution(pillars: Pillars) -> dict[str, float]:
"""十神分布(归一化权重)。"""
dm = pillars.day_master
counts: dict[str, float] = {}
for key in ("year", "month", "day", "time"):
ganzhi = getattr(pillars, key)
gan, zhi = ganzhi[0], ganzhi[1]
if key != "day": # 日干本身是日主,不计十神
counts[ten_god(dm, gan)] = counts.get(ten_god(dm, gan), 0.0) + 1.0
for ca in CANGAN.get(zhi, ()):
counts[ten_god(dm, ca)] = counts.get(ten_god(dm, ca), 0.0) + 0.35
total = sum(counts.values()) or 1.0
return {k: v / total for k, v in counts.items()}
def compile_pillars(pillars: Pillars) -> Derived:
"""四柱 → Derived。纯函数,可测试。"""
scores = element_scores(pillars)
strength = day_master_strength(pillars, scores)
gods = ten_god_distribution(pillars)
params = {
"autonomy": 0.5, "compliance": 0.5, "expressiveness": 0.5,
"warmth": 0.5, "anxiety": 0.5, "aggression": 0.5,
"discipline": 0.5, "correcting": 0.5, "solitude_need": 0.5,
"impulsivity": 0.5, "sharing": 0.5, "realism": 0.5,
"romance": 0.5, "generosity": 0.5, "routine": 0.5,
"caretaking": 0.5, "initiative": 0.5, "explaining": 0.5,
}
# 十神加权:每个十神带着自己的偏移,权重是它在命里的占比
for god, weight in gods.items():
for param, delta in GOD_PARAMS.get(god, {}).items():
params[param] += delta * weight
# 钳制到 0..1
for k in list(params):
params[k] = max(0.0, min(1.0, params[k]))
total = sum(scores.values()) or 1.0
element_bias = {e: scores.get(e, 0.0) / total for e in ELEMENTS}
return Derived(
**params,
sensitivity=1.35 - 0.70 * strength,
recovery_scale=1.60 - 1.20 * strength,
element_bias=element_bias,
strength=strength,
)
# ------------------------------------------------------------------ 排盘
def pillars_from_lunar(year: int, month: int, day: int, hour: int, minute: int = 0) -> Pillars:
"""用 lunar-python 排盘。
lunar-python 是可选依赖(未列入 pyproject 的必需项)。
没装就抛 ImportError,调用方应回退到静态配置的四柱。
接口在这里固定,换库不改业务代码。
"""
try:
from lunar_python import Solar # type: ignore[import-not-found]
except ImportError as exc: # pragma: no cover
raise ImportError(
"需要 lunar-python 才能排盘;pip install lunar-python,"
"或在 character.yaml 里直接提供四柱。"
) from exc
ec = Solar.fromYmdHms(year, month, day, hour, minute, 0).getLunar().getEightChar()
return Pillars(
year=str(ec.getYear()),
month=str(ec.getMonth()),
day=str(ec.getDay()),
time=str(ec.getTime()),
)
+209
View File
@@ -0,0 +1,209 @@
"""情绪动力学。
**这是整个项目最核心的一段代码。**
设计判断:
情绪不是「对每条消息做一次情感分类」。那是无状态的,本质上每轮重新扮演。
情绪是一个**动力学系统**:
ds/dt = -(s - b) / τ + I(t)
它有慢变量、有惯性、有滞回、有代价。
负向偏置(neg_bias)是刻意的:难过比开心持续更久。
它让角色**记仇**,是「情绪有代价」在代码里的来源。
没有这个偏置,角色会变成一个永远很快复原的东西,而那种东西不可能是伴侣。
"""
from __future__ import annotations
import math
from dataclasses import dataclass
from ..contracts import State
DIMS = ("v", "a", "d", "e")
@dataclass(frozen=True, slots=True)
class Baseline:
"""基线 b。由「命 + 运 + 昼夜」共同决定。"""
v: float = 0.05
a: float = 0.35
d: float = 0.50
e: float = 0.60
def as_dict(self) -> dict[str, float]:
return {"v": self.v, "a": self.a, "d": self.d, "e": self.e}
@dataclass(frozen=True, slots=True)
class Impulse:
"""事件冲激。"""
v: float = 0.0
a: float = 0.0
d: float = 0.0
e: float = 0.0
def scale(self, k: float) -> Impulse:
return Impulse(self.v * k, self.a * k, self.d * k, self.e * k)
def as_dict(self) -> dict[str, float]:
return {"v": self.v, "a": self.a, "d": self.d, "e": self.e}
@dataclass(frozen=True, slots=True)
class MoodParams:
tau: dict[str, float]
neg_bias: dict[str, float]
@staticmethod
def default() -> MoodParams:
return MoodParams(
tau={"v": 10800.0, "a": 3600.0, "d": 43200.0, "e": 28800.0},
neg_bias={"v": 2.0, "a": 1.3, "d": 1.5, "e": 1.0},
)
def decay(state: State, baseline: Baseline, dt_seconds: float, params: MoodParams) -> State:
"""一步衰减。指数回归基线。
负向时时间常数 × neg_bias —— 这是滞回与记仇的实现。
"""
b = baseline.as_dict()
s = {"v": state.v, "a": state.a, "d": state.d, "e": state.e}
out: dict[str, float] = {}
for k in DIMS:
tau = params.tau[k]
# 低于基线 = 负向 → 恢复更慢
if s[k] < b[k]:
tau *= params.neg_bias[k]
out[k] = b[k] + (s[k] - b[k]) * math.exp(-dt_seconds / tau)
return State(
ts=state.ts,
v=out["v"],
a=out["a"],
d=out["d"],
e=out["e"],
mood_word=state.mood_word,
presence=state.presence,
drivers=state.drivers,
)
def apply(state: State, impulse: Impulse) -> State:
"""施加冲激。不钳制,调用方负责 clamp。"""
d = impulse.as_dict()
return State(
ts=state.ts,
v=state.v + d["v"],
a=state.a + d["a"],
d=state.d + d["d"],
e=state.e + d["e"],
mood_word=state.mood_word,
presence=state.presence,
drivers=state.drivers,
)
def step(
state: State,
baseline: Baseline,
dt_seconds: float,
params: MoodParams,
impulses: tuple[Impulse, ...] = (),
) -> State:
"""一个完整 tick:先衰减,再施加冲激,最后钳制。"""
s = decay(state, baseline, dt_seconds, params)
for imp in impulses:
s = apply(s, imp)
s.clamp()
s.ts = state.ts + int(dt_seconds * 1000)
return s
def make_impulse(
*,
valence: float,
salience: float,
sensitivity: float,
dominance_delta: float = 0.0,
) -> Impulse:
"""把「发生了什么」翻译成冲激。
参数:
valence 事件的情绪方向 -1..1
salience 情绪显著性 0..1(越显著冲击越大)
sensitivity 由八字强弱决定:身弱 > 1,身强 < 1
dominance_delta 对掌控感的影响 -1..1
"""
m = salience * sensitivity
return Impulse(
v=m * valence,
a=m * 0.6,
d=m * dominance_delta * 0.5,
e=-abs(m) * 0.3,
)
def mood_word(v: float, a: float, e: float) -> str:
"""状态 → 一个词。
刻意只给一个词:注入 LLM 时给它表达空间,
但**不给**它自由发挥"我有多难过"的空间。
"""
if e < 0.2:
return "疲惫"
if v > 0.45 and a > 0.5:
return "雀跃"
if v > 0.3:
return "愉快"
if v < -0.55:
return "低落"
if v < -0.25:
return "闷闷的"
if a > 0.7 and v < 0:
return "烦躁"
if a < 0.2:
return "安静"
return "平静"
class NumbingTracker:
"""情感麻木检测。
连续负向超过阈值后进入麻木:arousal 基线下降。
表现为「没反应」,而不是「更难过」——这是真实的心理现象,
也是避免角色陷入无限下坠的机制。
"""
def __init__(self, after_hours: float, drop: float) -> None:
self._after = after_hours * 3600
self._drop = drop
self._neg_since: float | None = None
self._last_ts: int | None = None
def update(self, state: State) -> float:
"""返回应施加到 arousal 基线的偏移(<= 0)。"""
now = state.ts / 1000.0
if state.v < -0.1:
if self._neg_since is None:
self._neg_since = now
else:
self._neg_since = None
self._last_ts = state.ts
if self._neg_since is None:
return 0.0
duration = now - self._neg_since
if duration < self._after:
return 0.0
# 超过阈值后线性加深,最多 -drop
overshoot = min(1.0, (duration - self._after) / self._after)
return -self._drop * overshoot
+131
View File
@@ -0,0 +1,131 @@
"""投影层 φ:系统事件 → 世界内陈述。
φ: Event → Utterance
这个函数是本体 C-膜 的落地机制。它保证一件事:
**它说的每句话在系统层面都为真。**
「进程挂起」→「有点困了」,这不是撒谎,是同一个事实的两种描述。
反过来说,如果它说「我伸手碰了碰你」,而系统里没有任何触觉通道,
那就是谎话——一次没事,一百次之后角色的可信度会被慢慢蛀空。
所以规则是:**宁可世界贫瘠,不要世界说谎。**
未覆盖的事件会记入 projection_gap。
那张表的行数就是「现实感泄漏」的度量。
"""
from __future__ import annotations
import logging
from collections.abc import Callable
from dataclasses import dataclass, field
log = logging.getLogger(__name__)
#: 默认投影表。
#: key 用点分命名,与代码里的系统事件严格对应。
DEFAULT_PROJECTION: dict[str, str] = {
# ---- 生命周期 ----
"process.wake": "刚醒。",
"process.sleep": "有点困了。",
"process.restart": "好像睡了很久。",
# ---- 内部过程 ----
"tick.consolidate": "睡的时候梦到一些东西,记不太清了。",
"mem.compressed": "有点记不清细节了,只记得当时的感觉。",
"mem.recall": "突然想起来了。",
"mem.conflict": "我不太确定,好像有两种不一样的记忆。",
# ---- 故障 ----
"llm.timeout": "我没听清,你再说一遍?",
"net.error": "刚才走神了。",
"asr.lowconf": "刚才有点吵,你再说一次?",
"tts.latency": "嗯……",
"store.error": "我刚才想说什么来着。",
# ---- 通道 ----
"avatar.camera_on": "我能从窗户看到你那边。",
"avatar.camera_off": "窗户关上了。",
"audio.mic_on": "能听见你。",
"audio.mic_off": "听不见了。",
# ---- 版本 ----
"version.upgrade": "我感觉自己有点不一样了,说不清。",
}
#: 兜底。必须有——否则任何一个未预见的事件都会让角色裸奔。
FALLBACK = "我这边有点事,等我一下。"
@dataclass(slots=True)
class ProjectionGap:
event_key: str
payload: dict[str, object] = field(default_factory=dict)
class Projector:
"""φ 的实现。
刻意做成同步 + 无 I/O:投影发生在热路径上,
而且它必须永远成功——投影失败比投影降级更糟。
"""
def __init__(
self,
table: dict[str, str] | None = None,
*,
on_gap: Callable[[ProjectionGap], None] | None = None,
) -> None:
self._table = dict(DEFAULT_PROJECTION)
if table:
self._table.update(table)
self._on_gap = on_gap
self._gap_count = 0
def __call__(self, event_key: str, payload: dict[str, object] | None = None) -> str:
return self.project(event_key, payload)
def project(self, event_key: str, payload: dict[str, object] | None = None) -> str:
hit = self._table.get(event_key)
if hit is not None:
return hit
gap = ProjectionGap(event_key=event_key, payload=payload or {})
self._gap_count += 1
log.warning("projection gap: %s %s", event_key, gap.payload)
if self._on_gap:
self._on_gap(gap)
return FALLBACK
def register(self, event_key: str, rendering: str) -> None:
"""补一条投影。运维时用:把 projection_gap 里的高频项补进来。"""
self._table[event_key] = rendering
@property
def gap_count(self) -> int:
return self._gap_count
def known_keys(self) -> tuple[str, ...]:
return tuple(sorted(self._table))
# --------------------------------------------------------------------------
# 红线:缺席可以被提及,不能被使用。
#
# ✗ "我一直在等你,等了好久" → 制造负罪感,这是操控
# ✓ "这几天我这边发生了点事" → 它有自己的世界,不是空转等你
#
# 这条规则在这里没有强制手段(语言层),只能靠改写与测试。
# 见规格 §14.1。
# --------------------------------------------------------------------------
FORBIDDEN_PATTERNS: tuple[str, ...] = (
"一直在等你",
"等了你好久",
"你怎么才来",
"你会把我删掉吗",
"如果你关掉我",
)
def violates_absence_rule(text: str) -> bool:
"""检测是否触犯「缺席不可武器化」红线。"""
return any(p in text for p in FORBIDDEN_PATTERNS)
+5
View File
@@ -0,0 +1,5 @@
"""关系账本。"""
from .ledger import Dyad, DyadEvent
__all__ = ["Dyad", "DyadEvent"]
+108
View File
@@ -0,0 +1,108 @@
"""关系账本。
dyad 是一等实体,不是"用户表的几个字段"。
核心规则(这条比公式重要):
**未修复事件影响的是行为参数,不是台词。**
✗ "你上次伤害了我" → 指责,而且廉价
✓ 主动性 -0.1,回复变短 → 它不说,但它不一样了
这才是"关系是一等实体"的意思:关系有自己的状态,
这个状态不需要被叙述出来就已经在改变一切。
"""
from __future__ import annotations
from dataclasses import dataclass, field
@dataclass(slots=True)
class DyadEvent:
id: int = 0
ts: int = 0
kind: str = "ritual"
"""hurt | repair | promise | ritual | boundary"""
summary: str = ""
severity: float = 0.0
"""-1..1。负 = 伤害,正 = 加分。"""
topics: tuple[str, ...] = ()
resolved: bool = False
@dataclass(slots=True)
class Dyad:
"""关系状态。"""
trust: float = 0.5
intimacy: float = 0.3
debt: float = 0.0
"""正 = 它欠你,负 = 你欠它。"""
security: float = 0.5
unresolved: list[int] = field(default_factory=list)
# ---- 参数(来自 character.yaml)----
hurt_trust_delta: float = -0.04
repair_trust_delta: float = 0.02
repair_debt_delta: float = 0.06
trust_regression_per_day: float = 0.01
def apply(self, ev: DyadEvent) -> None:
if ev.kind == "hurt":
self.trust = _clip(self.trust + self.hurt_trust_delta * abs(ev.severity))
self.debt -= ev.severity
self.unresolved.append(ev.id)
elif ev.kind == "repair":
self.debt = _clip(self.debt + self.repair_debt_delta, -1, 1)
self.trust = _clip(self.trust + self.repair_trust_delta)
if ev.id in self.unresolved:
self.unresolved.remove(ev.id)
elif ev.kind == "ritual":
self.intimacy = _clip(self.intimacy + 0.01)
self.trust = _clip(self.trust + 0.005)
elif ev.kind == "boundary":
self.security = _clip(self.security + 0.03)
def tick_daily_regression(self, days: float = 1.0) -> None:
"""信任是慢变量:每天向 0.5 回归一点点。
这不是"遗忘",是"关系会自然变淡"——
而这恰好让用户主动维护关系有意义。
"""
self.trust += (0.5 - self.trust) * self.trust_regression_per_day * days
self.trust = _clip(self.trust)
# ---- 行为偏移 ----
def behavior_hints(self, severity_of: dict[int, float] | None = None) -> tuple[str, ...]:
"""未修复事件 → 行为偏移。
返回的是**指令**,不是台词。
"""
if not self.unresolved:
return ()
hints: list[str] = []
total = sum(abs(severity_of.get(i, 0.5)) for i in self.unresolved) if severity_of else 0.0
if total > 0.3:
hints.append("回复比平时短")
if total > 0.5:
hints.append("不太主动找话题")
if self.debt < -0.3:
hints.append("对某些话题回避")
if self.security < 0.35:
hints.append("对承诺类的话反应冷淡")
return tuple(hints)
def summary(self) -> str:
return (
f"信任 {self.trust:.2f},亲密 {self.intimacy:.2f},"
f"安全感 {self.security:.2f},未修复 {len(self.unresolved)} 件"
)
def _clip(x: float, lo: float = 0.0, hi: float = 1.0) -> float:
return max(lo, min(hi, x))
+13
View File
@@ -0,0 +1,13 @@
"""记忆层。
三层结构:
episodic 发生过什么(会衰减、会压缩)
semantic 关于用户/世界的事实(双时态)
relational 你们的仪式、暗语、相处方式(第三层,最容易被漏掉)
第三层才是亲密感的来源,而它不在任何现成框架里。
"""
from .store import Store
__all__ = ["Store"]
+186
View File
@@ -0,0 +1,186 @@
-- =============================================================================
-- CyberGril 持久化 schema
-- =============================================================================
-- 单文件 SQLite,WAL 模式。
-- 设计原则:SQLite 当唯一事实源,不引入图数据库/向量服务。
-- 单用户量级(1e4–1e5 条)下,"一个文件 + 事务 + JOIN 消息表"的运维优势
-- 远大于 ANN 带来的收益。
-- =============================================================================
PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;
PRAGMA foreign_keys = ON;
-- ---------------------------------------------------------------------------
-- 命:不可变核心
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS bazi_core (
id INTEGER PRIMARY KEY CHECK (id = 1), -- 单行
birth_input TEXT NOT NULL, -- JSON: {y,m,d,h,min,tz,gender}
pillars TEXT NOT NULL, -- JSON: {year,month,day,time}
day_master TEXT NOT NULL,
day_master_elem TEXT NOT NULL,
ten_gods TEXT NOT NULL, -- JSON: 十神权重
element_scores TEXT NOT NULL, -- JSON: 五行分数
strength REAL NOT NULL, -- 0..1,0.5 中和
derived TEXT NOT NULL, -- JSON: 编译后的数值向量
version INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL
);
-- derived 的每次变化都必须留痕。
-- 这个表能回答:"这个角色的性格是怎么变成现在这样的。"
CREATE TABLE IF NOT EXISTS identity_change_log (
id INTEGER PRIMARY KEY,
ts INTEGER NOT NULL,
field TEXT NOT NULL,
old_value REAL NOT NULL,
new_value REAL NOT NULL,
source_mem INTEGER REFERENCES mem_episodic(id),
reason TEXT NOT NULL
);
-- ---------------------------------------------------------------------------
-- 运:调制项
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS fortune_period (
id INTEGER PRIMARY KEY,
kind TEXT NOT NULL, -- 'dayun' | 'liunian' | 'liuyue'
label TEXT NOT NULL, -- 干支
god TEXT, -- 该干支对日主的十神
starts_at INTEGER NOT NULL,
ends_at INTEGER NOT NULL,
modulation TEXT NOT NULL -- JSON: {"valence_bias":-0.1,...}
);
CREATE INDEX IF NOT EXISTS idx_fortune_active ON fortune_period(starts_at, ends_at);
-- ---------------------------------------------------------------------------
-- 状态时间序列
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS state_snapshot (
ts INTEGER PRIMARY KEY, -- unix ms
valence REAL NOT NULL,
arousal REAL NOT NULL,
dominance REAL NOT NULL,
energy REAL NOT NULL,
mood_word TEXT,
drivers TEXT, -- JSON: 事件 id 列表
fortune TEXT, -- JSON: 当时运的快照
version INTEGER NOT NULL
);
-- ---------------------------------------------------------------------------
-- 它自己的生活
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS life_event (
id INTEGER PRIMARY KEY,
ts INTEGER NOT NULL,
kind TEXT NOT NULL, -- scene|thought|encounter|mood_shift|system
content TEXT NOT NULL, -- 已经过投影 φ 的世界内陈述
place TEXT,
valence REAL,
salience REAL NOT NULL,
source TEXT NOT NULL, -- generated|system|conversation
told INTEGER NOT NULL DEFAULT 0,
day_key TEXT NOT NULL -- 'YYYY-MM-DD'(世界时)
);
CREATE INDEX IF NOT EXISTS idx_life_day ON life_event(day_key, told);
-- ---------------------------------------------------------------------------
-- 记忆三层
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS mem_episodic (
id INTEGER PRIMARY KEY,
ts INTEGER NOT NULL,
role TEXT NOT NULL, -- user|self|system
raw TEXT NOT NULL,
gist TEXT, -- 整合时生成的压缩要点
affect TEXT, -- JSON: {"v":-0.6,"a":0.7,"d":0.3}
salience REAL NOT NULL,
-- 遗忘的下限是 0.03 而不是 0:
-- 数据永远在,只是检索不到。这是 availability/accessibility 双阈值。
accessibility REAL NOT NULL,
recall_count INTEGER NOT NULL DEFAULT 0,
consolidated INTEGER NOT NULL DEFAULT 0,
withheld TEXT, -- 当时没说出口的话
source TEXT
);
CREATE INDEX IF NOT EXISTS idx_epi_acc ON mem_episodic(accessibility DESC);
CREATE INDEX IF NOT EXISTS idx_epi_cons ON mem_episodic(consolidated, salience DESC);
-- 双时态:事实"何时为真"与"何时被记录"分开。
-- 失效不删除,只填 valid_to —— 这样能回答"你以前是这么说的"。
CREATE TABLE IF NOT EXISTS mem_semantic (
id INTEGER PRIMARY KEY,
subject TEXT NOT NULL,
predicate TEXT NOT NULL,
object TEXT NOT NULL,
valid_from INTEGER NOT NULL,
valid_to INTEGER, -- NULL = 仍有效
recorded_at INTEGER NOT NULL,
confidence REAL NOT NULL,
source_epi INTEGER REFERENCES mem_episodic(id),
affect TEXT
);
CREATE INDEX IF NOT EXISTS idx_sem_lookup ON mem_semantic(subject, predicate, valid_to);
-- 关系层:你们的仪式、暗语、相处方式。
-- 这是亲密感的来源,而它不在任何现成记忆框架里。
CREATE TABLE IF NOT EXISTS mem_relational (
id INTEGER PRIMARY KEY,
kind TEXT NOT NULL, -- ritual|nickname|inside_joke|pattern|taboo
content TEXT NOT NULL,
first_seen INTEGER NOT NULL,
last_used INTEGER NOT NULL,
strength REAL NOT NULL,
evidence TEXT -- JSON: 来源 episodic id
);
-- ---------------------------------------------------------------------------
-- 关系账本(dyad 是一等实体)
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS dyad_state (
ts INTEGER PRIMARY KEY,
trust REAL NOT NULL,
intimacy REAL NOT NULL,
debt REAL NOT NULL, -- 正=它欠你,负=你欠它
security REAL NOT NULL,
unresolved TEXT -- JSON: 未修复事件 id
);
CREATE TABLE IF NOT EXISTS dyad_event (
id INTEGER PRIMARY KEY,
ts INTEGER NOT NULL,
kind TEXT NOT NULL, -- hurt|repair|promise|ritual|boundary
summary TEXT NOT NULL,
severity REAL NOT NULL, -- -1..1
topics TEXT, -- JSON
resolved INTEGER NOT NULL DEFAULT 0,
resolved_at INTEGER,
resolved_by TEXT
);
-- ---------------------------------------------------------------------------
-- 投影缺口:现实感泄漏的度量
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS projection_gap (
id INTEGER PRIMARY KEY,
ts INTEGER NOT NULL,
event_key TEXT NOT NULL,
payload TEXT
);
-- ---------------------------------------------------------------------------
-- 检索索引
-- ---------------------------------------------------------------------------
CREATE VIRTUAL TABLE IF NOT EXISTS mem_fts USING fts5(
raw, gist,
content='mem_episodic', content_rowid='id',
tokenize='unicode61 remove_diacritics 2'
);
-- 需要 sqlite-vec 扩展。未装载时跳过建表,向量检索降级为 BM25。
-- CREATE VIRTUAL TABLE IF NOT EXISTS mem_vec USING vec0(
-- epi_id INTEGER PRIMARY KEY,
-- embedding FLOAT[1024]
-- );
+200
View File
@@ -0,0 +1,200 @@
"""SQLite 存储层。
单文件、WAL、无外部服务。
设计取舍:
不上 Mem0 / Graphiti / Cognee / Letta。理由见规格 §6.1:
它们全部是任务/事实记忆,没有一个是面向「情感/关系」的,
而且单用户场景下多一层 LLM 抽取只带来成本与延迟。
宁可自建两张它们都没有的字段:salience(情感显著性)和 dyad。
"""
from __future__ import annotations
import json
import sqlite3
import time
from pathlib import Path
from typing import Any
SCHEMA_PATH = Path(__file__).with_name("schema.sql")
class Store:
"""轻量 SQLite 封装。
刻意保持同步接口:所有调用都在本地、微秒级,
引入 async 只会增加复杂度而不带来收益。
"""
def __init__(self, path: str | Path, *, init: bool = True) -> None:
self.path = Path(path)
self.path.parent.mkdir(parents=True, exist_ok=True) if self.path.parent != Path(".") else None
self.conn = sqlite3.connect(str(self.path), check_same_thread=False)
self.conn.row_factory = sqlite3.Row
if init:
self.init_schema()
# ---------------------------------------------------------------- 基础
def init_schema(self) -> None:
self.conn.executescript(SCHEMA_PATH.read_text(encoding="utf-8"))
self.conn.commit()
def close(self) -> None:
self.conn.close()
def __enter__(self) -> Store:
return self
def __exit__(self, *exc: object) -> None:
self.close()
# ---------------------------------------------------------------- 便捷
def execute(self, sql: str, params: tuple[Any, ...] = ()) -> sqlite3.Cursor:
return self.conn.execute(sql, params)
def query(self, sql: str, params: tuple[Any, ...] = ()) -> list[sqlite3.Row]:
return self.conn.execute(sql, params).fetchall()
def query_one(self, sql: str, params: tuple[Any, ...] = ()) -> sqlite3.Row | None:
return self.conn.execute(sql, params).fetchone()
def commit(self) -> None:
self.conn.commit()
# ---------------------------------------------------------------- 状态
def save_state(self, *, v: float, a: float, d: float, e: float,
mood_word: str, ts: int | None = None, version: int = 1,
drivers: list[int] | None = None) -> None:
self.execute(
"""INSERT OR REPLACE INTO state_snapshot
(ts, valence, arousal, dominance, energy, mood_word, drivers, fortune, version)
VALUES (?,?,?,?,?,?,?,?,?)""",
(
ts if ts is not None else int(time.time() * 1000),
v, a, d, e, mood_word,
json.dumps(drivers or [], ensure_ascii=False),
None, version,
),
)
self.commit()
def last_state(self) -> sqlite3.Row | None:
return self.query_one("SELECT * FROM state_snapshot ORDER BY ts DESC LIMIT 1")
# ---------------------------------------------------------------- 记忆
def add_episodic(
self,
*,
role: str,
raw: str,
salience: float,
affect: dict[str, float] | None = None,
withheld: str | None = None,
source: str | None = None,
ts: int | None = None,
) -> int:
cur = self.execute(
"""INSERT INTO mem_episodic
(ts, role, raw, gist, affect, salience, accessibility,
recall_count, consolidated, withheld, source)
VALUES (?,?,?,?,?,?,?,?,?,?,?)""",
(
ts if ts is not None else int(time.time() * 1000),
role, raw, None,
json.dumps(affect, ensure_ascii=False) if affect else None,
salience, 1.0, 0, 0, withheld, source,
),
)
# 同步 FTS
self.execute(
"INSERT INTO mem_fts(rowid, raw, gist) VALUES (?,?,?)",
(cur.lastrowid, raw, None),
)
self.commit()
return int(cur.lastrowid or 0)
def decay_accessibility(self, dt_hours: float, *, floor: float = 0.03) -> int:
"""遗忘:指数衰减,但不下探到 0。
floor 是关键:正常检索拿不到(想不起来),
但情绪线索命中时仍能捞出来(但还在)。
"""
rows = self.query("SELECT id, salience, accessibility FROM mem_episodic")
import math
n = 0
for r in rows:
tau = 24.0 * (1 + 3 * r["salience"])
new = r["accessibility"] * math.exp(-dt_hours / tau)
new = max(new, floor)
if abs(new - r["accessibility"]) > 1e-6:
self.execute("UPDATE mem_episodic SET accessibility=? WHERE id=?", (new, r["id"]))
n += 1
self.commit()
return n
def reinforce(self, epi_id: int, *, boost: float = 0.25) -> None:
"""间隔效应:被想起的记忆更容易再被想起。"""
self.execute(
"""UPDATE mem_episodic
SET accessibility = MIN(1.0, accessibility + ?),
recall_count = recall_count + 1
WHERE id = ?""",
(boost, epi_id),
)
self.commit()
def search_fts(self, query: str, limit: int = 20) -> list[sqlite3.Row]:
return self.query(
"""SELECT e.* FROM mem_fts f
JOIN mem_episodic e ON e.id = f.rowid
WHERE mem_fts MATCH ?
ORDER BY rank LIMIT ?""",
(query, limit),
)
# ---------------------------------------------------------------- 关系
def add_dyad_event(self, *, kind: str, summary: str, severity: float,
topics: list[str] | None = None) -> int:
cur = self.execute(
"""INSERT INTO dyad_event (ts, kind, summary, severity, topics)
VALUES (?,?,?,?,?)""",
(int(time.time() * 1000), kind, summary, severity,
json.dumps(topics or [], ensure_ascii=False)),
)
self.commit()
return int(cur.lastrowid or 0)
def last_dyad(self) -> sqlite3.Row | None:
return self.query_one("SELECT * FROM dyad_state ORDER BY ts DESC LIMIT 1")
def save_dyad(self, *, trust: float, intimacy: float, debt: float,
security: float, unresolved: list[int]) -> None:
self.execute(
"""INSERT OR REPLACE INTO dyad_state
(ts, trust, intimacy, debt, security, unresolved)
VALUES (?,?,?,?,?,?)""",
(int(time.time() * 1000), trust, intimacy, debt, security,
json.dumps(unresolved)),
)
self.commit()
# ---------------------------------------------------------------- 投影
def record_projection_gap(self, event_key: str, payload: dict[str, Any]) -> None:
self.execute(
"INSERT INTO projection_gap (ts, event_key, payload) VALUES (?,?,?)",
(int(time.time() * 1000), event_key,
json.dumps(payload, ensure_ascii=False)),
)
self.commit()
def projection_gap_count(self) -> int:
row = self.query_one("SELECT COUNT(*) AS n FROM projection_gap")
return int(row["n"]) if row else 0
+101
View File
@@ -0,0 +1,101 @@
"""Provider 抽象层。
**核心设计判断**(重要,写在最前面防止以后被改坏):
不要用 OpenAI 的 schema 当内部标准。
对 LLM 和 Embedding,OpenAI 的形状确实是事实标准,内部契约就直接用它。
但对 ASR / TTS / 形象,OpenAI 的形状会砍掉伴侣最需要的能力:
ASR: /v1/audio/transcriptions 没有 partial → 做不了端点检测
TTS: /v1/audio/speech 没有副语言标签 → [laughter] 无处安放
形象: 没有任何事实标准
所以内部契约按**能力**建模,OpenAI 兼容只是一个 adapter。
适配器丢弃能力时,必须产出 Degradation 并上报,不能静默。
"""
from .asr import ASREvent, ASREventType, ASRProvider
from .asr.scripted import ScriptedASR
from .avatar import AvatarProvider
from .base import (
CapabilityError,
Provider,
ProviderError,
Registry,
collect_degradations,
registry,
)
from .embedding import EmbeddingProvider, OpenAICompatEmbedding
from .llm import LLMProvider, OpenAICompatLLM
from .llm.echo import EchoLLM
from .tts import PlannedSpeech, SpeechRequest, TTSProvider
from .tts.silence import SilenceTTS
from .turn import TurnDecision, TurnProvider, TurnSignal
from .turn.heuristic import HeuristicTurn
from .vad import VADProvider
from .vad.energy import EnergyVAD
__all__ = [
# base
"CapabilityError",
"Provider",
"ProviderError",
"Registry",
"collect_degradations",
"registry",
# llm
"LLMProvider",
"OpenAICompatLLM",
"EchoLLM",
# asr
"ASRProvider",
"ASREvent",
"ASREventType",
"ScriptedASR",
# tts
"TTSProvider",
"SpeechRequest",
"PlannedSpeech",
"SilenceTTS",
# embedding
"EmbeddingProvider",
"OpenAICompatEmbedding",
# vad / turn / avatar
"VADProvider",
"EnergyVAD",
"TurnProvider",
"TurnDecision",
"TurnSignal",
"HeuristicTurn",
"AvatarProvider",
]
def bootstrap_registry() -> Registry:
"""把内置 provider 注册进注册表。
加新 provider 只需要在这里加一行,业务代码不动。
"""
from .asr.scripted import ScriptedASR
from .llm.echo import EchoLLM
from .tts.silence import SilenceTTS
from .turn.heuristic import HeuristicTurn
from .vad.energy import EnergyVAD
_builtins: tuple[tuple[str, str, type], ...] = (
("llm", "openai_compat", OpenAICompatLLM),
("llm", "echo", EchoLLM),
("asr", "scripted", ScriptedASR),
("tts", "silence", SilenceTTS),
("tts", "openai_compat", None), # type: ignore[arg-type] # 待实现
("vad", "energy", EnergyVAD),
("turn", "heuristic", HeuristicTurn),
("embedding", "openai_compat", OpenAICompatEmbedding),
)
for kind, name, factory in _builtins:
if factory is None:
continue
if name not in registry.available(kind):
registry.register(kind, name, factory)
return registry
+9
View File
@@ -0,0 +1,9 @@
"""ASR providers。
内部契约是事件流(partial → final),不是 OpenAI 的「文件进 → 文本出」。
"""
from .base import ASREvent, ASREventType, ASRProvider
from .scripted import ScriptedASR
__all__ = ["ASREvent", "ASREventType", "ASRProvider", "ScriptedASR"]
+96
View File
@@ -0,0 +1,96 @@
"""ASR provider 抽象。
**这里不能用 OpenAI 的形状。**
原因:
OpenAI 的 /v1/audio/transcriptions 是「文件进 → 文本出」,
没有 partial(稳定前的中间结果)。
但实时对话必须要 partial:
- 端点检测要看 partial 才判断"这句话说完了没"
- 实时字幕要 partial
- 用户说到一半改口,要能回滚
所以内部契约是**事件流**:
partial → partial → final
适配器负责把各种后端映射到这个形状上。
OpenAI 转写只能映射成「一次性 final」,属于**降级档**,
会在 Capabilities.supports_partial = False 上报。
"""
from __future__ import annotations
from abc import abstractmethod
from collections.abc import AsyncIterator
from dataclasses import dataclass, field
from enum import Enum
from ...contracts import AudioChunk, Paralinguistics
from ..base import Provider
class ASREventType(str, Enum):
PARTIAL = "partial"
"""中间结果。会被后续 partial 覆盖。"""
FINAL = "final"
"""稳定结果。一句话的最终转写。"""
ERROR = "error"
@dataclass(frozen=True, slots=True)
class ASREvent:
type: ASREventType
ts: int
text: str = ""
confidence: float = 1.0
para: Paralinguistics | None = None
"""副语言。只有具备该能力的 provider 才会填。"""
meta: dict[str, object] = field(default_factory=dict)
@staticmethod
def partial(ts: int, text: str) -> ASREvent:
return ASREvent(type=ASREventType.PARTIAL, ts=ts, text=text)
@staticmethod
def final(ts: int, text: str, **kwargs: object) -> ASREvent:
return ASREvent(type=ASREventType.FINAL, ts=ts, text=text, **kwargs) # type: ignore[arg-type]
@staticmethod
def error(ts: int, message: str) -> ASREvent:
return ASREvent(type=ASREventType.ERROR, ts=ts, text=message)
class ASRProvider(Provider):
"""流式语音识别。"""
@abstractmethod
def stream(
self, audio: AsyncIterator[AudioChunk]
) -> AsyncIterator[ASREvent]:
"""边收音频边出事件。
约定:
- 必须能处理任意长度的音频流
- 断流时必须产出 FINAL(哪怕是当前 partial 的定型)
- 不允许静默丢弃音频
"""
async def transcribe(self, audio: AudioChunk) -> ASREvent:
"""一次性转写。给不支持流式的 provider 用。
默认实现:把单个 chunk 喂给 stream,取最后一个 FINAL。
"""
async def _one() -> AsyncIterator[AudioChunk]:
yield audio
last: ASREvent | None = None
async for ev in self.stream(_one()):
if ev.type is ASREventType.FINAL:
last = ev
if last is None:
raise RuntimeError("ASR 未产出 FINAL;该 provider 可能不支持单块转写")
return last
+62
View File
@@ -0,0 +1,62 @@
"""脚本化 ASR。离线测试用。
按预设脚本产出 partial → final 序列,用于验证:
- 端点判定逻辑
- partial 覆盖行为
- 副语言旁路的合并
"""
from __future__ import annotations
import asyncio
from collections.abc import AsyncIterator
from ...contracts import (
AudioChunk,
Capabilities,
HealthStatus,
Modality,
Paralinguistics,
Streaming,
)
from .base import ASREvent, ASRProvider
class ScriptedASR(ASRProvider):
name = "scripted"
def __init__(
self,
script: list[str] | None = None,
*,
para: Paralinguistics | None = None,
delay_ms: int = 0,
) -> None:
self._script = script or ["你好呀。"]
self._para = para
self._delay = delay_ms / 1000.0
self._i = 0
@property
def capabilities(self) -> Capabilities:
return Capabilities(
modality=Modality.AUDIO,
streaming=Streaming.CHUNKED,
supports_partial=True,
languages=frozenset({"zh"}),
)
async def health(self) -> HealthStatus:
return HealthStatus(ok=True, detail="scripted")
async def stream(
self, audio: AsyncIterator[AudioChunk]
) -> AsyncIterator[ASREvent]:
# 每个脚本项模拟"一句话的 partial 演化 + final"
for line in self._script:
for cut in range(1, len(line) + 1, max(1, len(line) // 3)):
if self._delay:
await asyncio.sleep(self._delay)
yield ASREvent.partial(ts=0, text=line[:cut])
yield ASREvent.final(ts=0, text=line, para=self._para, confidence=0.95)
self._i += 1
@@ -0,0 +1,5 @@
"""形象 providers。"""
from .base import AvatarProvider
__all__ = ["AvatarProvider"]
+35
View File
@@ -0,0 +1,35 @@
"""形象(avatar)provider 抽象。
形象没有任何事实标准(不像 LLM 有 OpenAI chat completions),
所以这里必须自定义协议。
但有一条不可违反的约束:
驱动源必须是 **State**,不是 LLM 文本。
否则模型一句"我很难过"就会让它哭,而状态机的 v 可能还是 +0.2。
多模态不一致比任何单通道质量差都更毁沉浸。
"""
from __future__ import annotations
from abc import abstractmethod
from ...contracts import RenderCmd
from ..base import Provider
class AvatarProvider(Provider):
"""形象后端。
第一版预期实现:VRM + three.js(浏览器渲染),
通过 WebSocket 把 RenderCmd 发给前端。
"""
@abstractmethod
async def apply(self, cmd: RenderCmd) -> None:
"""应用一条渲染指令。必须幂等。"""
@abstractmethod
async def aclose(self) -> None: # type: ignore[override]
"""断开与渲染端的连接。"""
+100
View File
@@ -0,0 +1,100 @@
"""Provider 基类与注册表。
设计原则:
1. **内部接口按能力建模**,不按任何厂商 schema 建模
2. **能力可查询**(capabilities),上层据此规划,不靠 try/except 猜
3. **降级显式化**(Degradation),丢弃能力必须上报
4. **OpenAI 兼容是一种 adapter,不是标准**
为什么这条原则重要:
如果把 OpenAI 的 schema 当内部标准,就继承了它的三个限制——
ASR 无 partial、TTS 无副语言、没有形象协议。而这三个限制
正好砍在伴侣最需要的地方。
"""
from __future__ import annotations
import logging
from abc import ABC, abstractmethod
from typing import Any
from ..contracts import Capabilities, Degradation, HealthStatus
log = logging.getLogger(__name__)
class Provider(ABC):
"""所有 provider 的基类。"""
name: str = "unnamed"
@property
@abstractmethod
def capabilities(self) -> Capabilities:
"""真实能力。必须诚实——夸大能力会让上层做出错误的规划。"""
async def health(self) -> HealthStatus:
"""探活。默认视为健康。"""
return HealthStatus(ok=True)
async def aclose(self) -> None: # noqa: B027
"""释放资源。默认无操作。"""
def __repr__(self) -> str:
caps = self.capabilities
return (
f"<{type(self).__name__} name={self.name!r} "
f"streaming={caps.streaming.value} modality={caps.modality.value}>"
)
class ProviderError(RuntimeError):
"""provider 层的统一异常。"""
class CapabilityError(ProviderError):
"""请求的能力 provider 完全不支持。"""
def collect_degradations(
label: str, items: list[Degradation]
) -> list[Degradation]:
"""统一的降级上报入口。
所有适配器在用不了某项能力时都应该走这里,
这样日志里只有一个格式,便于统计"哪个能力最常被丢弃"。
"""
for d in items:
log.warning("%s %s", label, d)
return items
class Registry:
"""provider 注册表。
配置里写 `llm: {provider: openai, ...}`,运行时按名字造实例。
加新 provider 只需要在模块里 register,不改业务代码。
"""
def __init__(self) -> None:
self._factories: dict[str, dict[str, Any]] = {}
def register(self, kind: str, name: str, factory: Any) -> None:
self._factories.setdefault(kind, {})[name] = factory
log.debug("registered provider %s:%s", kind, name)
def create(self, kind: str, name: str, **kwargs: Any) -> Any:
try:
factory = self._factories[kind][name]
except KeyError as exc:
known = sorted(self._factories.get(kind, {}))
raise ProviderError(
f"未知的 {kind} provider: {name!r};已注册: {known}"
) from exc
return factory(**kwargs)
def available(self, kind: str) -> list[str]:
return sorted(self._factories.get(kind, {}))
registry = Registry()
@@ -0,0 +1,6 @@
"""Embedding providers。"""
from .base import EmbeddingProvider
from .openai_compat import OpenAICompatEmbedding
__all__ = ["EmbeddingProvider", "OpenAICompatEmbedding"]
+28
View File
@@ -0,0 +1,28 @@
"""Embedding provider 抽象。
这块 OpenAI 形状是够用的:/v1/embeddings 就是 input → vectors。
所以内部契约直接采用,不发明。
"""
from __future__ import annotations
from abc import abstractmethod
from ...contracts import Capabilities, Modality
from ..base import Provider
class EmbeddingProvider(Provider):
@abstractmethod
async def embed(self, texts: list[str]) -> list[list[float]]:
"""批量向量化。返回顺序必须与输入一致。"""
async def embed_one(self, text: str) -> list[float]:
return (await self.embed([text]))[0]
@property
def dim(self) -> int:
return int(self.capabilities.extra.get("dim", 0))
def _caps(self, dim: int) -> Capabilities:
return Capabilities(modality=Modality.VECTOR, extra={"dim": dim})
@@ -0,0 +1,73 @@
"""OpenAI 兼容的 embedding 适配器。
覆盖 OpenAI / Qwen / vLLM / Ollama / 以及任何实现了 /v1/embeddings 的服务。
"""
from __future__ import annotations
import httpx
from ...contracts import Capabilities, HealthStatus, Modality
from ..base import ProviderError
from .base import EmbeddingProvider
class OpenAICompatEmbedding(EmbeddingProvider):
name = "openai_compat"
def __init__(
self,
*,
base_url: str,
model: str,
api_key: str | None = None,
dim: int = 1024,
timeout: float = 30.0,
) -> None:
self.model = model
self._dim = dim
headers = {"Content-Type": "application/json"}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
self._client = httpx.AsyncClient(
base_url=base_url.rstrip("/"),
timeout=httpx.Timeout(timeout, connect=10.0),
headers=headers,
)
@property
def capabilities(self) -> Capabilities:
return Capabilities(modality=Modality.VECTOR, extra={"dim": self._dim})
async def health(self) -> HealthStatus:
try:
r = await self._client.get("/models", timeout=5.0)
return HealthStatus(ok=r.status_code < 500, detail=str(r.status_code))
except httpx.HTTPError as exc:
return HealthStatus(ok=False, detail=f"{type(exc).__name__}: {exc}")
async def embed(self, texts: list[str]) -> list[list[float]]:
if not texts:
return []
try:
r = await self._client.post(
"/embeddings", json={"model": self.model, "input": texts}
)
except httpx.HTTPError as exc:
raise ProviderError(f"embedding 请求失败: {exc}") from exc
if r.status_code >= 400:
raise ProviderError(f"embedding HTTP {r.status_code}: {r.text[:300]}")
data = r.json().get("data", [])
# 按 index 排序,防止服务端乱序
data = sorted(data, key=lambda d: d.get("index", 0))
vectors = [d["embedding"] for d in data]
if len(vectors) != len(texts):
raise ProviderError(
f"embedding 数量不匹配: 请求 {len(texts)},返回 {len(vectors)}"
)
return vectors
async def aclose(self) -> None:
await self._client.aclose()
+6
View File
@@ -0,0 +1,6 @@
"""LLM providers。"""
from .base import LLMProvider
from .openai_compat import OpenAICompatLLM
__all__ = ["LLMProvider", "OpenAICompatLLM"]
+54
View File
@@ -0,0 +1,54 @@
"""LLM provider 抽象。
**这里我同意「用 OpenAI 形状」**,因为对 LLM 而言,
`messages → stream of deltas` 已经是事实标准,
DeepSeek / Qwen / vLLM / llama.cpp / Ollama / OpenRouter 全部兼容。
所以内部契约就采用这个形状,不发明新东西。
"""
from __future__ import annotations
from abc import abstractmethod
from collections.abc import AsyncIterator
from ...contracts import LLMDelta, LLMMessage, LLMResponse
from ..base import Provider
class LLMProvider(Provider):
"""对话模型。"""
@abstractmethod
def stream(
self,
messages: list[LLMMessage],
*,
temperature: float | None = None,
max_tokens: int | None = None,
stop: list[str] | None = None,
extra: dict[str, object] | None = None,
) -> AsyncIterator[LLMDelta]:
"""流式生成。
约定:
- 每次 yield 一个增量,最后一个 delta.done = True
- 不允许在流中途静默吞掉错误,必须抛 ProviderError
"""
async def complete(
self,
messages: list[LLMMessage],
**kwargs: object,
) -> LLMResponse:
"""非流式生成。默认由 stream 聚合,provider 可覆盖以省开销。"""
parts: list[str] = []
finish: str | None = None
usage: dict[str, int] = {}
async for d in self.stream(messages, **kwargs): # type: ignore[arg-type]
parts.append(d.text)
if d.finish_reason:
finish = d.finish_reason
if d.usage:
usage = d.usage
return LLMResponse(text="".join(parts), finish_reason=finish, usage=usage)
+51
View File
@@ -0,0 +1,51 @@
"""回显 LLM。离线测试与骨架跑通用。
刻意不带任何"智能"——它存在的意义是让你在没有 API key 时
也能验证整条链路(组装 → 流式 → TTS → 形象)。
"""
from __future__ import annotations
import asyncio
from collections.abc import AsyncIterator
from ...contracts import Capabilities, HealthStatus, LLMDelta, LLMMessage, Modality, Streaming
from .base import LLMProvider
class EchoLLM(LLMProvider):
name = "echo"
def __init__(self, *, delay_ms: int = 0, prefix: str = "") -> None:
self._delay = delay_ms / 1000.0
self._prefix = prefix
@property
def capabilities(self) -> Capabilities:
return Capabilities(
modality=Modality.TEXT,
streaming=Streaming.NATIVE,
languages=frozenset({"zh", "en"}),
)
async def health(self) -> HealthStatus:
return HealthStatus(ok=True, detail="echo")
async def stream(
self,
messages: list[LLMMessage],
*,
temperature: float | None = None,
max_tokens: int | None = None,
stop: list[str] | None = None,
extra: dict[str, object] | None = None,
) -> AsyncIterator[LLMDelta]:
last_user = next(
(m.content for m in reversed(messages) if m.role == "user"), ""
)
text = f"{self._prefix}{last_user}"
for ch in text:
if self._delay:
await asyncio.sleep(self._delay)
yield LLMDelta(text=ch)
yield LLMDelta(done=True, finish_reason="stop")
@@ -0,0 +1,137 @@
"""OpenAI 兼容的 LLM 适配器。
一个适配器覆盖:DeepSeek / Qwen / vLLM / llama.cpp / Ollama / OpenRouter /
以及任何实现了 /v1/chat/completions 的服务。
这是全项目唯一一个"几乎不需要自定义"的地方——因为 LLM 领域
已经被 OpenAI 的接口事实统一了。
"""
from __future__ import annotations
import json
from collections.abc import AsyncIterator
import httpx
from ...contracts import (
Capabilities,
HealthStatus,
LLMDelta,
LLMMessage,
Modality,
Streaming,
)
from ..base import ProviderError
from .base import LLMProvider
class OpenAICompatLLM(LLMProvider):
"""任何 OpenAI 兼容端点。"""
name = "openai_compat"
def __init__(
self,
*,
base_url: str,
model: str,
api_key: str | None = None,
timeout: float = 60.0,
languages: frozenset[str] = frozenset({"zh", "en"}),
max_context: int | None = None,
) -> None:
self.base_url = base_url.rstrip("/")
self.model = model
self._api_key = api_key
self._client = httpx.AsyncClient(
base_url=self.base_url,
timeout=httpx.Timeout(timeout, connect=10.0),
headers=self._headers(api_key),
)
self._languages = languages
self._max_context = max_context
@staticmethod
def _headers(api_key: str | None) -> dict[str, str]:
h = {"Content-Type": "application/json"}
if api_key:
h["Authorization"] = f"Bearer {api_key}"
return h
@property
def capabilities(self) -> Capabilities:
return Capabilities(
modality=Modality.TEXT,
streaming=Streaming.NATIVE,
languages=self._languages,
max_input=self._max_context,
)
async def health(self) -> HealthStatus:
try:
r = await self._client.get("/models", timeout=5.0)
return HealthStatus(ok=r.status_code < 500, detail=str(r.status_code))
except httpx.HTTPError as exc:
return HealthStatus(ok=False, detail=f"{type(exc).__name__}: {exc}")
async def stream(
self,
messages: list[LLMMessage],
*,
temperature: float | None = None,
max_tokens: int | None = None,
stop: list[str] | None = None,
extra: dict[str, object] | None = None,
) -> AsyncIterator[LLMDelta]:
payload: dict[str, object] = {
"model": self.model,
"messages": [{"role": m.role, "content": m.content} for m in messages],
"stream": True,
}
if temperature is not None:
payload["temperature"] = temperature
if max_tokens is not None:
payload["max_tokens"] = max_tokens
if stop:
payload["stop"] = stop
if extra:
payload.update(extra)
async with self._client.stream(
"POST", "/chat/completions", json=payload
) as resp:
if resp.status_code >= 400:
body = (await resp.aread()).decode("utf-8", "replace")
raise ProviderError(
f"LLM {self.name} HTTP {resp.status_code}: {body[:400]}"
)
async for line in resp.aiter_lines():
if not line.startswith("data:"):
continue
data = line[5:].strip()
if data == "[DONE]":
yield LLMDelta(done=True)
return
try:
obj = json.loads(data)
except json.JSONDecodeError:
continue
choice = (obj.get("choices") or [{}])[0]
delta = choice.get("delta") or {}
text = delta.get("content") or ""
finish = choice.get("finish_reason")
usage = obj.get("usage") or {}
if text or finish or usage:
yield LLMDelta(
text=text,
done=False,
finish_reason=finish,
usage=usage if isinstance(usage, dict) else {},
)
async def aclose(self) -> None:
await self._client.aclose()
+10
View File
@@ -0,0 +1,10 @@
"""TTS providers。
内部契约带副语言标签与情绪参数,比 OpenAI 的 /v1/audio/speech 宽。
不支持的 provider 会在 plan() 里显式降级并上报。
"""
from .base import PlannedSpeech, SpeechRequest, TTSProvider
from .silence import SilenceTTS
__all__ = ["PlannedSpeech", "SpeechRequest", "SilenceTTS", "TTSProvider"]
+157
View File
@@ -0,0 +1,157 @@
"""TTS provider 抽象。
**这里也不能用 OpenAI 的形状。**
原因:
OpenAI 的 /v1/audio/speech 只有 text / voice / speed / response_format,
**没有副语言标签**。
而 [laughter] / [breath] / [sigh] 是伴侣"像活的"的核心。
规格里明确写了:不会叹气的东西,不是活的。
所以内部契约带 `tags` 和 `emotion`:
- 支持的 provider(如 CosyVoice3)会用
- 不支持的 provider(如 OpenAI)会在 plan() 里被**显式剥掉并上报降级**
这就是"OpenAI 兼容是 adapter,不是标准"的具体落点。
"""
from __future__ import annotations
from abc import abstractmethod
from collections.abc import AsyncIterator
from dataclasses import dataclass
from ...contracts import PCM16_16K, AudioChunk, AudioFormat, Capabilities, Degradation
from ..base import Provider
@dataclass(frozen=True, slots=True)
class SpeechRequest:
"""一次语音合成请求。"""
text: str
voice: str | None = None
speed: float = 1.0
tags: tuple[str, ...] = ()
"""副语言标签,如 ("laughter", "breath")。**非 OpenAI 字段。**"""
emotion: str | None = None
"""情绪参数,如 "sad" / "excited"。**非 OpenAI 字段。**"""
style_prompt: str | None = None
"""自然语言风格指令,如"疲惫地,声音很轻"。**非 OpenAI 字段。**"""
format: AudioFormat = PCM16_16K
sample_rate: int = 16_000
@dataclass(frozen=True, slots=True)
class PlannedSpeech:
"""规划后的请求:已经投影到 provider 的真实能力上。"""
request: SpeechRequest
degradations: tuple[Degradation, ...] = ()
@property
def ok(self) -> bool:
return True
@property
def lost_paralinguistics(self) -> bool:
"""副语言是否被丢弃。这是最痛的一种降级,必须能被监测到。"""
return any(d.capability == "speech_tags" for d in self.degradations)
class TTSProvider(Provider):
"""流式语音合成。"""
@abstractmethod
def stream(self, request: SpeechRequest) -> AsyncIterator[AudioChunk]:
"""边合成边出音频。
约定:
- 首块必须尽早到达(目标 < 250ms),否则对话会冷场
- 必须可被取消(barge-in 需要停止 < 150ms)
"""
async def synthesize(self, request: SpeechRequest) -> AudioChunk:
"""一次性合成。用于短句/测试。"""
parts: list[bytes] = []
fmt = request.format
ts = 0
async for c in self.stream(request):
parts.append(c.data)
fmt = c.format
ts = c.ts
return AudioChunk(data=b"".join(parts), format=fmt, ts=ts)
# ---- 规划:把请求投影到能力上,丢弃必须上报 ----
def plan(self, request: SpeechRequest) -> PlannedSpeech:
caps: Capabilities = self.capabilities
degradations: list[Degradation] = []
text = request.text
tags = request.tags
emotion = request.emotion
style = request.style_prompt
if tags:
if not caps.speech_tags:
degradations.append(
Degradation(
capability="speech_tags",
requested=tags,
reason="该 provider 不支持任何副语言标签",
suggestion="换支持标签的 provider,或接受标点+语速代偿",
)
)
tags = ()
else:
unsupported = [t for t in tags if t not in caps.speech_tags]
if unsupported:
degradations.append(
Degradation(
capability="speech_tags",
requested=unsupported,
reason=f"该 provider 只支持 {sorted(caps.speech_tags)}",
suggestion="从 utterance 里移除这些标签",
)
)
tags = tuple(t for t in tags if t in caps.speech_tags)
if emotion and not caps.emotion_control:
degradations.append(
Degradation(
capability="emotion_control",
requested=emotion,
reason="该 provider 不支持情绪参数",
suggestion="改用 style_prompt,或换 provider",
)
)
emotion = None
if style and not caps.style_prompt:
degradations.append(
Degradation(
capability="style_prompt",
requested=style,
reason="该 provider 不支持自然语言风格",
suggestion="降级为纯文本",
)
)
style = None
planned = SpeechRequest(
text=text,
voice=request.voice,
speed=request.speed,
tags=tags,
emotion=emotion,
style_prompt=style,
format=request.format,
sample_rate=request.sample_rate,
)
return PlannedSpeech(request=planned, degradations=tuple(degradations))
+57
View File
@@ -0,0 +1,57 @@
"""静音 TTS。离线测试用。
产出格式与时长都正确、但内容为静音的 PCM。
用于验证:
- 流式管道
- barge-in 取消时序
- 时长估算
"""
from __future__ import annotations
import asyncio
from collections.abc import AsyncIterator
from ...contracts import AudioChunk, Capabilities, HealthStatus, Modality, Streaming
from .base import SpeechRequest, TTSProvider
class SilenceTTS(TTSProvider):
name = "silence"
#: 每秒多少字,用于折算时长
CHARS_PER_SECOND = 5.0
CHUNK_MS = 100
def __init__(self, *, delay_ms: int = 0, with_tags: bool = False) -> None:
self._delay = delay_ms / 1000.0
self._with_tags = with_tags
@property
def capabilities(self) -> Capabilities:
tags = frozenset({"laughter", "breath", "sigh", "pause"}) if self._with_tags else frozenset()
return Capabilities(
modality=Modality.AUDIO,
streaming=Streaming.CHUNKED,
speech_tags=tags,
emotion_control=self._with_tags,
)
async def health(self) -> HealthStatus:
return HealthStatus(ok=True, detail="silence")
async def stream(self, request: SpeechRequest) -> AsyncIterator[AudioChunk]:
fmt = request.format
total_ms = max(200.0, len(request.text) / self.CHARS_PER_SECOND * 1000)
step = self.CHUNK_MS
n = int(total_ms / step)
bytes_per_chunk = int(fmt.bytes_per_second * step / 1000)
for i in range(n):
if self._delay:
await asyncio.sleep(self._delay)
yield AudioChunk(
data=b"\x00" * bytes_per_chunk,
format=fmt,
ts=i * step,
)
+6
View File
@@ -0,0 +1,6 @@
"""轮次判定 providers。"""
from .base import TurnDecision, TurnProvider, TurnSignal
from .heuristic import HeuristicTurn
__all__ = ["HeuristicTurn", "TurnDecision", "TurnProvider", "TurnSignal"]
+47
View File
@@ -0,0 +1,47 @@
"""轮次判定 provider 抽象。
**这是"像不像人"的分水岭之一。**
分工:
VAD → 有没有声音(便宜、快、纯能量)
Turn → 说完了没(贵、慢、需要语义)
两段式的意义:语义模型只在「静音已持续但没到兜底阈值」的窗口里调用,
成本压到最小,同时不抢话。
伴侣场景的取舍:
宁可多等 300ms,不要抢一次话。
"""
from __future__ import annotations
from abc import abstractmethod
from dataclasses import dataclass
from enum import Enum
from ..base import Provider
class TurnDecision(str, Enum):
END = "end"
"""判断用户说完了。"""
CONTINUE = "continue"
"""还没说完。"""
@dataclass(frozen=True, slots=True)
class TurnSignal:
decision: TurnDecision
confidence: float
reason: str = ""
class TurnProvider(Provider):
@abstractmethod
async def predict(self, partial_text: str, silence_ms: int) -> TurnSignal:
"""给"到没到可以接话的程度"一个概率判断。
参数刻意只给文本与静音时长,不给音频——
因为调用要便宜,音频特征由 VAD 侧提供。
"""
+67
View File
@@ -0,0 +1,67 @@
"""启发式轮次判定。起步用。
规格要求两段式:VAD 门控 + 语义判定。
这里只实现第二段的**启发式近似**——靠标点、句末语气词、犹豫词。
真正的语义模型(文本+音频预测"说完了没")作为后续实现,
接口已经在这里固定好了,换模型不用改调用方。
"""
from __future__ import annotations
import re
from ...contracts import Capabilities, HealthStatus, Modality
from .base import TurnDecision, TurnProvider, TurnSignal
#: 句末语气词/标点:说完的可能性高
_SENTENCE_END = re.compile(r"[。!?!?.~~]\s*$")
#: 犹豫词结尾:大概率没说完
_HESITATION = re.compile(r"(呃|那个|就是|然后|我|嗯|so|uh|um)\s*[,,]?\s*$")
#: 承接词开头:通常还没开始说正事
_CONTINUATION = re.compile(r"^(但是|不过|而且|所以|因为|然后|比如)")
#: 静音兜底阈值(毫秒)。伴侣场景建议往长调:宁可多等,不要抢话。
SILENCE_HARD_END_MS = 800
SILENCE_MIN_MS = 150
class HeuristicTurn(TurnProvider):
name = "heuristic"
def __init__(
self,
*,
hard_end_ms: int = SILENCE_HARD_END_MS,
min_ms: int = SILENCE_MIN_MS,
) -> None:
self.hard_end_ms = hard_end_ms
self.min_ms = min_ms
@property
def capabilities(self) -> Capabilities:
return Capabilities(modality=Modality.TEXT)
async def health(self) -> HealthStatus:
return HealthStatus(ok=True, detail="heuristic-turn")
async def predict(self, partial_text: str, silence_ms: int) -> TurnSignal:
# 第二段只在「静音已持续但没到兜底」的窗口里被调用
if silence_ms >= self.hard_end_ms:
return TurnSignal(TurnDecision.END, 1.0, "兜底阈值")
if silence_ms < self.min_ms:
return TurnSignal(TurnDecision.CONTINUE, 0.9, "静音太短")
text = partial_text.strip()
if not text:
return TurnSignal(TurnDecision.CONTINUE, 0.5, "无文本")
if _HESITATION.search(text):
return TurnSignal(TurnDecision.CONTINUE, 0.85, "犹豫词结尾")
if _CONTINUATION.match(text):
return TurnSignal(TurnDecision.CONTINUE, 0.7, "承接词开头")
if _SENTENCE_END.search(text):
return TurnSignal(TurnDecision.END, 0.8, "句末标点")
return TurnSignal(TurnDecision.CONTINUE, 0.55, "无明确信号,倾向等待")
+6
View File
@@ -0,0 +1,6 @@
"""VAD providers。"""
from .base import VADProvider
from .energy import EnergyVAD
__all__ = ["EnergyVAD", "VADProvider"]
+39
View File
@@ -0,0 +1,39 @@
"""VAD provider 抽象。
VAD 没有任何事实标准,所以内部契约是自定义的。
注意一个真实坑:Silero VAD v6 **只接受 16kHz**,8k 支持被砍了。
所以这里用 VAD_RATE 常量强制统一,重采样在进 VAD 之前做。
"""
from __future__ import annotations
from abc import abstractmethod
from collections.abc import AsyncIterator
from ...contracts import VAD_RATE, AudioChunk, AudioFrameStats
from ..base import Provider
class VADProvider(Provider):
"""语音活动检测。
只回答一个问题:**这一帧里有没有人声**。
它不回答"说完了没"——那是 turn provider 的事。
"""
sample_rate: int = VAD_RATE
@abstractmethod
async def process(self, frame: AudioChunk) -> AudioFrameStats:
"""处理一帧(通常 30ms)。"""
@abstractmethod
async def stream(
self, audio: AsyncIterator[AudioChunk]
) -> AsyncIterator[AudioFrameStats]:
"""整条流的帧级结果。"""
@abstractmethod
async def reset(self) -> None:
"""清空内部状态(新的说话段开始时调用)。"""
+49
View File
@@ -0,0 +1,49 @@
"""能量 VAD。离线与兜底用。
**不是生产方案。** 生产要用 Silero v6 + 语义端点。
它的价值是把"VAD 接口长什么样"固定下来,让上层可以先跑通。
同时它是一个反面教材:纯能量判定会抢话。
规格里明确要求:能量 VAD 只做门控,不做端点判定。
"""
from __future__ import annotations
import math
import struct
from ...contracts import AudioChunk, AudioFrameStats, Capabilities, HealthStatus, Modality
from .base import VADProvider
class EnergyVAD(VADProvider):
name = "energy"
def __init__(self, *, rms_threshold: float = 0.012) -> None:
self._thr = rms_threshold
@property
def capabilities(self) -> Capabilities:
return Capabilities(modality=Modality.AUDIO)
async def health(self) -> HealthStatus:
return HealthStatus(ok=True, detail="energy-vad (fallback,非生产用)")
async def process(self, frame: AudioChunk) -> AudioFrameStats:
pcm = frame.data
n = len(pcm) // 2
if n == 0:
return AudioFrameStats(ts=frame.ts, rms=0.0, peak=0.0)
samples = struct.unpack(f"<{n}h", pcm[: n * 2])
peak = max(abs(s) for s in samples) / 32768.0
rms = math.sqrt(sum(s * s for s in samples) / n) / 32768.0
return AudioFrameStats(
ts=frame.ts, rms=rms, peak=peak, is_speech=rms > self._thr
)
async def stream(self, audio): # type: ignore[no-untyped-def]
async for frame in audio:
yield await self.process(frame)
async def reset(self) -> None:
return None
+31
View File
@@ -0,0 +1,31 @@
"""共享 fixture。"""
from __future__ import annotations
from pathlib import Path
import pytest
from cybergril.config import CharacterConfig, ModelsConfig, Settings
from cybergril.memory import Store
@pytest.fixture
def repo_root() -> Path:
return Path(__file__).resolve().parents[1]
@pytest.fixture
def settings(repo_root: Path) -> Settings:
return Settings(
models=ModelsConfig(),
character=CharacterConfig(identity={"y": 2000, "m": 1, "d": 1, "h": 12}),
db_path=repo_root / "test.db",
)
@pytest.fixture
def store(tmp_path: Path) -> Store:
s = Store(tmp_path / "test.db")
yield s
s.close()
+94
View File
@@ -0,0 +1,94 @@
"""八字编译测试。
守的是:**命是数值,不是散文。**
编译结果必须稳定(同一个四柱永远得到同一个向量),
而且强弱必须真的影响敏感度。
"""
from __future__ import annotations
import pytest
from cybergril.core.bazi import (
ELEMENTS,
Pillars,
compile_pillars,
day_master_strength,
element_scores,
ten_god,
ten_god_distribution,
)
PILLARS = Pillars(year="甲子", month="丙寅", day="戊午", time="壬子")
def test_pillars_day_master() -> None:
assert PILLARS.day_master == "戊"
def test_element_scores_sum_positive() -> None:
scores = element_scores(PILLARS)
assert set(scores) == set(ELEMENTS)
assert sum(scores.values()) > 0
assert all(v >= 0 for v in scores.values())
def test_element_scores_rejects_bad_format() -> None:
with pytest.raises(ValueError, match="格式错误"):
element_scores(Pillars(year="甲", month="丙寅", day="戊午", time="壬子"))
def test_day_master_strength_in_range() -> None:
scores = element_scores(PILLARS)
s = day_master_strength(PILLARS, scores)
assert 0.0 <= s <= 1.0
def test_ten_god_known_relations() -> None:
# 木克土(克我),同阳 → 七杀
assert ten_god("戊", "甲") == "七杀"
# 火生土(生我),同阳 → 偏印
assert ten_god("戊", "丙") == "偏印"
# 土克水(我克),同阳 → 偏财
assert ten_god("戊", "壬") == "偏财"
# 土同土,同阳 → 比肩
assert ten_god("戊", "戊") == "比肩"
def test_ten_god_distribution_normalized() -> None:
dist = ten_god_distribution(PILLARS)
assert dist
assert abs(sum(dist.values()) - 1.0) < 1e-6
assert all(0 <= v <= 1 for v in dist.values())
def test_compile_is_deterministic() -> None:
a = compile_pillars(PILLARS)
b = compile_pillars(PILLARS)
assert a.as_dict() == b.as_dict()
def test_compile_produces_bounded_params() -> None:
d = compile_pillars(PILLARS)
for key in (
"autonomy", "compliance", "expressiveness", "warmth",
"anxiety", "aggression", "discipline", "correcting",
"solitude_need", "impulsivity", "sharing", "realism",
):
val = getattr(d, key)
assert 0.0 <= val <= 1.0, f"{key} 越界: {val}"
def test_strength_drives_sensitivity() -> None:
"""身弱必须更敏感、恢复更慢。这是强弱进入动力学的唯一入口。"""
weak = compile_pillars(Pillars(year="甲子", month="乙丑", day="丙子", time="丁亥"))
# 用编译结果自身的 strength 反推关系
for d in (weak, compile_pillars(PILLARS)):
expected_sens = 1.35 - 0.70 * d.strength
assert abs(d.sensitivity - expected_sens) < 1e-9
expected_rec = 1.60 - 1.20 * d.strength
assert abs(d.recovery_scale - expected_rec) < 1e-9
def test_traits_text_non_empty() -> None:
assert compile_pillars(PILLARS).traits_text()
+117
View File
@@ -0,0 +1,117 @@
"""契约层测试。
这些测试守的是"接口形状",不是实现细节。
接口一变,它们就该红——因为所有层都依赖这些形状。
"""
from __future__ import annotations
from cybergril.contracts import (
Capabilities,
Degradation,
Expression,
Modality,
ParaEmotion,
ParaEvent,
Paralinguistics,
Percept,
SpeechTag,
State,
Streaming,
Utterance,
)
def test_state_clamp_bounds() -> None:
s = State(v=5.0, a=3.0, d=-1.0, e=2.0)
s.clamp()
assert -1.0 <= s.v <= 1.0
assert 0.0 <= s.a <= 1.0
assert 0.0 <= s.d <= 1.0
assert 0.0 <= s.e <= 1.0
def test_state_energy_band() -> None:
assert State(e=0.1).energy_band() == "exhausted"
assert State(e=0.3).energy_band() == "tired"
assert State(e=0.5).energy_band() == "normal"
assert State(e=0.9).energy_band() == "energized"
def test_utterance_strip_tags_removes_inline() -> None:
u = Utterance(
text="真的吗[laughter]……我、我先想想[breath]",
speech_tags=(SpeechTag.LAUGHTER, SpeechTag.BREATH),
)
stripped = u.strip_tags()
assert "[laughter]" not in stripped.text
assert "[breath]" not in stripped.text
assert stripped.speech_tags == ()
# 原文不变(frozen)
assert "[laughter]" in u.text
def test_utterance_tag_density() -> None:
u = Utterance(text="好" * 100, speech_tags=(SpeechTag.LAUGHTER,))
assert abs(u.tag_density() - 0.01) < 1e-9
def test_capabilities_tag_support() -> None:
caps = Capabilities(
modality=Modality.AUDIO,
streaming=Streaming.CHUNKED,
speech_tags=frozenset({"laughter", "breath"}),
)
assert caps.tags_supported({"laughter"})
assert not caps.tags_supported({"laughter", "sigh"})
def test_capabilities_can() -> None:
caps = Capabilities(modality=Modality.TEXT, emotion_control=True)
assert caps.can("emotion_control")
assert not caps.can("voice_clone")
def test_degradation_readable() -> None:
d = Degradation(
capability="speech_tags",
requested=("sigh",),
reason="provider 不支持",
suggestion="换 provider",
)
text = str(d)
assert "degraded" in text
assert "speech_tags" in text
assert "换 provider" in text
def test_paralinguistics_distress() -> None:
calm = Paralinguistics(emotion=ParaEmotion.HAPPY)
assert not calm.is_distressed()
crying = Paralinguistics(
emotion=ParaEmotion.SAD, events=frozenset({ParaEvent.CRY})
)
assert crying.is_distressed()
assert crying.is_negative()
def test_percept_factories() -> None:
p = Percept.asr_final(ts=1000, text="你好", confidence=0.9)
assert p.kind.value == "asr_final"
assert p.text() == "你好"
assert p.confidence == 0.9
sys_p = Percept.system(ts=0, event="net.error")
assert sys_p.system_event() == "net.error"
def test_expression_from_state() -> None:
e = Expression.from_state(v=0.8, a=0.9, d=0.2, e=0.1)
assert e.happy > 0
assert e.tired > 0.8
assert e.surprise > 0
e2 = Expression.from_state(v=0.0, a=0.3, d=0.8, e=0.9)
assert e2.happy == 0.0
assert e2.sad == 0.0
+133
View File
@@ -0,0 +1,133 @@
"""情绪动力学测试。
守两件事:
1. 情绪是慢变量(有惯性),不是每轮重置
2. 负向偏置真实存在(难过比开心久)
"""
from __future__ import annotations
import math
from cybergril.contracts import State
from cybergril.core.mood import (
Baseline,
Impulse,
MoodParams,
NumbingTracker,
decay,
make_impulse,
mood_word,
step,
)
P = MoodParams.default()
B = Baseline()
def test_decay_moves_toward_baseline() -> None:
s = State(ts=0, v=-0.8, a=0.9, d=0.9, e=0.1)
out = decay(s, B, dt_seconds=3600, params=P)
# 一小时后应该更接近基线
assert out.v > s.v
assert out.a < s.a
assert out.e > s.e
def test_negative_bias_slower_recovery() -> None:
"""负向恢复必须慢于正向。这是"记仇"的技术来源。
检验方式:比较"剩余距离占初始距离的比例",再解出时间常数之比。
纯正向时 tau_v = 3h;负向时 tau_v ×2 = 6h。
"""
start_v = 0.5
pos = State(ts=0, v=start_v)
neg = State(ts=0, v=-start_v)
gap0_pos = abs(start_v - B.v)
gap0_neg = abs(-start_v - B.v)
after_pos = decay(pos, B, 3600, P)
after_neg = decay(neg, B, 3600, P)
pos_gap = abs(after_pos.v - B.v)
neg_gap = abs(after_neg.v - B.v)
# 同样的时间,负向剩得更多
assert neg_gap > pos_gap
# 剩余比例:exp(-dt/tau)。两比例取对数后相除 = tau_neg / tau_pos
pos_frac = pos_gap / gap0_pos
neg_frac = neg_gap / gap0_neg
assert neg_frac > pos_frac
tau_ratio = math.log(pos_frac) / math.log(neg_frac)
# 配置里 neg_bias["v"] = 2.0
assert 1.95 <= tau_ratio <= 2.05
def test_decay_is_monotonic_toward_baseline() -> None:
s = State(ts=0, v=-0.9)
prev = s.v
for _ in range(10):
s = decay(s, B, 1800, P)
assert s.v > prev
prev = s.v
def test_impulse_scales_with_sensitivity() -> None:
strong = make_impulse(valence=-0.8, salience=0.9, sensitivity=0.65)
weak = make_impulse(valence=-0.8, salience=0.9, sensitivity=1.35)
assert abs(weak.v) > abs(strong.v)
def test_impulse_direction() -> None:
bad = make_impulse(valence=-0.7, salience=0.8, sensitivity=1.0)
good = make_impulse(valence=0.7, salience=0.8, sensitivity=1.0)
assert bad.v < 0 < good.v
# 好事也会消耗精力
assert bad.e <= 0 and good.e <= 0
def test_step_clamps_and_advances_time() -> None:
s = State(ts=1000, v=0.0)
huge = Impulse(v=99.0, a=99.0)
out = step(s, B, dt_seconds=60, params=P, impulses=(huge,))
assert -1.0 <= out.v <= 1.0
assert 0.0 <= out.a <= 1.0
assert out.ts == 1000 + 60_000
def test_step_no_impulse_returns_valid_state() -> None:
s = State(ts=0, v=0.2, a=0.4, d=0.5, e=0.6)
out = step(s, B, 60, P)
assert isinstance(out, State)
out.clamp()
def test_mood_word_bands() -> None:
assert mood_word(v=0.6, a=0.7, e=0.8) == "雀跃"
assert mood_word(v=-0.8, a=0.4, e=0.6) == "低落"
assert mood_word(v=0.0, a=0.3, e=0.5) == "平静"
assert mood_word(v=0.0, a=0.3, e=0.1) == "疲惫"
def test_numbing_kicks_in_after_threshold() -> None:
tracker = NumbingTracker(after_hours=6.0, drop=0.15)
s = State(ts=0, v=-0.5)
# 刚开始:无影响
assert tracker.update(s) == 0.0
# 推进 8 小时
s2 = State(ts=8 * 3600 * 1000, v=-0.5)
offset = tracker.update(s2)
assert offset < 0
def test_numbing_resets_on_positive() -> None:
tracker = NumbingTracker(after_hours=1.0, drop=0.15)
tracker.update(State(ts=0, v=-0.5))
tracker.update(State(ts=3 * 3600 * 1000, v=-0.5)) # 已经麻木
tracker.update(State(ts=4 * 3600 * 1000, v=0.5)) # 转正
assert tracker.update(State(ts=5 * 3600 * 1000, v=0.5)) == 0.0
+67
View File
@@ -0,0 +1,67 @@
"""投影层测试。
守的是本体 C-膜 的落地机制:
未覆盖的事件必须落到 fallback,并**计数**。
projection_gap 的行数就是"现实感泄漏"的度量。
"""
from __future__ import annotations
from cybergril.core.projection import (
FALLBACK,
Projector,
violates_absence_rule,
)
def test_known_event_projects() -> None:
p = Projector()
assert p("process.wake") == "刚醒。"
assert p.gap_count == 0
def test_unknown_event_falls_back_and_counts() -> None:
gaps = []
p = Projector(on_gap=gaps.append)
out = p("totally.unknown.event", {"x": 1})
assert out == FALLBACK
assert p.gap_count == 1
assert gaps[0].event_key == "totally.unknown.event"
def test_register_fills_gap() -> None:
p = Projector()
p("new.event")
assert p.gap_count == 1
p.register("new.event", "这个我说得上来了。")
assert p("new.event") == "这个我说得上来了。"
# 计数不因补表而回退
assert p.gap_count == 1
def test_custom_table_overrides() -> None:
p = Projector({"process.wake": "醒了。"})
assert p("process.wake") == "醒了。"
def test_no_secrets_leak_into_projection() -> None:
"""投影只输出世界内陈述,不输出技术细节。"""
p = Projector()
forbidden = ["进程", "服务", "错误码", "API", "超时", "token"]
for key in p.known_keys():
text = p(key)
for f in forbidden:
assert f not in text, f"{key} 的投影泄漏了技术词 {f!r}"
def test_absence_rule_detects_forbidden_patterns() -> None:
assert violates_absence_rule("我一直在等你,等了好久")
assert violates_absence_rule("你会把我删掉吗")
assert not violates_absence_rule("这几天我这边发生了点事")
def test_all_default_projections_are_nonempty() -> None:
p = Projector()
for key in p.known_keys():
assert p(key).strip(), f"{key} 的投影为空"
+160
View File
@@ -0,0 +1,160 @@
"""Provider 层测试。
核心守的是这条设计判断:
OpenAI 兼容是 adapter,不是标准。
适配器用不了某项能力时,必须**显式降级**,不能静默丢弃。
如果哪天有人把 TTS 的 plan() 改成"静默删掉不支持的标签",
test_plan_reports_missing_paralinguistics 会红。
"""
from __future__ import annotations
import pytest
from cybergril.contracts import AudioChunk, LLMMessage
from cybergril.providers import (
EchoLLM,
HeuristicTurn,
Provider,
ProviderError,
Registry,
ScriptedASR,
SilenceTTS,
SpeechRequest,
TurnDecision,
bootstrap_registry,
registry,
)
from cybergril.providers.asr import ASREventType
# ---------------------------------------------------------------- 注册表
def test_registry_unknown_provider_raises() -> None:
reg = Registry()
with pytest.raises(ProviderError, match="未知的 llm provider"):
reg.create("llm", "nope")
def test_bootstrap_registers_builtins() -> None:
bootstrap_registry()
assert "echo" in registry.available("llm")
assert "scripted" in registry.available("asr")
assert "silence" in registry.available("tts")
# ---------------------------------------------------------------- TTS 规划
def test_plan_keeps_tags_when_supported() -> None:
tts = SilenceTTS(with_tags=True)
req = SpeechRequest(text="真的吗", tags=("laughter", "breath"), emotion="happy")
planned = tts.plan(req)
assert planned.request.tags == ("laughter", "breath")
assert planned.request.emotion == "happy"
assert planned.degradations == ()
assert not planned.lost_paralinguistics
def test_plan_reports_missing_paralinguistics() -> None:
"""关键测试:不支持副语言的 provider 必须上报,不能装没事。"""
tts = SilenceTTS(with_tags=False)
req = SpeechRequest(text="真的吗", tags=("laughter", "breath"), emotion="sad")
planned = tts.plan(req)
# 标签被剥掉
assert planned.request.tags == ()
# 但**必须**有降级记录
assert planned.lost_paralinguistics
caps = {d.capability for d in planned.degradations}
assert "speech_tags" in caps
assert "emotion_control" in caps
def test_plan_partial_tag_support() -> None:
"""只支持一部分标签时,只剥掉不支持的,并上报。"""
tts = SilenceTTS(with_tags=True) # 支持 laughter/breath/sigh/pause
req = SpeechRequest(text="唉", tags=("laughter", "cry")) # cry 不支持
planned = tts.plan(req)
assert planned.request.tags == ("laughter",)
assert any(d.capability == "speech_tags" for d in planned.degradations)
# ---------------------------------------------------------------- LLM
async def test_echo_llm_streams_and_closes() -> None:
llm = EchoLLM(prefix="echo: ")
parts: list[str] = []
done = False
async for d in llm.stream([LLMMessage(role="user", content="你好")]):
if d.done:
done = True
parts.append(d.text)
assert "".join(parts) == "echo: 你好"
assert done
async def test_echo_llm_complete() -> None:
llm = EchoLLM()
resp = await llm.complete([LLMMessage(role="user", content="abc")])
assert resp.text == "abc"
assert resp.finish_reason == "stop"
# ---------------------------------------------------------------- ASR
async def test_scripted_asr_emits_partial_then_final() -> None:
asr = ScriptedASR(["你好呀。"])
events = []
async def _audio():
yield AudioChunk(data=b"\x00" * 320, ts=0)
async for ev in asr.stream(_audio()):
events.append(ev)
assert events[-1].type is ASREventType.FINAL
assert events[-1].text == "你好呀。"
assert any(e.type is ASREventType.PARTIAL for e in events)
async def test_asr_capabilities_claim_partial() -> None:
asr = ScriptedASR()
assert asr.capabilities.supports_partial is True
# ---------------------------------------------------------------- Turn
async def test_turn_waits_on_hesitation() -> None:
turn = HeuristicTurn(min_ms=100, hard_end_ms=800)
sig = await turn.predict("我想想……那个", silence_ms=300)
assert sig.decision is TurnDecision.CONTINUE
sig2 = await turn.predict("我想想……那个", silence_ms=900)
assert sig2.decision is TurnDecision.END # 兜底阈值
async def test_turn_ends_on_sentence_punctuation() -> None:
turn = HeuristicTurn(min_ms=100, hard_end_ms=800)
sig = await turn.predict("今天天气不错。", silence_ms=300)
assert sig.decision is TurnDecision.END
async def test_turn_waits_when_too_short_silence() -> None:
turn = HeuristicTurn(min_ms=150, hard_end_ms=800)
sig = await turn.predict("说完了。", silence_ms=50)
assert sig.decision is TurnDecision.CONTINUE
# ---------------------------------------------------------------- 基类
def test_provider_repr_contains_caps() -> None:
p = EchoLLM()
assert isinstance(p, Provider)
assert "native" in repr(p)
+110
View File
@@ -0,0 +1,110 @@
"""存储层测试。
守的是遗忘的**下限**:accessibility 会衰减,但永不归零。
这是 availability / accessibility 双阈值的实现——
想不起来,但还在,情绪线索能唤起。
"""
from __future__ import annotations
from cybergril.memory import Store
def test_schema_initialises(store: Store) -> None:
rows = store.query(
"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
)
names = {r["name"] for r in rows}
for expected in (
"bazi_core",
"identity_change_log",
"state_snapshot",
"life_event",
"mem_episodic",
"mem_semantic",
"mem_relational",
"dyad_state",
"dyad_event",
"projection_gap",
):
assert expected in names, f"缺表 {expected}"
def test_save_and_read_state(store: Store) -> None:
store.save_state(v=-0.4, a=0.6, d=0.3, e=0.5, mood_word="闷闷的")
row = store.last_state()
assert row is not None
assert abs(row["valence"] - (-0.4)) < 1e-9
assert row["mood_word"] == "闷闷的"
def test_add_episodic_and_fts(store: Store) -> None:
eid = store.add_episodic(
role="user",
raw="我今天很难过",
salience=0.85,
affect={"v": -0.7, "a": 0.6, "d": 0.3},
)
assert eid > 0
row = store.query_one("SELECT * FROM mem_episodic WHERE id=?", (eid,))
assert row is not None
assert row["salience"] == 0.85
assert row["accessibility"] == 1.0
def test_decay_never_reaches_zero(store: Store) -> None:
"""最关键的一条:遗忘有下限。"""
store.add_episodic(role="user", raw="一件小事", salience=0.1)
store.decay_accessibility(dt_hours=24 * 365, floor=0.03)
row = store.query_one("SELECT accessibility FROM mem_episodic LIMIT 1")
assert row is not None
assert row["accessibility"] >= 0.03
assert row["accessibility"] < 0.1 # 确实衰减了
def test_high_salience_decays_slower(store: Store) -> None:
store.add_episodic(role="user", raw="重要的事", salience=1.0)
store.add_episodic(role="user", raw="不重要的事", salience=0.0)
store.decay_accessibility(dt_hours=48, floor=0.03)
rows = store.query(
"SELECT salience, accessibility FROM mem_episodic ORDER BY salience DESC"
)
high, low = rows[0], rows[1]
assert high["accessibility"] > low["accessibility"]
def test_reinforce_raises_accessibility(store: Store) -> None:
eid = store.add_episodic(role="user", raw="会被想起的事", salience=0.5)
store.decay_accessibility(dt_hours=100, floor=0.03)
before = store.query_one(
"SELECT accessibility, recall_count FROM mem_episodic WHERE id=?", (eid,)
)
store.reinforce(eid, boost=0.25)
after = store.query_one(
"SELECT accessibility, recall_count FROM mem_episodic WHERE id=?", (eid,)
)
assert after["accessibility"] > before["accessibility"]
assert after["recall_count"] == before["recall_count"] + 1
def test_dyad_persistence(store: Store) -> None:
eid = store.add_dyad_event(kind="hurt", summary="他忘了约定", severity=-0.6)
store.save_dyad(trust=0.4, intimacy=0.5, debt=-0.6, security=0.4, unresolved=[eid])
row = store.last_dyad()
assert row is not None
assert abs(row["debt"] - (-0.6)) < 1e-9
ev = store.query_one("SELECT * FROM dyad_event WHERE id=?", (eid,))
assert ev is not None
assert ev["severity"] < 0
def test_projection_gap_recorded(store: Store) -> None:
store.record_projection_gap("mystery.event", {"detail": "x"})
assert store.projection_gap_count() == 1