Friend 项目 Parity Pack v0 解析:基于本地脱敏 Cassette 重放的 STT/LLM 契约测试体系
【免费下载链接】FriendAI that sees your screen, listens to your conversations and tells you what to do项目地址: https://gitcode.com/GitHub_Trending/fr/Friend
本篇文章以仓库中 backend/testing/parity_pack_v0/README.md 为主干,结合 backend/testing/parity_pack_v0/ 下的实现源码、backend/routers/listen/parity_capture.py 实时接线以及 backend/tests/unit/test_parity_pack_v0.py 等测试用例,系统讲解 Parity Pack v0 的完整机制:它如何在仅限开发(dev)且默认拒绝的强门禁下采集匿名的"线缆观测"(cassette)、如何通过 SHA-256 脱敏指纹保证可重放性,以及如何用严格调用拓扑的播放器在无外网、无真实供应商的条件下完成 STT/LLM 回环重放。读完本文,你将掌握这套"采集→脱敏→重放→金标漂移"流水线的每个配置项、目录契约与操作流程,并能在本地复现npm run test:parity-pack-v0的整套检查。
1. 定位:本地重放包契约,而非生产采集路径
Parity Pack v0 在 README 开头就给出了明确的边界定义:This is a local replay-pack contract, not a production capture path。它是一套本地重放包契约,用来验证"同一批请求在录制时的行为与当前代码行为保持一致"(parity,即对等一致性),而不是一条可以随时开关的生产录制通道。
由此派生出的两条硬性约束贯穿全文:
- 载荷受限:Pack 载荷(pack payloads)是受限的本地/开发产物,必须禁止被提交到 Git("must never be committed");
- 默认拒绝:所有采集入口默认 deny,只有显式满足三项环境变量条件才会放行。
实现上,整个模块由 backend/testing/parity_pack_v0/ 下的若干小文件组成,各司其职:
| 文件 | 职责 |
|---|---|
| whitelist.py | CaptureWhitelist:从环境变量解析并执行默认拒绝门禁 |
| schema.py | CassetteIdentity与RequestFingerprint:匿名身份与脱敏指纹 |
| redaction.py | 保守脱敏:凭据键、邮箱、电话、URL 参数 |
| capture.py | CaptureTap/CaptureInvocation:采集与落盘 |
| players.py | STTCassettePlayer/LLMCassettePlayer回环播放器 |
| manifest.py | manifest.json的构建与写盘 |
| gold.py | 金标双跑、漂移报告 |
| runner.py | 无外网运行原语与 Fake 命中记账 |
| matrix.py | 六个合成 overlay 的名称矩阵 |
| rewrite.py | 未来重写二进制的描述符槽位 |
2. 开发采集门禁:三项环境变量与默认拒绝
采集的唯一入口是CaptureWhitelist。README 明确:CaptureTap使用CaptureWhitelist.from_environ(),在序列化任何 cassette 字节之前调用allows(principal_id),默认 deny。允许采集必须同时满足三个设置:
OMI_ENV_STAGE=dev OMI_PARITY_PACK_CAPTURE=1 OMI_PARITY_PACK_ALLOWED_PRINCIPALS='synthetic-user-1,synthetic-device-2'对照 whitelist.py 的源码,门禁判定逻辑非常直接:
def allows(self, principal_id: str | None) -> bool: """Default deny: only explicit dev enablement plus an exact allow-list hit.""" return bool(self.enabled and self.environment == "dev" and principal_id and principal_id in self.principal_ids)即允许条件为:OMI_PARITY_PACK_CAPTURE取值为1/true/yes(大小写不敏感)且OMI_ENV_STAGE == "dev"且principal 非空且严格命中OMI_PARITY_PACK_ALLOWED_PRINCIPALS逗号分隔列表。任何一个条件不满足,start()都会拒绝并只保留有界的 lane/reason 元数据。
backend/tests/unit/test_parity_pack_v0.py 用测试锁死了这一行为:prod阶段即使 capture 置 1 也拒绝;空环境默认拒绝;特别地,OMI_ENV不是规范运行时阶段变量——只有OMI_ENV_STAGE能开启 dev 采集(test_capture_whitelist_ignores_non_canonical_env_var)。
采集命名遵守匿名规则:在CassetteIdentity中使用匿名会话/事件标识符;请求指纹是规范化脱敏请求结构的 SHA-256 摘要;auth、cookies、keys、签名 URL 参数以及尽力而为的邮箱/电话字符串都会在生成摘要前被移除或打码。严禁把原始请求数据写进 manifest、报告或 Git。
3. 匿名身份与脱敏指纹:契约的地基
3.1 CassetteIdentity:六元组身份
schema.py 中的CassetteIdentity是"单次供应商调用"的身份元组,六个字段:
| 字段 | 说明 |
|---|---|
anon_session | 匿名会话标识(必须已是匿名、稳定的标识符) |
provider_lane | 供应商通道,如stt、llm、memory |
route_or_model | 路由或模型名 |
call_ordinal | 调用序号(≥ 0) |
retry_attempt | 重试次数(≥ 0) |
parent_event_anon | 父事件的匿名标识 |
构造器在__post_init__中强制:四个文本字段必须为非空字符串,call_ordinal/retry_attempt必须非负。key()方法对身份的规范 JSON 做 SHA-256,生成 64 位、文件系统安全的稳定键名——不包含任何请求内容。测试 test_identity_tuple_is_stable_and_complete 验证了as_dict()的字段完整性与key()的长度。
3.2 RequestFingerprint:canonical + redacted 的一次性摘要
RequestFingerprint.from_request()的流程是:先用redact_value(value, drop_sensitive=True)递归移除敏感字段(而不是打码),再对规范化 JSON(sort_keys=True, separators=(",",":"))做 SHA-256,算法标识为sha256-canonical-redacted-v1。摘要本身只用于比对,绝不写入 manifest 或报告。
redaction.py 的脱敏是"保守型"的,包含三层规则:
- 敏感键:正则
SENSITIVE_KEY匹配authorization、auth、cookie、token、secret、api_key、password、signature、signed、private_key、credential、bearer、jwt等(带词边界)。_is_sensitive_key()先把 camelCase 归一化为 snake_case,所以accessToken、clientSecret与access_token、access-token一样能被捕获; - URL:剥离 query/fragment,仅保留 scheme/netloc/path,并追加
[REDACTED_URL_PARAMS]标记; - 邮箱/电话:
[REDACTED_EMAIL]、[REDACTED_PHONE]打码。
redact_value的drop_sensitive参数决定敏感值的处理方式:为True(指纹场景)时直接省略,为False(报告场景)时替换为稳定的[REDACTED]标记。测试 test_fingerprint_is_canonical_and_never_contains_sensitive_values 证明:键序不同的两个请求会得到相同指纹,而指纹/脱敏结果中不会残留topsecret或电话号码。
3.3 特例:audio_b64 不做过度脱敏
capture.py 的CaptureInvocation.observe()有一个容易被忽略的关键细节:base64 音频是不透明二进制而非自由文本,对其跑文本模式脱敏可能把一段 base64 数字误判成电话号码、从而破坏可重放音频事件。因此实现中对payload.get("audio_b64")原样保留,仅对其余字段走redact_value。
4. CaptureTap:三类方向的时序观测
CaptureTap.start()在通过白名单后创建一次调用(invocation)。CaptureInvocation.observe()的观测边界记录三类 wire 事件:
client:客户端方向的观测(如解码后的客户端音频);outbound:出站到供应商的观测(如发送给 STT 的 socket 数据);inbound:供应商回调方向的观测(如 STT 返回的 transcript)。
每个CassetteEvent由direction、dt_ms、payload组成;dt_ms是相对毫秒(相对 invocation 起点的时间差,由可注入的time.monotonic时钟计算,取整到毫秒),且必须非负(构造器强制校验)。persist()把schema_version: 1、identity、fingerprint 与事件数组写成紧凑 JSON(sort_keys=True, separators=(",",":"))到cassettes/<identity-key>.json,并可选追加surface/source顶层判别字段(见第 6 节)。
白名单未命中时,CaptureTap.start()不写任何 cassette 字节,只把{"provider_lane": ..., "reason": "whitelist_miss"}追加进有界元数据列表denied_metadata,并裁剪到最近 100 条(del self.denied_metadata[:-100])。
5. 实时路径接线:/v4/listen 与 ListenParityCapture
README 指出,/v4/listen运行时只在 Firebase WebSocket 认证完成、STT 供应商选定之后才会创建routers.listen.parity_capture.ListenParityCapture。它:
- 用 Firebase UID仅做
CaptureWhitelist的精确比对; - 在 cassette 创建前派生匿名会话/事件标识符(
_anonymous_id对 UID+session 做 SHA-256 并截取前 32 位十六进制,见 parity_capture.py); - 记录解码后的客户端音频、成功的 STT socket 发送、以及供应商 transcript 回调;
- 在正常 listen 会话 teardown 期间持久化。
parity_capture.py 的from_environ()把门禁决策细化为可观测的分类(_allowlist_decision_reason):allowed、capture_disabled、stage_not_dev、principal_missing、allowlist_miss,并同步写入遥测。整条链路设计为采集绝不拖垮 listen 会话:初始化、观察、持久化任何一步异常都只记 warning 日志并降级为无操作(fail-closed,但服务本身继续)。
5.1 第四个变量:OMI_PARITY_PACK_ROOT
README 强调第四个、必需且仅操作者可设的变量,其值必须是仓库之外的绝对路径:
OMI_PARITY_PACK_ROOT=/absolute/restricted/local/parity-pack没有默认 root。缺失、相对路径或位于仓库内的 root 一律禁用。源码_capture_root()落实了这一规则:先要求非空、expanduser后必须is_absolute(),再用root.resolve().relative_to(repository_root)探测——若解析后的 root 落在仓库目录树内则返回None(禁用)。因此以下情况全部判定为禁用:
- 未设置
OMI_PARITY_PACK_ROOT; - root 是相对路径;
- root 位于仓库内;
OMI_ENV_STAGE不是dev;- 缺少
OMI_PARITY_PACK_CAPTURE=1; - 白名单未命中。
任何 Helm 或生产默认配置都不会开启这条路径。由于本地 cassette 可能包含受限的音频/transcript 事件载荷,必须把 root 放在 Git 之外,且永远不要把它附到 PR 上。
5.2 有界性约束:采集不是无限录音
parity_capture.py 与 live_capture.py 共享一组采集上限常量:
| 常量 | 值 | 含义 |
|---|---|---|
MAX_CAPTURE_EVENTS | 1 000 | 单次 invocation 最多事件数 |
MAX_CAPTURE_AUDIO_BYTES | 8 MiB | 音频总字节上限 |
MAX_CAPTURE_TEXT_CHARS | 16 384 | 单段文本截断长度 |
MAX_CAPTURE_SEQUENCE_ITEMS | 1 000 | 序列元素截断数量 |
_can_observe()在分配 base64 表示之前就检查原始输入(len(audio)),超限即置_limit_reached并丢弃后续事件,防止把 listen 会话的内存打爆。
6. 更多采集面:SurfaceParityCapture 与 surface 判别表
SurfaceParityCapture复用同一套门禁/导出器,覆盖额外的记忆形成(memory-forming)表面。它在 cassette 文档上追加可选的顶层判别字段surface/source,但保持 v1 的 identity、fingerprint、event 契约不变,因此存量播放器无需改动。README 的映射表如下:
surface | source | 采集接缝 |
|---|---|---|
ptt | desktop_ptt_http、desktop_ptt_stream | Desktop PCM PTT 与实时 PTT STT(有界音频 + transcript 事件) |
screen | desktop_screen_activity_sync | 纯文本屏幕活动/上下文同步;无视频或 embedding 向量 |
conversation_finalization | conversation_<source> | transcript 输入、记忆抽取结果与已接受记忆 |
memory_write | v3_memory_create、v3_memory_batch_create、integration_<app>、twitter_<persona> | 手动/API、集成与社交记忆写入者 |
memory_import | v3_memory_import_batch | 有界导入产物与摄取结果(非原始媒体) |
surface_parity_capture.py 提供了这些表面的通用实现:from_environ()内部同样走_capture_root→CaptureTap.start(),任何初始化异常都降级为禁用实例。对记忆载荷还定义了窄化函数_memory_payload(),只保留id、content(截断到 16K)、category、visibility、source_type五个字段——绝不把完整证据链塞进 cassette。capture_memory_write()则是一次写入一个"memory-write" cassettes 的便捷入口(request仅含memory_count与source,不暴露 UID)。对应的单元测试在 test_surface_parity_capture.py。
7. 开发部署:emptyDir 挂载与私有 GCS 导出
README 描述了开发 listen 部署的落地方式:挂载/var/omi-parity-pack作为emptyDir,仅供显式白名单的 dogfood 主体验证者使用。Pod 的fsGroup: 10001与非 root 后端镜像组一致,使 listener 能创建并持久化cassettes/目录,随后尽力而为地把 cassette JSON 导出到私有开发桶:
gs://based-hardware-dev-omi-parity-pack-v0/parity-pack/v0/cassettes/<identity-key>.json导出是fail-open的:即使 GCS 宕机,listen 会话也照常继续(persist()内对导出异常只记 warning)。README 给出的离线重放下载命令:
gcloud storage cp -r \ "gs://based-hardware-dev-omi-parity-pack-v0/parity-pack/v0" \ ./omi-parity-pack-dogfood/ # Point OMI_PARITY_PACK_ROOT at the local tree (or compose a pack with # manifest.json as required by this README), then: npm run test:parity-pack-v0两条红线:永远不要把 cassettes 提升到生产存储,也不要提交进仓库;emptyDir 是临时存储,pod 重启后、成功导出之前的数据会丢失。
7.1 可观测性:零初始化计数与日志标记
开发 listen 采集暴露零初始化的 Prometheus 计数器omi_parity_pack_capture_events_total{stage,outcome,reason_class}及配套的parity_pack_capture_event日志标记。其封闭标签(closed labels)用于区分:接受的 listen、白名单决策、采集初始化、cassette 持久化、GCS 导出尝试/成功/失败。这些事件从不包含主体验证者或会话标识、载荷、凭据或 cassette 对象路径;非 dev 运行时既不递增该计数器也不写该日志。相关实现在 parity_telemetry.py。
8. 重放播放器:严格调用拓扑的回环适配器
采集之后是重放。STTCassettePlayer与LLMCassettePlayer是 wire-oracle fakes 的回调式回环适配器(loopback adapters)。两者共享有序的InvocationTopology:
play()先验证完整身份与规范化脱敏请求指纹,再依次产出录制的PlayedEvent(direction / dt_ms / payload)并交给emit回调;assert_complete()对未使用的 cassette判定失败(unused cassettes: N);- 错位、乱序或多余调用立即失败(
CassetteTopologyError)。
players.py 的具体行为:
| 情形 | 错误信息 | 含义 |
|---|---|---|
| 已消费完但还有新调用 | extra invocation: <key> | 多出的调用 |
| 身份与当前位置 cassette 不一致 | out-of-order cassette: expected=... actual=... | 乱序 |
| 请求指纹不一致 | cassette request fingerprint mismatch | 请求不匹配 |
| player 通道不匹配 | <lane> player cannot play <lane> | 通道错配 |
| 消费后仍有剩余 | unused cassettes: N | 未用完的 cassette |
STTCassettePlayer.lane = "stt",LLMCassettePlayer.lane = "llm",互不串位。_read()还会校验schema_version == 1,不支持的版本直接抛ValueError。cassettes 始终是受限的本地/dev 输入,禁止提交。
9. 包目录布局与 manifest.json 契约
README 规定的受限本地包目录结构:
<restricted-local-pack>/ manifest.json # hashes + case descriptors only inputs/<case>.json # referenced by inputs_ref cassettes/<identity-key>.json # referenced by cassette_refsmanifest.json记录:schema 版本、pack_id、产物哈希,以及每个 case的:输入/cassette 引用、预期结果(expected outcomes)、不变量 ID、匿名 cassette 身份、脱敏请求指纹(仅摘要)。由 manifest.py 的build_manifest()生成(schema_version: 1、pack_id、cases、artifact_hashes四段),sha256_file()按 1 MiB 分块计算产物哈希。测试 test_manifest_has_references_hashes_outcomes_and_invariants 验证了 case 中invariant_ids、artifact_hashes的完整性。
基础检查命令是:
npm run test:parity-pack-v0该脚本在仓库根 package.json 中映射到 backend/testing/parity_pack_v0/run.sh,后者在仓库根执行PYTHONPATH=backend的 pytest,覆盖 test_parity_pack_v0.py 与 test_parity_pack_v0_stage3.py 两组测试。
10. 金标、漂移与重写槽
10.1 double_run_gold:先双跑,再冻结
gold.py 的double_run_gold()在更新金标(gold)前把每个 case跑两遍:
- 两次结果摘要不同 → 直接抛
AssertionError(non-deterministic replay for <case_id>),拒绝非确定性结果; - 只有
write_gold=True才允许改写expected_outcomes(把第一次结果写回 manifest);普通重放只产出一份仅摘要、仅 warn的漂移报告drift_report()(status: warn/ok、drift_count、逐条Drift(case_id, expected_digest, actual_digest)、enforcement: warn-only)。
设计意图很明确:漂移永不掩盖结果、永不阻塞开发者排查,它只是一个提示信号。金标变更必须显式走write_gold=True,防止普通重放以副作用方式悄悄改掉本地包的预期结果。
10.2 rewrite_launch_descriptor:重写二进制的显式集成槽
rewrite_launch_descriptor()是为未来重写二进制预留的显式集成槽位。描述符(RewriteLaunchDescriptor)包含command、input_manifest、output_manifest、available,其中available恒为False。README 特别强调:该描述符在本仓库中刻意不可用——操作者必须自行安装并调用经批准的二进制(默认名omi-replay-rewrite),重放流程绝不会下载或执行任意二进制。这是对供应链安全的刻意约束:只有经人工批准、显式提供的二进制才能改写 cassette 数据。
11. 合成 v0 矩阵:六个 overlay
README 给出合成 v0 矩阵的六个 overlay 名称:baseline、duplicate_delivery、provider_timeout、provider_error、out_of_order_events、redacted_capture。它们不包含任何真实采集载荷。对应 matrix.py 的SYNTHETIC_MATRIX,每个 overlay 用delivery/provider/expected三元组描述行为预期:
| overlay | delivery | provider | expected |
|---|---|---|---|
baseline | single | recorded | finalized(正常终结) |
duplicate_delivery | duplicate | recorded | idempotent(幂等) |
provider_timeout | single | timeout | recoverable(可恢复) |
provider_error | single | error | failed-safe(安全失败) |
out_of_order_events | reordered | recorded | rejected(拒绝) |
redacted_capture | single | recorded | no-sensitive-payload(无敏感载荷) |
这组矩阵实际上把重放引擎要验证的六类不变量固化成了命名契约:正常路径、重复投递的幂等性、供应商超时的可恢复性、供应商错误的失败安全、乱序事件的拒绝,以及脱敏捕获确保无敏感载荷。
12. 操作者工作流(仅限 dev)
README 在最后给出三步操作者工作流,也是整套体系的收束:
- 本地隔离:把本地包放在仓库之外;绝不提交 cassettes、inputs、载荷或白名单。
- 显式门禁:设置
OMI_ENV_STAGE=dev、OMI_PARITY_PACK_CAPTURE=1,以及显式的OMI_PARITY_PACK_ALLOWED_PRINCIPALS白名单。任何其他阶段或缺失白名单都是默认拒绝,且不持久化任何 cassette 字节。 - Hermetic 重放:用已选择加入的合成/dev principal 运行应用路径,然后执行
npm run test:parity-pack-v0进行隔离重放。测试拒绝外网 egress 并要求 Fake 命中记账,不使用任何真实供应商或生产服务。
12.1 hermetic 保障如何落地
runner.py 是第 3 步的技术保障:deny_network()委托给仓库验证过的block_outbound_network(见 backend/testing/hermetic_network.py),连底层socket.connect、connect_ex和 DNS 解析都一并封死;任何被拦截的 egress 都会以UnexpectedEgress断言失败。hermetic_run()在此之上叠加FakeHitRegistry:每个 Fake 被调用一次记一次hit(),最后由require(**expected)做精确命中计数比对(多了少了都失败)。清理钩子按逆序执行且彼此隔离——某个清理钩子失败不会阻断其他钩子,body 异常永远优先于清理异常上报。测试 test_parity_pack_v0.py 直接导入了hermetic_run与UnexpectedEgress,验证重放确实在完全离线的环境里完成。
小结
Parity Pack v0 是一套把"隐私安全"与"契约测试"焊死在一起的本地重放体系:CaptureWhitelist的默认拒绝门禁、CassetteIdentity/RequestFingerprint的匿名与脱敏、CaptureTap三类方向的事件观测、SurfaceParityCapture的多种记忆表面、STTCassettePlayer/LLMCassettePlayer的严格拓扑重放、double_run_gold的双跑金标与 warn-only 漂移,以及rewrite_launch_descriptor的安全重写槽位,共同构成一条从 dev 采集到 hermetic 重放的完整闭环。所有机制都以"载荷不落 Git、身份不落日志、egress 一律封禁"为底线,任何一环失守都会以测试失败或显式禁用收场。对需要为供应商密集型后端(STT/LLM/记忆写入)建立可回归、可离线验证的契约测试体系的工程团队而言,这套 pack 契约与目录布局是一个可直接借鉴的范本。
【免费下载链接】FriendAI that sees your screen, listens to your conversations and tells you what to do项目地址: https://gitcode.com/GitHub_Trending/fr/Friend
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考