FriendOmi后端每日负面反馈报告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}某一天的报告计数 指针pointersbackend/jobs/feedback_daily_report.pyfeedback_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包含dateUTC 日期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为盐、infobuser-data-encryption派生 32 字节用户专属密钥再以 AES-256-GCM 加密见 backend/utils/encryption.py 中的ENCRYPTION_SECRET校验逻辑——环境变量缺失或短于 32 字节会直接抛ValueErrorenhanced是默认的数据保护等级因此对话已加密是常态而非少数用户的自选如果把可读转录物物化进报告集合就等于制造了用户对话的第二份永久明文副本——任何持有 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_contextbackend/utils/feedback_context.py其模块注释明确写道这是负面反馈对话文本以明文形式存在的唯一路径且只存在于响应生命周期内。访问控制与审计三道关卡 逐人归因官方文档规定读取报告与上下文必须通过三道关卡全部通过才能放行admin.omi.me—— 浏览器用 Firebase 登录路由处理器要求存在adminData/{uid}文档对应前端 web/admin/lib/auth.tsX-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_contextbackend/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_SECONDS300评分后仍视为对该答案的反应的时间窗秒MAX_PRECEDING_TURNS10被评分回合之前最多保留的回合数MAX_FOLLOW_UP_TURNS10窗口内最多携带的后续回合数MAX_HYDRATED_TEXT_CHARS4000每次按需解密单回合文本的硬上限防止粘贴文档撑爆响应关于之后窗口还有一个实现细节查询会多取一条limit(MAX_FOLLOW_UP_TURNS 1)来判断是否截断从而保证爆发的重试以truncated_after标志明确暴露而不是静默裁剪——因为窗口承诺的是5 分钟内每一回合一旦裁剪就必须可见。单元测试对窗口语义做了严格锚定backend/tests/unit/test_feedback_report.pytest_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_textmacOS 主窗口PATCH /v2/desktop/messages/{id}/rating是原因选择器随本改动上线chat_voicemacOS 浮动条同上surfacevoice否悬浮覆盖层放不下原因选择行需单独设计迭代chat_notification主动卡片同上surfacenotification否选择器只在回答气泡上conversation_summaryPOST /v1/users/analytics/memory_summary否该端点只接受评分memoryPOST /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 \ --schedule30 1 * * * \ --time-zoneEtc/UTC \ --urihttps://backend-host/v1/admin/feedback/reports/generate-yesterday \ --http-methodPOST \ --headersX-Admin-KeyADMIN_KEY \ --attempt-deadline1800s调度在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_ENTRIES500携带的条目数RAW_FETCH_LIMIT2000 500 × 4从账本读取的原始行数MAX_REPORT_DOCUMENT_BYTES800 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_textbackend/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的用户级 HKDFAES-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),仅供参考