From a3cf99a8da10caca0c62af59ed452bbb70f27553 Mon Sep 17 00:00:00 2001 From: tamakyi Date: Sun, 25 Oct 2026 09:27:57 +0800 Subject: [PATCH] =?UTF-8?q?fix(stats):=20=E7=8E=AF=E6=AF=94=E5=8F=A5?= =?UTF-8?q?=E7=82=B9=E6=98=8E=E6=9C=88=E4=BB=BD=E4=B8=8E=E6=9D=A1=E6=95=B0?= =?UTF-8?q?=EF=BC=8C=E4=B8=8D=E5=86=8D=E5=86=99=E3=80=8C=E4=B8=8A=E6=9C=88?= =?UTF-8?q?=E2=80=A6=E7=8E=AF=E6=AF=94=20-75%=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 「上月提问量环比 -75.0%」这句要读者做两次翻译:先把「上月」翻成具体月份, 再把「环比」翻成「比上上月」。两个相对词叠在一起,站长本人看到就以为 是「上月同期 vs 本月同期」—— 而它其实比的是两个完整自然月。 改前:上月提问量环比 -75.0%。 改后:9 月收到 5,620 条提问,比 8 月的 4,890 条多了 14.9%。 口径一个字没改(仍是上月 vs 上上月,本月没过完不参与),只是把月份和条数 写进句子:读者不需要知道「环比」的定义也能判断这句话在说什么,还能顺手 核对百分比(5620/4890 确实是 +14.9%)。 实现: - `monthText` 按语言出月份名 —— CJK 一律「9 月」,英文要 "September" ("month 9" 不是英语的说法)。不带年份:相比的两个月相邻。 - 一个词条拆成 momUp / momDown / momFlat:方向词塞不进同一个模板, 四种语言里「多了 / 少了」的位置和词形都不一样。 - `|pct| < 0.05` 走持平,否则会出现「多了 0.0%」。 测试:`TestInsightMoMNamesBothMonths` 锁住「句子里同时有两个月份名和两个条数」, 覆盖上升/下降/持平;`TestMonthText` 锁语言差异。 顺手修预览数据:`zz_preview_test.go` 的 `GrowthMoM` 原来写死 12.3,和它造的 monthly 对不上。以前句子里只有百分比无所谓,现在会连条数一起印出来, 截图就成了「5,620 比 4,890 多了 12.3%」这种假证据,改成从数据算。 --- .workbuddy/memory/2026-10-25.md | 20 +++++++++++++ .workbuddy/memory/topics/stats.md | 6 +++- internal/i18n/dict_en.go | 4 ++- internal/i18n/dict_ja.go | 4 ++- internal/i18n/dict_zh_cn.go | 4 ++- internal/i18n/dict_zh_tw.go | 4 ++- internal/stats/overview_test.go | 50 +++++++++++++++++++++++++++++++ internal/stats/page.go | 41 +++++++++++++++++++++++-- templates/zz_preview_test.go | 4 ++- 9 files changed, 129 insertions(+), 8 deletions(-) diff --git a/.workbuddy/memory/2026-10-25.md b/.workbuddy/memory/2026-10-25.md index 18b73d4..154ab81 100644 --- a/.workbuddy/memory/2026-10-25.md +++ b/.workbuddy/memory/2026-10-25.md @@ -27,3 +27,23 @@ 页面文案一律以**访客**为第二人称对象:不写实现细节(缓存、重启、索引), 不写作者视角的免责(「没有推测」「这里只有」「本文」这类把读者当审稿人的话)。 数据来源/隐私/新鲜度这些确实要对访客交代的事,放 `stats.lead` 与页脚注。 + +## 环比句改成「点明月份 + 条数」 + +用户问「上月提问量环比 -75.0%」是不是「上月同期比本月同期」。**不是**——实际比的是 +两个**完整自然月**(9 月 vs 8 月)。但这个问题本身就是证据:句子里「上月」和「环比」 +两个相对词叠着,读者得先做两次翻译,很容易读成「同期对比」。 + +- 新句:`9 月收到 5,620 条提问,比 8 月的 4,890 条多了 14.9%。` + EN:`September had 5,620 questions; August had 4,890, up 14.9%.` +- 口径没变(仍是上月 vs 上上月,本月没过完不参与),只换表达。 +- `page.go` 新增 `monthText`:CJK 一律「9 月」,英文用 `time.Month().String()` + ("month 9" 不是英语),不带年份——相比的两个月相邻。 +- 三个词条 `momUp/momDown/momFlat` 取代 `mom`:方向词没法塞进同一个模板, + 四种语言语序不同。参数顺序统一(本月份名/条数、对比月名/条数、|pct|)。 +- `|pct| < 0.05` 走持平,避免「多了 0.0%」。 +- 测试:`TestInsightMoMNamesBothMonths`(上升/下降/持平三分支,断言两个月份名 + + 两个条数都在句子里)、`TestMonthText`。 +- 顺手修预览测试:`GrowthMoM` 原来写死 12.3,与 `monthly` 造的数据对不上, + 而新句会同时印出两个月的条数 → 截图会显示「5620 比 4890 多了 12.3%」这种假证据。 + 改成从 `monthly[10]/monthly[9]` 算。 diff --git a/.workbuddy/memory/topics/stats.md b/.workbuddy/memory/topics/stats.md index 3976b6c..501ea7f 100644 --- a/.workbuddy/memory/topics/stats.md +++ b/.workbuddy/memory/topics/stats.md @@ -37,7 +37,11 @@ db.Analytics.SiteAnalytics(ctx, now) // 纯 SQL 聚合,无缓存 `COALESCE(answered_at, updated_at)` 回退,且首次加列时用 updated_at 冻结一次 (`backfillAnsweredAt`,靠 `Migrator().HasColumn` 判断是否刚加列)。 - 环比是「上月 vs 上上月」,不是「本月 vs 上月」:本月没过完,每月 1 号会得出 -95%。 - 句式用「上月提问量环比 %s%%」(带符号),不要写「比前一个月 -69.6%」。 + **句子必须点明月份与条数**:「9 月收到 5,620 条提问,比 8 月的 4,890 条多了 14.9%。」 + 不要写「上月提问量环比 -75.0%」——两个相对词(上月 / 环比)叠在一起,用户实测读成 + 「上月同期 vs 本月同期」。也不要用带符号的裸百分比(「环比 -75%」)当结论。 + 三套句式 `stats.insight.momUp/momDown/momFlat`(方向词进不了同一个模板,语法各语言不通); + 参数顺序统一是 本月份名/条数、对比月份名/条数、绝对值百分比,`|pct|<0.05` 走持平。 - 累计与图表是**当天重算一次**的快照(当天首访时算,重启也会重算;不是「0 点快照」), 「今日提问/回答」两项实时(60 秒 TTL,见下)。 - 页脚时间标签因此叫「快照时间」而不是「计算于」:它只覆盖累计与图表, diff --git a/internal/i18n/dict_en.go b/internal/i18n/dict_en.go index 63ac79a..2fd010a 100644 --- a/internal/i18n/dict_en.go +++ b/internal/i18n/dict_en.go @@ -974,7 +974,9 @@ var enDict = map[string]string{ "stats.insight.daily30": "%s questions in the last 30 days, about %.1f per day.", "stats.insight.peak": "Busiest day was %s with %s questions.", "stats.insight.busiest": "Questions cluster around %s %d:00.", - "stats.insight.mom": "Month-over-month change for last month: %s%%.", + "stats.insight.momDown": "%s had %s questions; %s had %s, down %.1f%%.", + "stats.insight.momFlat": "%s had %s questions; %s had %s, unchanged.", + "stats.insight.momUp": "%s had %s questions; %s had %s, up %.1f%%.", "stats.insight.fastReply": "%s%% of answered questions got a reply within 24 hours.", "stats.insight.private": "%s%% of questions were sent privately.", diff --git a/internal/i18n/dict_ja.go b/internal/i18n/dict_ja.go index bfa415a..e10eea4 100644 --- a/internal/i18n/dict_ja.go +++ b/internal/i18n/dict_ja.go @@ -973,7 +973,9 @@ var jaDict = map[string]string{ "stats.insight.daily30": "直近 30 日で %s 件の質問、1 日あたり約 %.1f 件。", "stats.insight.peak": "最も多かった日は %s で %s 件でした。", "stats.insight.busiest": "質問は %s %d 時ごろに集中しています。", - "stats.insight.mom": "先月の質問数は前月比 %s%%。", + "stats.insight.momDown": "%sは %s 件、%sは %s 件で %.1f%% 減少しました。", + "stats.insight.momFlat": "%sは %s 件、%sは %s 件で変わりません。", + "stats.insight.momUp": "%sは %s 件、%sは %s 件で %.1f%% 増加しました。", "stats.insight.fastReply": "回答済みのうち %s%% が 24 時間以内に回答されています。", "stats.insight.private": "質問の %s%% は非公開が選ばれています。", diff --git a/internal/i18n/dict_zh_cn.go b/internal/i18n/dict_zh_cn.go index 54c60ad..ce330f6 100644 --- a/internal/i18n/dict_zh_cn.go +++ b/internal/i18n/dict_zh_cn.go @@ -974,7 +974,9 @@ var zhCNDict = map[string]string{ "stats.insight.daily30": "近 30 天共收到 %s 条提问,日均 %.1f 条。", "stats.insight.peak": "最忙的一天是 %s,收到 %s 条提问。", "stats.insight.busiest": "提问最集中在%s %d 点前后。", - "stats.insight.mom": "上月提问量环比 %s%%。", + "stats.insight.momDown": "%s收到 %s 条提问,比 %s的 %s 条少了 %.1f%%。", + "stats.insight.momFlat": "%s收到 %s 条提问,与 %s的 %s 条持平。", + "stats.insight.momUp": "%s收到 %s 条提问,比 %s的 %s 条多了 %.1f%%。", "stats.insight.fastReply": "已回答的提问中,%s%% 在 24 小时内拿到了回复。", "stats.insight.private": "%s%% 的提问选择了私密(不公开)。", diff --git a/internal/i18n/dict_zh_tw.go b/internal/i18n/dict_zh_tw.go index e24a184..b759bb7 100644 --- a/internal/i18n/dict_zh_tw.go +++ b/internal/i18n/dict_zh_tw.go @@ -973,7 +973,9 @@ var zhTWDict = map[string]string{ "stats.insight.daily30": "近 30 天共收到 %s 則提問,日均 %.1f 則。", "stats.insight.peak": "最忙的一天是 %s,收到 %s 則提問。", "stats.insight.busiest": "提問最集中在%s %d 點前後。", - "stats.insight.mom": "上月提問量環比 %s%%。", + "stats.insight.momDown": "%s收到 %s 則提問,比 %s的 %s 則少了 %.1f%%。", + "stats.insight.momFlat": "%s收到 %s 則提問,與 %s的 %s 則持平。", + "stats.insight.momUp": "%s收到 %s 則提問,比 %s的 %s 則多了 %.1f%%。", "stats.insight.fastReply": "已回答的提問中,%s%% 在 24 小時內得到回覆。", "stats.insight.private": "%s%% 的提問選擇了私密(不公開)。", diff --git a/internal/stats/overview_test.go b/internal/stats/overview_test.go index 0ce5e6d..00ab77d 100644 --- a/internal/stats/overview_test.go +++ b/internal/stats/overview_test.go @@ -7,6 +7,7 @@ package stats import ( "context" "errors" + "fmt" "strings" "testing" "time" @@ -113,6 +114,55 @@ func TestOverviewDerived(t *testing.T) { require.Contains(t, strings.Join(page.Insights, "\n"), "提问最集中在周一 8 点前后。") } +// TestInsightMoMNamesBothMonths 环比句子必须点明是哪两个月、各多少条。 +// +// 旧文案「上月提问量环比 -75.0%」要读者先把「上月」翻译成月份、再把「环比」 +// 翻译成「比上上月」——两个相对词叠在一起,实测被读成「上月同期 vs 本月同期」。 +// 这里锁住:两个月份名、两个条数、方向词与百分比同号。 +func TestInsightMoMNamesBothMonths(t *testing.T) { + now := time.Now() + monthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()) + prev := monthStart.AddDate(0, -1, 0) + before := monthStart.AddDate(0, -2, 0) + + cases := []struct { + name string + cur, prev int64 + want string + }{ + {"下降", 25, 100, "少了 75.0%"}, + {"上升", 200, 100, "多了 100.0%"}, + {"持平", 100, 100, "持平"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + raw := &db.SiteAnalytics{ + TotalQuestions: 300, + Monthly: []db.CountPoint{ + {Key: before.Format("2006-01"), N: c.prev}, + {Key: prev.Format("2006-01"), N: c.cur}, + }, + } + joined := strings.Join(NewPage("zh-CN", buildOverview(raw, now)).Insights, "\n") + + // 句子里得能读出「哪个月」「多少条」,否则又回到靠脑补的老样子。 + require.Contains(t, joined, fmt.Sprintf("%d 月收到", int(prev.Month()))) + require.Contains(t, joined, fmt.Sprintf("%d 月的 %s 条", int(before.Month()), numText(c.prev))) + require.Contains(t, joined, c.want) + }) + } +} + +// TestMonthText 月份名要按语言分别处理:英文不能用「9 月」,CJK 不用 "September"。 +func TestMonthText(t *testing.T) { + require.Equal(t, "9 月", monthText("zh-CN", "2026-09")) + require.Equal(t, "9 月", monthText("zh-TW", "2026-09")) + require.Equal(t, "9 月", monthText("ja", "2026-09")) + require.Equal(t, "September", monthText("en", "2026-09")) + // 解析失败时原样返回(宁可露出原始 key,也别在句子里留空格)。 + require.Equal(t, "bad-key", monthText("zh-CN", "bad-key")) +} + // fakeAnalytics 最小实现,用来卡住缓存的边界: // 整页聚合一天只该算一次,但「今日」两项必须跟着每次请求走。 type fakeAnalytics struct { diff --git a/internal/stats/page.go b/internal/stats/page.go index 98767cd..668f885 100644 --- a/internal/stats/page.go +++ b/internal/stats/page.go @@ -7,6 +7,8 @@ package stats import ( "fmt" "math" + "strings" + "time" "github.com/tamakyi/TamaBox/internal/i18n" ) @@ -119,7 +121,27 @@ func insights(lang string, ov *Overview) []string { } if ov.HasPrevMonth { - out = append(out, t("stats.insight.mom", fmt.Sprintf("%+.1f", ov.GrowthMoM))) + // 句子必须点明是哪两个月、各多少条。 + // + // 原来写「上月提问量环比 -75.0%」:读者得自己先翻译「上月 = 9 月」、 + // 再翻译「环比 = 比 8 月」,于是很容易读成「上月同期 vs 本月同期」。 + // 两个相对词叠在一起,全靠脑补;把月份和条数写出来就没有歧义了。 + n := len(ov.Monthly) + cur, prev := ov.Monthly[n-2], ov.Monthly[n-3] + pct := math.Abs(ov.GrowthMoM) + // pct 四舍五入后是 0.0 时按持平处理:别写「多了 0.0%」。 + key := "stats.insight.momFlat" + if pct >= 0.05 { + if ov.GrowthMoM > 0 { + key = "stats.insight.momUp" + } else { + key = "stats.insight.momDown" + } + } + out = append(out, t(key, + monthText(lang, cur.Month), numText(cur.N), + monthText(lang, prev.Month), numText(prev.N), + pct)) } if ov.Answered > 0 { @@ -132,8 +154,23 @@ func insights(lang string, ov *Overview) []string { return out } -// numText 加千分位(2148 → "2,148")。 +// monthText 把 "2026-09" 变成句子里能用的月份名。 // +// 中文 / 日文用「9 月」,英文要 "September"("month 9" 不是英语的说法)。 +// 不带年份:要比较的两个月相邻,读者知道哪个月是「上个月」,年份只是噪音。 +func monthText(lang, month string) string { + m, err := time.Parse("2006-01", month) + if err != nil { + // 解析不了就把原始 key 原样带出去,总比句子里空一格强。 + return month + } + if strings.HasPrefix(lang, i18n.LangEn) { + return m.Month().String() + } + return fmt.Sprintf("%d 月", int(m.Month())) +} + +// numText 加千分位(2148 → "2,148")。 // 模板侧有同名功能的 NumInt,但它在 internal/template 里;统计层反过来依赖渲染层 // 会让依赖方向变脏,所以这里重新写一份十行的实现。 func numText(v int64) string { diff --git a/templates/zz_preview_test.go b/templates/zz_preview_test.go index 318a4f4..546692c 100644 --- a/templates/zz_preview_test.go +++ b/templates/zz_preview_test.go @@ -236,7 +236,9 @@ func writeStatsPreviews(t *testing.T, outDir string) { ActiveBoxes: 412, AnsweringBoxes: 268, TotalMessages: 8842, LastQuestionAt: "2026-10-22 23:41:07", LastAnswerAt: "2026-10-22 23:12:33", AnswerRate: 55.4, FastReplyRate: 38.6, Total30: 2148, AvgDailyLast30: 71.6, - GrowthMoM: 12.3, HasPrevMonth: true, + // 环比从预览数据算出来,不写死:句子里会同时印出两个月的条数, + // 写死的百分比和它对不上,截图就成了假证据。 + GrowthMoM: (float64(monthly[10].N) - float64(monthly[9].N)) / float64(monthly[9].N) * 100, HasPrevMonth: true, TodayQuestions: 37, TodayAnswers: 21, Daily: daily, Monthly: monthly, PeakDay: peak, DailyMax: peak.N, MonthlyMax: 6180, BusiestWD: 6, BusiestHr: 23,