Files
ai-companion/app/config.py
T
ylhan 20cfaa6ef7 基线:姓名档案修复、疑问句防误写、搜索与传输测试
这是进入 Pipecat Flows 改造前的回滚点。
提交时已核对本地与线上代码 md5 一致(engine.py 7e97bad8、
bot_server.py 1cf543ae),所以这个提交就是当前线上状态。

内容(此前一直在线上运行但未提交的改动):
- 姓名档案:用户自报"我叫林"同步写 confirmed 姓名,重连后恢复
- 疑问句防误写:"谁吗"之类的疑问句不再被覆盖成姓名
- 新增 test_profile_memory.py(姓名档案专项回归)
- 新增 test_profile_live.py(需要真实 LLM 的现场测试)
- 搜索、身份、记忆测试的相应补充

背景:下一步要按 P1-Pipecat Flows Agent化与记忆改造交接.md
渐进引入 Pipecat Flows,交接文档明确要求先保护基线、不得
reset/checkout/覆盖现有改动。此提交即该基线。
2026-09-16 12:48:18 +08:00

245 lines
9.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""配置加载。
原则(来自方案 §10):
- 密钥保存在本机配置,不写日志,不进版本管理
- 设备标识用稳定名字,不用会变的序号
- 依赖版本固定,板卡/固件/通道/引脚都记录
密钥文件是 ~/.config/ai_assistant/env(权限 600),格式是 shell 变量。
本模块只读它,不写。
"""
from __future__ import annotations
import os
import threading
from dataclasses import dataclass, field
from pathlib import Path
ENV_FILE = Path.home() / ".config" / "ai_assistant" / "env"
# 本项目根目录(app/ 的上一级)
PROJECT_ROOT = Path(__file__).resolve().parent.parent
_lock = threading.Lock()
_loaded = False
def load_env(path: Path | None = None) -> dict[str, str]:
"""读取密钥文件到 os.environ(已存在的变量不覆盖)。
只做极简解析:KEY=VALUE,忽略空行和 # 注释。
不用 os.system/source,避免把 shell 引进来。
"""
global _loaded
p = path or ENV_FILE
with _lock:
if _loaded:
return dict(os.environ)
if p.is_file():
for line in p.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, _, v = line.partition("=")
k, v = k.strip(), v.strip().strip('"').strip("'")
os.environ.setdefault(k, v)
_loaded = True
return dict(os.environ)
def _env(key: str, default: str = "") -> str:
load_env()
return os.environ.get(key, default)
def _env_int(key: str, default: int) -> int:
try:
return int(_env(key, str(default)))
except ValueError:
return default
def _env_float(key: str, default: float) -> float:
try:
return float(_env(key, str(default)))
except ValueError:
return default
def _env_bool(key: str, default: bool) -> bool:
value = _env(key, "true" if default else "false").strip().lower()
return value in {"1", "true", "yes", "on"}
# ---------------------------------------------------------------- LLM
@dataclass
class LLMConfig:
"""一个 LLM 后端的配置。
thinking 的处理方式各后端不同,所以由后端自己解释这个开关:
- 本机 vLLM: chat_template_kwargs={"enable_thinking": false}
- DeepSeek: thinking={"type": "disabled"}
两者都实测有效,见 P0-本地LLM验证报告.md 与 P0-DeepSeek验证报告.md。
"""
name: str
base_url: str
api_key: str
model: str
thinking: bool = False # 语音场景默认关思考
max_tokens: int = 200 # 语音回复要短,方案要求"默认简短自然回复"
timeout_s: float = 30.0
first_token_timeout_s: float = 8.0
@property
def enabled(self) -> bool:
return bool(self.base_url and self.api_key and self.model)
def local_llm_config() -> LLMConfig:
"""本机/内网 LLM(vLLM 等)。
默认走回环:服务与助手同机时 127.0.0.1 最省一跳。
部署到别的机器时必须用 LOCAL_LLM_BASE_URL 显式覆盖——
**不要把具体的内网地址写进默认值**,那会把网络拓扑带进版本历史,
而且换环境就得改代码。
"""
return LLMConfig(
name="local",
base_url=_env("LOCAL_LLM_BASE_URL", "http://127.0.0.1:18020/v1"),
api_key=_env("LOCAL_LLM_API_KEY"),
model=_env("LOCAL_LLM_MODEL", "qwen3.8-27b"),
)
def deepseek_config() -> LLMConfig:
return LLMConfig(
name="deepseek",
base_url=_env("DEEPSEEK_BASE_URL", "https://api.deepseek.com/v1"),
api_key=_env("DEEPSEEK_API_KEY"),
model=_env("DEEPSEEK_MODEL", "deepseek-flash"),
)
# ---------------------------------------------------------------- 上下文
@dataclass
class ContextConfig:
"""上下文预算。
实测:真实语音 prompt(人设+记忆+3轮对话)约 406 tokens,
本机 LLM 首 token 367ms。上下文越长,首 token 越慢——直接伤"回应及时"。
所以这里设的是硬上限,超出就丢最旧的轮次,不是软建议。
"""
max_prompt_tokens: int = 4096 # 总上限
max_turns: int = 12 # 最多保留的对话轮次
max_memories: int = 5 # 单轮最多注入的记忆条数
memory_timeout_s: float = 0.3 # 检索超时,方案要求"记忆失败不阻塞对话"
# ---------------------------------------------------------------- 联网搜索
@dataclass
class SearchConfig:
"""SearXNG 搜索后端。
默认关闭,避免开发机没有 SearXNG 时改变普通对话行为;部署机显式设置
SEARCH_ENABLED=true。服务只需暴露在助手所在机器的回环地址。
"""
enabled: bool = field(default_factory=lambda: _env_bool("SEARCH_ENABLED", False))
base_url: str = field(
default_factory=lambda: _env("SEARXNG_BASE_URL", "http://127.0.0.1:18080"))
timeout_s: float = field(default_factory=lambda: _env_float("SEARCH_TIMEOUT_S", 6.0))
max_results: int = field(default_factory=lambda: _env_int("SEARCH_MAX_RESULTS", 5))
language: str = field(default_factory=lambda: _env("SEARCH_LANGUAGE", "zh-CN"))
# ---------------------------------------------------------------- 语音
def _default_model_dir() -> Path:
"""模型目录。可用环境变量 AI_MODEL_DIR 覆盖。
为什么要有这个覆盖:开发在 N5095,运行在服务器,两边模型放的
位置不一样(服务器上模型在 ~/ai_companion_bench/models,
不在代码目录里)。没有覆盖的话,部署就得改代码——
而"改代码才能部署"是部署流程里最容易出错的一环。
"""
override = _env("AI_MODEL_DIR")
if override:
return Path(override).expanduser()
return PROJECT_ROOT / "p0" / "stt_tts_bench" / "models"
@dataclass
class VoiceConfig:
"""语音输入输出。
线程数是实测出来的,不能按"核多给多"猜:
- N5095(4核,无AVX2):STT 超过 2 线程急剧退化,4线程慢 3.9 倍
- 服务器(i9-14900K,有AVX2):同样配置下 RTF 0.03
所以线程数按部署机器配置,不写死。服务器上可以给足。
"""
model_dir: Path = field(default_factory=_default_model_dir)
sample_rate: int = 16000
stt_threads: int = field(default_factory=lambda: _env_int("AI_STT_THREADS", 2))
tts_threads: int = field(default_factory=lambda: _env_int("AI_TTS_THREADS", 4))
# ---- TTS 后端选择 ----
# "local" = sherpa-onnx vits-zh-ll:RTF 0.09,很快,但音色用户不满意
# "remote" = Qwen3-TTS 独立服务:RTF 0.55–0.94,音质是另一档,用户试听选定
tts_backend: str = field(default_factory=lambda: _env("AI_TTS_BACKEND", "remote"))
tts_service_url: str = field(
default_factory=lambda: _env("TTS_SERVICE_URL", "http://127.0.0.1:18040"))
# 用户试听选定的组合(out/tts_qwen/instruct_7849.wav)
tts_speaker: str = field(default_factory=lambda: _env("AI_TTS_SPEAKER", "Serena"))
tts_instructions: str = field(
default_factory=lambda: _env("AI_TTS_INSTRUCTIONS", "用轻快活泼的语气说"))
tts_language: str = field(default_factory=lambda: _env("AI_TTS_LANGUAGE", "Chinese"))
# ---------------------------------------------------------------- 记忆
@dataclass
class MemoryConfig:
db_path: Path = field(default_factory=lambda: Path.home() / ".local" / "share" / "ai_assistant" / "memory.db")
# 自动抽取的记忆默认是"候选",不参与检索;只有用户显式确认的才生效。
# 对应方案 §8:"模型推测不当作确认事实"
auto_extract_enabled: bool = True
auto_extract_confirm_status: str = "candidate"
# ---------------------------------------------------------------- 汇总
@dataclass
class AppConfig:
# 单用户原型的稳定内部档案 ID。它只用于数据隔离,绝不是用户可见姓名。
default_user_id: str = field(
default_factory=lambda: _env("AI_DEFAULT_USER_ID", "u_ylhan"))
#: 主 LLM 由 AI_LLM_PRIMARY 选择:
#: "local" = 本机 vLLM(首 token 192ms,但占 21.5GB 显存)
#: "deepseek" = 云端(首 token 786ms,网络往返约 700ms)
#: 2026-09-15 起用 deepseek:本地 27B 已停,GPU 让给 Qwen3-TTS。
#: 这是个可逆的权衡——音质提升换首音延迟增加约 600ms。
llm: LLMConfig = field(default_factory=local_llm_config)
llm_fallback: LLMConfig = field(default_factory=deepseek_config)
context: ContextConfig = field(default_factory=ContextConfig)
search: SearchConfig = field(default_factory=SearchConfig)
voice: VoiceConfig = field(default_factory=VoiceConfig)
memory: MemoryConfig = field(default_factory=MemoryConfig)
@classmethod
def load(cls) -> "AppConfig":
load_env()
primary = _env("AI_LLM_PRIMARY", "local").strip().lower()
if primary == "deepseek":
return cls(llm=deepseek_config(), llm_fallback=local_llm_config())
return cls()
__all__ = [
"AppConfig", "LLMConfig", "ContextConfig", "SearchConfig", "VoiceConfig", "MemoryConfig",
"load_env", "local_llm_config", "deepseek_config", "PROJECT_ROOT", "ENV_FILE",
]