Files
ai-companion/app/identity/manual.py
T
ylhan 35e107c290 助手本体:会话引擎、LLM 路由、记忆、身份、语音适配
从 Obsidian 笔记库剥离为独立仓库。规划文档与 P0 报告留在笔记库,
代码(助手本体、TTS 服务、P0 基准脚本、ESP32 固件)在此。

架构:服务器是助手,本地是客户端
- 助手本体跑在 cadadmin-server,N5095 只做 USB 外设桥与网络桥接
- 起因:实测 N5095 无 AVX2,跑不动可用的语音模型

当前技术栈
- LLM:DeepSeek deepseek-flash(云端,必须关思考)
- TTS:Qwen3-TTS 1.7B(服务器 3090,独立 HTTP 服务)
- STT:Zipformer 中英双语 int8(CPU,中文识别率未测)

实测:端到端 16/16 通过,首音延迟 1545ms
(对比:本地 LLM + vits-zh-ll 是 179ms,但音色不满意——音质与延迟
在此硬件上此消彼长)

设计要点
- MemoryStore 接口按"可能远程"设计(全 async、可超时、可失败)
- user_id / session_id / track_id 三者分开,人脸只作候选不自动切身份
- 记忆查询强制带 owner_user_id,无按 id 单独取的口子
- 自动抽取的记忆是"候选",默认不参与检索
- 异步写记忆绑定创建时的身份,不读"当前用户"
- 引擎与传输解耦,只用文件音频即可端到端测试

模型与音频产物不进版本管理(1.1GB),由 download_models.sh 恢复
2026-09-15 11:05:09 +08:00

124 lines
4.6 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.
"""首版身份实现:显式切换 + 访客兜底。
对应方案 §6:"首版可靠身份入口:手动/对话确认切换到已建档的 user_id;
无法确认时使用 unknown/guest。"
行为规则:
1. 显式切换(`switch`)立即生效,这是唯一能单独定身份的方式。
2. 人脸/声纹线索只生成**建议**,不自动改身份。上层可以选择性地
问一句"要切换成 X 吗",但不能替用户决定。
3. 没有任何线索时是访客,且访客不能落私人记忆(由记忆层用
owner_user_id 隔离保证)。
4. 切换身份返回一个"版本号",上层据此判断要不要丢弃上一人的上下文。
"""
from __future__ import annotations
import logging
from typing import Sequence
from .base import (
GUEST_USER_ID, TRUST_GUEST, TRUST_NORMAL,
IdentityClaim, IdentityDecision, IdentityProvider,
)
log = logging.getLogger(__name__)
#: 人脸候选被采纳为"建议"的最低置信度。
#: 低于这个值连建议都不给——减少无意义的打扰。
SUGGEST_MIN_CONFIDENCE = 0.6
class ManualIdentityProvider(IdentityProvider):
def __init__(self, initial_user: str | None = None):
self._decision = IdentityDecision(
user_id=initial_user or GUEST_USER_ID,
trust_level=TRUST_NORMAL if initial_user else TRUST_GUEST,
confirmed=bool(initial_user),
reason="初始身份" if initial_user else "初始为访客",
)
self._version = 0
# ---------------------------------------------------------- 查询
def current(self) -> IdentityDecision:
return self._decision
@property
def version(self) -> int:
"""身份版本号。每次真正变更时 +1。
上层用它判断"上一轮的私有检索上下文是否过期"——
方案 §8 要求切换身份时清理上一人的上下文。
"""
return self._version
# ---------------------------------------------------------- 变更
def switch(self, user_id: str, *, display_name: str = "",
reason: str = "显式切换") -> IdentityDecision:
if not user_id:
raise ValueError("user_id 不能为空")
prev = self._decision.user_id
self._decision = IdentityDecision(
user_id=user_id,
trust_level=TRUST_NORMAL,
confirmed=True,
reason=reason,
)
if prev != user_id:
self._version += 1
log.info("身份切换 %s → %s(%s)", prev, user_id, reason)
return self._decision
def reset(self, reason: str = "回到待机") -> IdentityDecision:
if self._decision.user_id != GUEST_USER_ID:
self._version += 1
self._decision = IdentityDecision(
user_id=GUEST_USER_ID,
trust_level=TRUST_GUEST,
confirmed=False,
reason=reason,
)
return self._decision
def resolve(self, claims: Sequence[IdentityClaim]) -> IdentityDecision:
"""裁决。
顺序很重要:先看有没有权威来源(显式切换 / 客户端登录态),
有就采用;人脸之类的只作为建议附在后面。
"""
authoritative = [c for c in claims if c.is_authoritative and c.candidate_user_id]
suggestions = [
c for c in claims
if not c.is_authoritative
and c.candidate_user_id
and c.confidence >= SUGGEST_MIN_CONFIDENCE
]
if authoritative:
# 同一批里有多个权威来源且互相矛盾——宁可不切,报出来让人处理
ids = {c.candidate_user_id for c in authoritative}
if len(ids) > 1:
log.warning("权威身份线索互相矛盾 %s,保持当前身份", ids)
d = self._decision
d.reason = f"线索矛盾 {ids},保持 {d.user_id}"
d.suggestions = suggestions
return d
c = max(authoritative, key=lambda x: x.confidence)
assert c.candidate_user_id is not None
self.switch(c.candidate_user_id, reason=f"来源 {c.source}")
else:
# 没有权威来源:即便人脸认出来了也不切,只给建议
self._decision.suggestions = suggestions
if suggestions:
self._decision.reason = (
f"仅有人脸候选 {suggestions[0].candidate_user_id}"
f"(置信度 {suggestions[0].confidence:.2f}),未自动切换"
)
return self._decision
__all__ = ["ManualIdentityProvider", "SUGGEST_MIN_CONFIDENCE"]