Skip to content

fix(i18n): stop the Review Queue tab claiming a 180-day window its filter never applied (#769) - #776

Merged
yinlianghui merged 2 commits into
mainfrom
claude/issue-769-stale-articles-window
Aug 5, 2026
Merged

yinlianghui merged 2 commits into
mainfrom
claude/issue-769-stale-articles-window

Conversation

@yinlianghui

@yinlianghui yinlianghui commented Aug 5, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #769

四个语言包把 crm_knowledge_article.stale_articles 译成 Stale (>180d) / 过期 (>180 天) / Obsoletos (>180d) / 古い (>180日),而 filter 只有 status = published —— 这个页签实际返回每一篇已发布文章(包括几分钟前刚复核过的,只是排在最后)。与 #730 同缺陷类,谎言同样只活在翻译那一半:canonical metadata label 是 Review Queue · Oldest First,自始至终只承诺排序,而 last_reviewed_at asc 确实兑现了这个排序。

裁定:走退路(翻译回归 canonical),依据是实测而非偏好

派单预授权了两个分支,并给了明确的切换判据:「若 filter 层无法无损表达『早于 {180_days_ago} 或为空』,这就是切换退路的判据,不许把空值行悄悄藏掉。」这个判据实测成立,所以本 PR 改的是名字,不是 filter。

四条实测(全部在 pinned 17.0.0-rc.2 + InMemoryDriver + 真 ObjectQL.find 上跑,last_reviewed_at 声明为 datetime):

1. 宏解析,且日界落在 180 天前那一天的开始

where {"last_reviewed_at":{"$lt":"{180_days_ago}"}}
  -> [reviewed-181d, reviewed-400d, draft-ancient]   (n=3)

用字面量二分定位边界(fixtures 分别在 179d / 180d 的 00:00、11:59、23:59 / 181d / 400d 复核):

$lt 2026-02-07T00:00:00.000Z (=179d 前 00:00) -> [boundary-180d-00:00, boundary-180d-11:59, boundary-180d-23:59, reviewed-181d, reviewed-400d, draft-ancient]
$lt 2026-02-06T00:00:00.000Z (=180d 前 00:00) -> [reviewed-181d, reviewed-400d, draft-ancient]     ← 与宏结果逐行相同
$lt 2026-02-05T00:00:00.000Z (=181d 前 00:00) -> [reviewed-400d, draft-ancient]

即 {180_days_ago} 解析为「180 天前那个日历日的 00:00:00.000Z」。配 less_than(排他上界)整天被排除 —— 正是 #3777 的日界惯例,也正是 > 180d 该有的语义。三态(窗口内 / 窗口外 / 边界日)已钉进测试。

2. 空值在比较运算下不命中,而空值行由 BULK 写入路径产生(这一条我先测错了一次,如实记录)

引擎侧的事实无争议:

where {"last_reviewed_at":{"$null":true}}          -> [never-reviewed-NULL, never-reviewed-ABSENT]  (n=2)
where {"status":"published","last_reviewed_at":{"$lt":"{180_days_ago}"}}
                                                    -> [reviewed-181d, reviewed-400d]               (n=2)

显式 null 与键缺失两种写法都掉出窗口。有争议的是「已发布文章能不能真的没有复核时间戳」。第一次实测用的是裸 ObjectQL.create(),它并不注册引擎内建的 sys_fetch_previous_update(那个由 kernel 服务的 registerAuditHooks() 装上),于是 ctx.previous 恒为 undefined,我据此得出「一条普通 update 就能写回 null」—— 这个结论是错的,已作废。补上该内建的忠实复刻(priority 5 / beforeUpdate / object *)后重测:

=== single-row update (category only) ===
  [prev-fetch] input.id="crm_knowledge_article-…-1" previous=undefined
  [knowledge-hook sees] previous.status="published" input={"id":"…","category":"api"}
  => last_reviewed_at refreshed: true

=== MULTI update (category only, every published row) ===
  [prev-fetch] input.id=undefined previous=undefined
  [knowledge-hook sees] previous.status=undefined input={"category":"troubleshooting"}
  => B.category=troubleshooting  last_reviewed_at refreshed: false

=== MULTI update writing last_reviewed_at: null ===
  [prev-fetch] input.id=undefined previous=undefined
  [knowledge-hook sees] previous.status=undefined input={"last_reviewed_at":null}
  => A: last_reviewed_at=null
  => B: last_reviewed_at=null

=== the window over that table ===
  windowed -> [(none)]
  shipped   -> [A, B]

单行路径上 hook 正常补戳,null 写不进去;multi: true 路径上没有 input.id,内建取不到 previous,hook 看不到 status、什么也不戳 —— 于是批量写把 null 直接落到已发布行上,批量编辑也不会把文章标记为已复核。

修正后的结论比原先更硬,不是更软:空值行不是「畸形操作产生的理论行」,而是批量导入/批量编辑产生的行 —— 而批量导入正是一家公司把既有知识库搬进 CRM 的常规方式。最后两行是最锋利的陈述:加窗之后,这批从来没有人复核过的导入文章恰好一条都不显示。(该 bulk 路径缺陷已另立 issue,不在本 PR 修。)

3. 诚实的条件是析取,而 view filter 语法写不出析取

ViewFilterRuleSchema 是 {field, operator, value} 的扁平严格数组,规则之间 AND;没有 or,没有嵌套,没有 logic key。用语法允许的唯一写法拼出来,两条规则在同一字段上 AND,结果是零行:

where {"last_reviewed_at":{"$lt":"{180_days_ago}","$null":true}}   -> []  (n=0)

一个空白页签和「没有文章需要复核」在界面上完全一样 —— 这正是不能悄悄采用的形态。

4. 引擎本身能答对,所以这是 view 授权语法的限制,不是数据层的限制

where {"status":"published","$or":[{"last_reviewed_at":{"$lt":"{180_days_ago}"}},{"last_reviewed_at":{"$null":true}}]}
  -> [reviewed-181d, reviewed-400d, never-reviewed-NULL, never-reviewed-ABSENT]  (n=4)

这一条特意写成正面陈述并钉进测试,免得后来人把上面第 3 条误读成「引擎做不到」。

另一条独立理由:漂移发生在下游

#730 / #743 两个先例里,时间口径写在 metadata label 本身(This Quarter、Closing This Quarter)—— 契约自己作了承诺,所以必须由 filter 兑现。本条不同:canonical label 不含任何窗口承诺,> 180d 只存在于四个语言包。按 contract-first,漂移在翻译这半边,就在这半边修;让下游译文去反向重塑上游行为是反的。

不补 emptyState:本 PR 不引入新的空态(「没有已发布文章」是这个页签一直就有的零),#746 补空态的理由(加窗使「空」成为新可达状态)在这里不成立。

改了什么

文件 改动
src/translations/{en,zh-CN,es-ES,ja-JP}.ts stale_articles label 回归 canonical 的忠实翻译
src/views/knowledge_article.view.ts #773 刚重写的注释块同步改真实(原文写着 adding the bound「is filed rather than done here (#769)」,修完即过期);文件头 stale — published > 180 days without review 同样是假声明,一并订正
test/forecast-current-quarter-view.test.ts 守卫词表扩到参数化天数窗口 + 反真空正控 + #769 运行时实测块
.changeset/… 一份

行为不变:页签返回的行与顺序与 main 完全一致。

反向验证(两个方向,均先声明预期再跑)

方向一 —— 预期 RED。 把删掉的四个 >180d label 原样装回(filter 保持不加窗),新家族必须变红,且只红两条:every current-period label is backed by a filter that pins that period 与 no name this stack actually ships claims a day window;四条字面量/自带标签的探针必须保持绿,因为它们不读 stack。实跑:

FAIL … > every current-period label is backed by a filter that pins that period
+   "crm_knowledge_article.stale_articles is labelled en:\"Stale (>180d)\", zh-CN:\"过期 (>180 天)\", ja-JP:\"古い (>180日)\", es-ES:\"Obsoletos (>180d)\" but its filter carries no {180_days_ago} — it returns rows of every age, not the ones untouched for 180 days (filter tokens: none)"

FAIL … > no name this stack actually ships claims a day window
+   "crm_knowledge_article.stale_articles en:\"Stale (>180d)\"",  (…四条)

Tests  2 failed | 28 passed (30)

预期与实测一致。

方向二 —— 预期 GREEN,证明词表扩展是承重的。 同样装回四个 label,但把测试文件换回 origin/main 版本(即 #769 之前的守卫)。若旧词表本就能抓,这次扩展就是装饰:

Test Files  1 passed (1)
      Tests  17 passed (17)

旧守卫在这四个 label 上 17/17 全绿 —— 只认「当前日历周期」短语的词表看不见 > 180d,这也是为什么 #769 只能靠人眼发现。扩展是承重的。

正控在文件里长期保留:re-installing those labels over the shipped filter is reported as a breach 把删掉的四个 label 喂给 stale_articles 实际发布的 filter,断言守卫必须报一条 breach —— 两个家族都是靠删除其唯一实例来修的,没有这条正控,词表会在实例清零后长期空转而无人察觉。

验证

node scripts/check-source-hygiene.mjs   ✓ source hygiene clean
npx tsc --noEmit                        (无输出)
npx objectstack validate                ✓ Validation passed (1390ms)
npx objectstack build                   ✓ Build complete (1810ms)
npx vitest run --maxWorkers=2           Test Files 62 passed (62)
                                        Tests 1492 passed | 1 skipped (1493)

改动文件控制字符自扫 grep -naP '[\x00-\x08\x0b\x0c\x0e-\x1f]' 零命中。

越界发现(已另立 issue,未折入本 PR)

见下方评论。

…lter never applied (#769)

All four locale packs named `crm_knowledge_article.stale_articles` "Stale
(>180d)" / 过期 (>180 天) / "Obsoletos (>180d)" / 古い (>180日) over a filter
reading only `status = published`. The tab therefore returned EVERY published
article — one reviewed minutes ago included, merely sorted last — under a
heading promising a six-month cut. Same defect class as #730, and as there the
lie lived only in the translated half: the metadata label
("Review Queue · Oldest First") promised an ordering throughout, and the
`last_reviewed_at asc` sort delivers it.

The window was measured before being ruled out, not assumed impossible. On the
pinned 17.0.0-rc.2 `{180_days_ago}` resolves on the read path and lands on the
START of the calendar day 180 days back, so `less_than` excludes that whole day
— the #3777 day-boundary convention, and the right sense for "> 180d". But
`$lt` matches neither null nor absent values, so the window deletes
never-reviewed articles, which are a review queue's most overdue population.
The honest condition is a DISJUNCTION ("older than the window OR never
reviewed"), and a view `filter` is a flat, strict array of {field, operator,
value} rules combined with AND: no `or`, no nesting, no logic key. Spelled the
only way the grammar allows, the two rules AND together and the tab returns
zero rows. The engine itself answers correctly with `$or` — the limit is the
view-authoring grammar, not the data layer.

So the fix is the name, not the filter. Which rows the tab returns is
unchanged, and no emptyState is authored because no new empty state exists (the
#746 reason does not apply here).

The house-rule guard grew a second claim family. It read only
current-calendar-period phrases and so was blind to "> 180d": run against these
four labels verbatim it passes 17/17. It now also reads parameterised day
windows in all four locales, and carries a positive control — the deleted
labels, fed through the shipped filter, must be reported as a breach — so the
family cannot pass vacuously now that its only instances are gone. A new
runtime block pins every measurement above against a real engine.

Fixes #769

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VHrPAGEgFDoHjphqYG4BMa
@vercel

vercel Bot commented Aug 5, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
hotcrm Ignored Ignored Aug 5, 2026 7:21pm

Request Review

@github-actions github-actions Bot added ci/cd CI plumbing and the verification pipeline metadata Declarative metadata — schema, security posture, UI surfaces labels Aug 5, 2026
…article unreviewed (#769)

The note added in the previous commit said `last_reviewed_at` is nullable
without saying how a published article reaches that state, and the measurement
behind it was taken on a bare `ObjectQL.create()` — which does NOT register the
engine's `sys_fetch_previous_update` builtin. Re-measured with a faithful
replica of that builtin bound (priority 5, beforeUpdate, object '*'), the first
reading was wrong in one direction and right in another:

  single-row update  prev-fetch sets previous, hook sees previous.status
                     "published" and re-stamps — a null write is overwritten
  multi:true update  input.id is undefined, so the builtin fetches nothing,
                     the hook sees no status and stamps nothing — a bulk write
                     puts null straight onto published rows, and a bulk edit
                     never marks them reviewed

So the never-reviewed population is not produced by ordinary editing; it is
produced by bulk loads and mass edits — which is how an org imports an existing
knowledge base. That makes the window's loss worse than "rare and
pathological", not better: the rows it would hide are the freshly imported
ones, which no human has ever reviewed.

Comment only. The runtime block's assertions are about `$lt`, null and the
filter grammar, none of which depend on hooks, so they are unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VHrPAGEgFDoHjphqYG4BMa

Copy link
Copy Markdown
Collaborator Author

越界发现(均已另立 issue,未折入本 PR)

按 Prime Directive #10,实测过程中撞到的三条与 #769 无关的问题,先去重再单独立单,不带 label、不 assign,留给 PM triage:

三条都先做了关键词 + 文件路径检索去重(last_reviewed_at / ctx.previous / published_at / multi bulk update hook / 视图 filter 析取),未命中已有 open issue。


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci/cd CI plumbing and the verification pipeline metadata Declarative metadata — schema, security posture, UI surfaces

Projects

None yet

Development

Successfully merging this pull request may close these issues.

stale_articles 的四个语言包 label 都写着 “>180d”,但 filter 只有 status = published —— 列表返回每一篇已发布文章

2 participants