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:
@@ -0,0 +1,11 @@
|
||||
# 复制成 .env 后填写。不填也能跑(会回退到离线 provider)。
|
||||
|
||||
# ---- LLM ----
|
||||
DEEPSEEK_API_KEY=
|
||||
|
||||
# ---- Embedding(可选,留空则用本地或跳过向量检索)----
|
||||
EMBEDDING_API_KEY=
|
||||
|
||||
# ---- 运行时 ----
|
||||
CYBERGRIL_DB=companion.db
|
||||
CYBERGRIL_LOG_LEVEL=INFO
|
||||
@@ -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
@@ -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
|
||||
@@ -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`。
|
||||
|
||||
## 许可
|
||||
|
||||
私有项目,未授权。
|
||||
@@ -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。
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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"),
|
||||
)
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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", ""))
|
||||
@@ -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})
|
||||
@@ -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}。"
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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()),
|
||||
)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -0,0 +1,5 @@
|
||||
"""关系账本。"""
|
||||
|
||||
from .ledger import Dyad, DyadEvent
|
||||
|
||||
__all__ = ["Dyad", "DyadEvent"]
|
||||
@@ -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))
|
||||
@@ -0,0 +1,13 @@
|
||||
"""记忆层。
|
||||
|
||||
三层结构:
|
||||
episodic 发生过什么(会衰减、会压缩)
|
||||
semantic 关于用户/世界的事实(双时态)
|
||||
relational 你们的仪式、暗语、相处方式(第三层,最容易被漏掉)
|
||||
|
||||
第三层才是亲密感的来源,而它不在任何现成框架里。
|
||||
"""
|
||||
|
||||
from .store import Store
|
||||
|
||||
__all__ = ["Store"]
|
||||
@@ -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]
|
||||
-- );
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,9 @@
|
||||
"""ASR providers。
|
||||
|
||||
内部契约是事件流(partial → final),不是 OpenAI 的「文件进 → 文本出」。
|
||||
"""
|
||||
|
||||
from .base import ASREvent, ASREventType, ASRProvider
|
||||
from .scripted import ScriptedASR
|
||||
|
||||
__all__ = ["ASREvent", "ASREventType", "ASRProvider", "ScriptedASR"]
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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]
|
||||
"""断开与渲染端的连接。"""
|
||||
@@ -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"]
|
||||
@@ -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()
|
||||
@@ -0,0 +1,6 @@
|
||||
"""LLM providers。"""
|
||||
|
||||
from .base import LLMProvider
|
||||
from .openai_compat import OpenAICompatLLM
|
||||
|
||||
__all__ = ["LLMProvider", "OpenAICompatLLM"]
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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"]
|
||||
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -0,0 +1,6 @@
|
||||
"""轮次判定 providers。"""
|
||||
|
||||
from .base import TurnDecision, TurnProvider, TurnSignal
|
||||
from .heuristic import HeuristicTurn
|
||||
|
||||
__all__ = ["HeuristicTurn", "TurnDecision", "TurnProvider", "TurnSignal"]
|
||||
@@ -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 侧提供。
|
||||
"""
|
||||
@@ -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, "无明确信号,倾向等待")
|
||||
@@ -0,0 +1,6 @@
|
||||
"""VAD providers。"""
|
||||
|
||||
from .base import VADProvider
|
||||
from .energy import EnergyVAD
|
||||
|
||||
__all__ = ["EnergyVAD", "VADProvider"]
|
||||
@@ -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:
|
||||
"""清空内部状态(新的说话段开始时调用)。"""
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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} 的投影为空"
|
||||
@@ -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)
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user