Friend(Omi)后端每日负面反馈报告(Daily Negative-Feedback Report)架构与运维实践
【免费下载链接】FriendAI that sees your screen, listens to your conversations and tells you what to do项目地址: https://gitcode.com/GitHub_Trending/fr/Friend
导读
本文围绕 Friend 仓库(项目描述:AI that sees your screen, listens to your conversations and tells you what to do)后端实现的每日负面反馈报告(thumbs-down 报告)展开,详细讲解该报告系统如何把"用户给了一个差评"这种孤立信号,还原成"用户问了什么、Omi 答了什么、随后五分钟内用户做了什么、为什么打差评"的完整可行动上下文。读完本文,你将掌握该报告的两层数据模型(追加式事件账本 + 每日指针型报告)、按需解密的安全设计、三层访问控制与审计日志、上下文窗口的截取规则、Cloud Scheduler 调度与手工回填命令,以及整套三重截断保护机制及其背后的 Firestore 1 MiB 文档限制约束。该机制的核心源码位于 backend/docs/runbooks/negative-feedback-daily-report.md 所对应的 backend/jobs/feedback_daily_report.py、backend/utils/feedback_context.py 与 backend/routers/feedback_admin.py。
报告要回答的问题:把"差评数量"升级为"可行动上下文"
一个孤立的事实——"昨天有 14 个 thumbs-down"——本身没有任何可行动价值。真正有价值的是每次差评前后的完整对话回合。该报告为每个thumbs-down 事件提供四类信息:
- 用户问了什么(被评分回合之前的同会话上下文);
- Omi 答了什么(被评分的消息本身);
- 用户随后五分钟内做了什么(是否重试、是否换了新会话重问、是否放弃);
- 用户为什么打差评(在客户端已提供原因选择器的表面层上,捕获结构化的
reason)。
报告的阅读入口是管理后台仪表盘https://admin.omi.me/dashboard/feedback。设计者的一句核心判断是:"评分本身告诉你不了任何可行动的信息,但评分之前的回合和之后的重试通常告诉你一切"——这正是整个报告系统存在的原因。
数据在哪里:两张各司其职的 Firestore 集合
报告的数据模型刻意分为两层,全部以 UTC 日为单位组织。官方文档给出的集合结构如下:
| 集合 | 存放内容 | 写入方 |
|---|---|---|
feedback_events | 每次评分动作一行,追加式(append-only),覆盖所有评分表面 | backend/utils/feedback.py,由所有评分端点调用 |
feedback_reports/{YYYY-MM-DD} | 某一天的报告:计数 +指针(pointers) | backend/jobs/feedback_daily_report.py |
feedback_events:统一评分账本
backend/models/feedback.py 中的FeedbackEvent模型定义了账本行结构。值得注意的关键设计(均可从源码确认):
- 追加式而非更新式:用户把 thumbs-down 翻回 thumbs-up 会产生一条新事件而不是擦除旧事件,因为"这个答案曾经让用户失望"这个事实本身值得复盘;
- 值域白名单:
_VALID_VALUES = frozenset({-1, 0, 1}),即1为赞、-1为踩、0为清除评分。任何越界值(如-2)会被 backend/database/feedback.py 中的record_feedback_event直接拒绝并记日志,而不是落库——否则报告查询永远匹配不到它,却像是一条"已记录的反馈",形同漏洞; - 写入时即捕获对话坐标:事件携带
chat_session_id、target_created_at,以及模型溯源字段langsmith_run_id、prompt_name、prompt_commit。这使每日报告作业无需扫描用户整个消息历史即可定位被评分回合(见 backend/utils/feedback.py 的模块注释); - 尽力而为写入:
record_feedback_event永不抛异常——调用方已经先把自己的评分持久化,账本行丢失只会损失一行报告数据,不会让用户请求失败; - 原因字段白名单化:
POST /v1/users/analytics/chat_message的历史端点上reason是自由字符串。写入前会做FeedbackReason枚举校验,未知值只丢弃该字段、保留评分——否则整行会因枚举解析失败被读边界丢弃,差评会从报告中"凭空消失"。
feedback_reports/{YYYY-MM-DD}:每日指针报告
FeedbackReport模型(backend/models/feedback.py)包含:date(UTC 日期YYYY-MM-DD)、generated_at、total_negative、三个计数分布(counts_by_surface/counts_by_reason/counts_by_platform)、entries(事件信封 + 上下文指针)以及truncated标志。
两个集合都不包含任何对话文本。这不是疏忽,而是刻意为之——也是"报告需要两步阅读(先看指针,再按需解密)而非一个文档搞定"的根本原因。
为什么报告存"指针"而不是"转录文本":加密与隐私边界
这是整个系统最重要的设计决策,官方文档与源码给出了完整论证:
- 聊天文本在静态存储时是加密的。密钥由后端主密钥
ENCRYPTION_SECRET按用户派生:derive_key(uid)使用 HKDF-SHA256,以uid为盐、info=b'user-data-encryption'派生 32 字节用户专属密钥,再以 AES-256-GCM 加密(见 backend/utils/encryption.py 中的ENCRYPTION_SECRET校验逻辑——环境变量缺失或短于 32 字节会直接抛ValueError); enhanced是默认的数据保护等级,因此"对话已加密"是常态而非少数用户的自选;- 如果把可读转录物物化进报告集合,就等于制造了用户对话的第二份永久明文副本——任何持有 Firestore 服务账号的人都能读取它,这与加密的全部意义相悖。
于是每晚作业(backend/jobs/feedback_daily_report.py 的generate_report→ backend/utils/feedback_context.py 的resolve_chat_context)只读取消息文档的明文元数据——id、sender、created_at、chat_session_id——并且这个约束是通过Firestore field mask(字段投影_METADATA_FIELDS)在查询层强制执行的,而非仅仅依赖代码"碰巧不去读text":加密正文根本不会跨越网络进入报告作业(见 backend/utils/feedback_context.py 顶部模块注释与_METADATA_FIELDS定义)。
当审查者在 admin.omi.me 上展开某条记录时,后端才按请求、按事件解密那一小段窗口并返回,解密结果不落盘。源码中该路径是hydrate_context(backend/utils/feedback_context.py),其模块注释明确写道:"这是负面反馈对话文本以明文形式存在的唯一路径,且只存在于响应生命周期内。"
访问控制与审计:三道关卡 + 逐人归因
官方文档规定,读取报告与上下文必须通过三道关卡,全部通过才能放行:
- admin.omi.me—— 浏览器用 Firebase 登录,路由处理器要求存在
adminData/{uid}文档(对应前端 web/admin/lib/auth.ts); X-Admin-Key—— Next.js 路由在服务端附加后端的ADMIN_KEY。该密钥只存在于 Cloud Run 运行时密钥中,永不进入浏览器;- GCP—— 直接读取原始集合需要项目内的 Firestore 访问权限。
普通用户的 Firebase token 到达不了这些端点中的任何一个。
审计方面有一个非常细致的归因设计(见 backend/routers/feedback_admin.py 的_verify_admin_key):
- 每次对上下文(解密)端点的调用都会被记录:事件 id、管理员密钥哈希(
sha256(x_admin_key)[:8])、以及发起请求的管理员的 Firebase uid; - 为什么只靠密钥哈希不够?因为
ADMIN_KEY是部署级共享密钥,密钥哈希只能标识"哪个部署",无法回答"哪个管理员读了用户的聊天"; - 因此 admin.omi.me 在服务端校验
adminData/{uid}之后,会把调用者的 Firebase uid 作为X-Admin-User头转发。uid 本身不可信(任何持有密钥的人都能伪造),但它的作用不是第二道门,而是归因——让一次授权读取可以追溯到具体的人; - 没有 uid 头的读取会记作
unattributed。这不算错误(密钥仍然是门禁),但意味着有人绕过仪表盘直接调用了后端,值得留意。
上下文窗口的截取规则:前后不对称,跨会话追踪
resolve_chat_context(backend/utils/feedback_context.py)定义了两段非对称窗口:
- 之前(Before):同一
chat_session_id内、截至被评分回合的回合,最多保留最新的 10 条(超出则设置truncated_before标记截断)。限制是有意的:长会话可能有上百个回合,而"产生坏答案的铺垫几乎总是在最后几条"; - 之后(After):评分后5 分钟内的每一回合,无论会话。一个用户放弃坏答案、开一个新会话重问——恰恰是最值得看的后续行为。5 分钟窗口(
FOLLOW_UP_WINDOW_SECONDS = 5 * 60)宽到能覆盖"让我换个说法"及其产生的重试,窄到一小时后的无关提问不会被误读为坏答案的连锁反应。
三个核心常量(均可从 backend/utils/feedback_context.py 源码确认):
| 常量 | 值 | 含义 |
|---|---|---|
FOLLOW_UP_WINDOW_SECONDS | 300 | 评分后仍视为"对该答案的反应"的时间窗(秒) |
MAX_PRECEDING_TURNS | 10 | 被评分回合之前最多保留的回合数 |
MAX_FOLLOW_UP_TURNS | 10 | 窗口内最多携带的后续回合数 |
MAX_HYDRATED_TEXT_CHARS | 4000 | 每次按需解密单回合文本的硬上限(防止粘贴文档撑爆响应) |
关于"之后"窗口还有一个实现细节:查询会多取一条(limit(MAX_FOLLOW_UP_TURNS + 1))来判断是否截断,从而保证"爆发的重试"以truncated_after标志明确暴露,而不是静默裁剪——因为窗口承诺的是"5 分钟内每一回合",一旦裁剪就必须可见。
单元测试对窗口语义做了严格锚定(backend/tests/unit/test_feedback_report.py):test_follow_up_window_crosses_sessions_but_stops_at_five_minutes验证新会话s2里 30 秒后的重试被纳入、480 秒后的消息被排除;test_preceding_turns_are_scoped_to_the_rated_session验证"之前"窗口绝不跨会话。
覆盖的评分表面(Surfaces)与原因捕获矩阵
官方文档用一张表完整列出了当前所有评分表面、评分路径与原因捕获情况:
| 表面 | 评分路径 | 是否捕获原因 |
|---|---|---|
chat_text(移动端) | POST /v1/users/analytics/chat_message、PATCH /v2/messages/{id}/rating | 是 |
chat_text(macOS 主窗口) | PATCH /v2/desktop/messages/{id}/rating | 是(原因选择器随本改动上线) |
chat_voice(macOS 浮动条) | 同上,surface=voice | 否(悬浮覆盖层放不下原因选择行,需单独设计迭代) |
chat_notification(主动卡片) | 同上,surface=notification | 否(选择器只在回答气泡上) |
conversation_summary | POST /v1/users/analytics/memory_summary | 否(该端点只接受评分) |
memory | POST /v3/memories/{id}/review | 否(保留/丢弃是二元的) |
这里有一个贯穿全文的语义细节:没有原因的差评计为not_captured,绝不等于"未给原因"。这是两个不同的事实,报告必须把它们分开——not_captured的含义是"我们从未询问",而不是"用户拒绝回答"(backend/jobs/feedback_daily_report.py 中generate_report的注释明确说明了这一设计意图,且 backend/tests/unit/test_feedback_report.py 的test_report_counts_a_reasonless_thumbs_down_as_not_captured将其钉死为测试契约)。
chat_notification被单独拆分,与 PR #12626 将这些卡片从响应质量比率中排除是同一理由:给一条主动推送的 focus/insight/task 卡片打分,评判的是通知本身,而不是 Omi 给出的答案。把它们当作聊天失败来读,会归错系统的责。FeedbackSurface枚举(backend/models/feedback.py)中该字段的 docstring 同样说明了这一点。
调度:Cloud Scheduler + 管理端点,01:30 UTC
报告作业由一个命中管理端点的Cloud Scheduler任务触发,形态与管理员仪表盘的precomputecron 一致。该调度任务不定义在本仓库内,需要按环境创建一次。官方文档给出的创建命令:
gcloud scheduler jobs create http feedback-daily-report \ --schedule="30 1 * * *" \ --time-zone="Etc/UTC" \ --uri="https://<backend-host>/v1/admin/feedback/reports/generate-yesterday" \ --http-method=POST \ --headers="X-Admin-Key=<ADMIN_KEY>" \ --attempt-deadline=1800s调度在01:30 UTC的理由很务实:UTC 日结束后留出 1.5 小时的余量,确保"迟到的评分写入"不会落在报告构建完成之后。对应的服务端入口是 backend/routers/feedback_admin.py 中的POST /v1/admin/feedback/reports/generate-yesterday——它内部调用previous_utc_day()计算"昨天"(UTC 时区换算,避免任何本地时区偏差),把日期运算完全留在后端,调度配置里不做日期算术。
回填与恢复:从账本随时重建任意一天
由于事件账本是追加式且不与报告一起删除,某天失败的运行可以随时从账本重建。官方文档给出的手工回填命令:
curl -X POST -H "X-Admin-Key: $ADMIN_KEY" \ "https://<backend-host>/v1/admin/feedback/reports/2026-09-01/generate"仪表盘上的Regenerate(重新生成)按钮对所选日期做的就是这件事。其服务端实现是 backend/routers/feedback_admin.py 的POST /v1/admin/feedback/reports/{report_date}/generate,文档字符串明言这是"调度器入口,也是手工回填路径"。
两个值得一提的路由细节(源码可证):
- 日期解析严格化:
_parse_date用strptime(value, '%Y-%m-%d')校验,拒绝任何非标准格式——因为报告文档以规范YYYY-MM-DD为键,未填充的2026-9-1若被放行,会去查一个不存在的文档而 404; - 上下文端点可脱离报告独立工作:
GET /v1/admin/feedback/events/{event_id}/context若未命中报告内的存储指针(事件在报告之外,或报告早于窗口形状变更),会现场resolve_context实时解析,保证路由始终有应答。
已知限制与三重截断保护
官方文档列出的限制,几乎每一条都对应源码中的防御逻辑。逐一展开:
1. 报告容量上限(三重边界)
一份报告就是一个 Firestore 文档,而Firestore 对任何超过 1 MiB 的文档直接拒绝写入。三条边界共同把它压在限制以内,且越过任意一条都会设置truncated: true,而不是静默展示残缺的一天:
| 边界 | 值 | 作用 |
|---|---|---|
MAX_REPORT_ENTRIES | 500 | 携带的条目数 |
RAW_FETCH_LIMIT | 2000(= 500 × 4) | 从账本读取的原始行数 |
MAX_REPORT_DOCUMENT_BYTES | 800 KiB | 序列化字节预算 |
为什么三个都需要(backend/database/feedback.py 中的常量注释讲得很透):
- 原始行上限大于条目上限:一个带原因的差评会写两行账本(点击评分 + 选择原因),坍缩后才是一条条目。如果只看坍缩后的条目数,某天即使读取已越界,仍可能显示"报告完整"——所以截断判断必须基于原始行数(
hit_raw_limit),而非坍缩后的条目。单元测试test_truncation_is_judged_on_raw_rows_not_collapsed_entries专门验证了这一点; - 字节预算不可或缺:仅靠条目数并不安全——500 条各带完整 21 回合窗口的条目序列化后约2 MiB,写入会直接失败,结果"高反馈日反而一份报告都没有",而这恰是最需要报告的哪天。所以生成器边写边量(
_entry_bytes按存储 JSON 计算 UTF-8 字节数),超预算即停并置truncated。测试test_report_stops_at_the_document_size_budget_and_says_so验证字节预算触发后,total_negative == 20依然统计全天,只有上下文窗口被裁剪; - "先保一条"守卫:字节预算若小到连单个窗口都放不下,也必须输出至少一条条目(
if used > budget and entries: break)——否则空报告与"平静的一天"无法区分,审查者将一无所获。测试test_one_oversized_window_still_yields_an_entry用MAX_REPORT_DOCUMENT_BYTES = 1钉死了这条行为。
当字节预算截断报告时,counts_by_surface、counts_by_reason和total_negative仍然描述完整的一天——只有逐事件上下文窗口被丢弃。分布是你据以行动的部分,转录是你随时可以重新生成或查回的部分。
2. 后续回合上限
窗口最多携带前后各 10 个回合。一次超过上限的重试爆发会设置truncated_after——因为窗口承诺的是"5 分钟内每一回合",静默裁剪会让人把一个忙碌的重试爆发误读成一次平静的重试(测试test_a_burst_of_follow_ups_is_reported_as_truncated验证)。
3. 未知会话
被评分消息没有chat_session_id时,完全没有"之前"窗口,并设置resolution_error: preceding_turns_session_unknown。源码注释解释了原因:只保留时间过滤、去掉会话过滤,会把任何会话的前十条消息拉回来冒充"这个答案的铺垫",比什么都不显示更糟——审查者会把一段无关对话读成产生坏回答的问题(测试test_preceding_window_is_skipped_when_the_session_is_unknown验证)。
4. 无法解密的回合
utils.encryption.decrypt在解密失败时返回它的输入,所以失败不抛异常,得到的是被当作字符串的 base64 密文。hydrator(_readable_text,backend/utils/feedback_context.py)通过与存储值比对来识别这种失败——对enhanced行,若解出文本与传入密文逐字节相同,判定解密未成功,把该回合列入unavailable列表,而不是把密文 blob 渲染成用户的原话(测试test_hydrate_marks_a_turn_unavailable_when_it_comes_back_encrypted验证)。
5. 已删除消息
夜间运行与审查者阅读之间被删除的会话,同样以unavailable中的消息 id 呈现,而不是显示一个会被误读为"用户什么都没说"的缺口(hydrate_context中对_find_message返回 None 的分支)。
6. 部署前无历史
账本从空开始,第一份报告只覆盖本功能上线后记录的评分。旧的analytics行(type: 'chat_message')不受影响,仍继续喂养现有的 PostHog 比率图表。
7. 每条被评分消息只对应一条条目
macOS 客户端在点击时发送一次评分、选择原因时再发送一次,账本因此有两行;报告保留信息量更大的那条。注意排序规则不是简单的"后者胜":客户端两次独立请求可能乱序到达,若按到达时间排序,裸评分行后到会静默丢弃用户真正给出的原因。_is_more_informative按信息内容排序——有原因的行永远胜过没有原因的行(backend/jobs/feedback_daily_report.py),从而无论两个请求如何竞争,原因都能存活(测试test_the_reason_survives_when_the_two_rating_writes_land_out_of_order验证)。也因此,账本行数与报告条目数不是同一个数。
关键源码索引
以下文件是深入研读本主题的起点(均以仓库根目录为基准):
- backend/docs/runbooks/negative-feedback-daily-report.md —— 本文所依据的官方运维手册原文;
- backend/jobs/feedback_daily_report.py —— 每晚报告生成作业:按原始行判断截断、按信息量坍缩条目、边写边量字节预算;
- backend/utils/feedback_context.py —— 窗口解析(
resolve_chat_context,字段投影只读元数据)与按需解密(hydrate_context,响应级生命周期); - backend/routers/feedback_admin.py —— 管理端点:密钥校验、日期解析、报告列表/读取/生成、上下文解密端点与审计日志;
- backend/database/feedback.py —— 账本与报告集合的存储层,三个容量常量(
MAX_REPORT_ENTRIES、RAW_FETCH_LIMIT、MAX_REPORT_DOCUMENT_BYTES)与写入白名单; - backend/models/feedback.py ——
FeedbackEvent/FeedbackReport/FeedbackContextPointer/FeedbackContextHydrated等数据契约; - backend/utils/feedback.py —— 三个评分端点的统一写账本入口,写入时捕获会话与模型溯源坐标;
- backend/utils/encryption.py ——
ENCRYPTION_SECRET与derive_key的用户级 HKDF+AES-GCM 加密; - backend/tests/unit/test_feedback_report.py —— 覆盖窗口跨会话、无明文契约、
not_captured计数、乱序原因存活、三种截断与防空报告等全部关键行为的单元测试。
【免费下载链接】FriendAI that sees your screen, listens to your conversations and tells you what to do项目地址: https://gitcode.com/GitHub_Trending/fr/Friend
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考