From 1d33ff697632c8ab81ce2ae305ed200ef1fd2da6 Mon Sep 17 00:00:00 2001 From: hermes Date: Sun, 25 Oct 2026 23:11:12 +0800 Subject: [PATCH] =?UTF-8?q?fix(qa):=20=E6=97=A0=E6=B3=95=E5=88=A4=E5=AE=9A?= =?UTF-8?q?=E7=9A=84=E5=88=A4=E9=94=99=E7=BB=93=E8=AE=BA=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E8=AE=A1=E5=85=A5=E6=AD=A3=E7=A1=AE=E7=8E=87=E5=88=86=E6=AF=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根因为“结果不清”或未归因的判错记录移出正确率分母,并以 unjudged 单独返回,覆盖率口径不变。 统计规则版本升到 calendar-month-strict-yes-over-yes-no-v2,前端严格解析同步接受该字段。 --- api/openapi.yaml | 2 +- internal/qaopsmirror/dashboard_monthly.go | 22 +++++++++++++- .../qaopsmirror/dashboard_monthly_test.go | 30 +++++++++++++++++++ web/src/qaDashboardData.ts | 22 ++++++++++---- web/src/qaDashboardMonthly.fixture.ts | 9 ++++-- 5 files changed, 74 insertions(+), 11 deletions(-) diff --git a/api/openapi.yaml b/api/openapi.yaml index 643c313..425951c 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -125,7 +125,7 @@ "get": {"tags":["QA operations"],"operationId":"getQAOrganizationSync","summary":"管理员读取通讯录同步执行器状态及最近任务的安全汇总,不返回人员名单或运行配置","responses":{"200":{"$ref":"#/components/responses/Envelope"},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"description":"需要登录"},"403":{"description":"仅管理员"},"500":{"$ref":"#/components/responses/InternalError"},"503":{"description":"状态存储不可用,不伪装为空记录"}}}, "post": {"tags":["QA operations"],"operationId":"startQAOrganizationSync","summary":"管理员同源发起持久化通讯录同步;重复请求重放同一任务,进行中任务合并;失败保留现有快照","parameters":[{"$ref":"#/components/parameters/IdempotencyKey"},{"name":"X-Governance-Action","in":"header","required":true,"schema":{"const":"qa-organization"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","additionalProperties":false}}}},"responses":{"202":{"$ref":"#/components/responses/OperationEnvelope"},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"description":"需要登录"},"403":{"description":"仅管理员同源操作"},"409":{"description":"操作标识与原请求冲突"},"500":{"$ref":"#/components/responses/InternalError"},"503":{"description":"同步未接线、执行器离线或入队失败;未创建新任务"}}} }, - "/api/v1/qa-ops/dashboard": {"post":{"operationId":"readQAOperationsDashboard","tags":["QA operations"],"summary":"管理员只读正式总表一致性看板:累计数量、自然月质量及区域统计;跨月周展示上月月报","description":"同一只读可重复读事务读取独立正式总表有效字段、质量及组织版本。不访问源端,不冻结或写入指标;区域分母为当前配置通讯录去重人数而非在职/绩效名单。冻结记录按保存的原始标量分子分母及问题文字支持字面关键词筛选,不用当前评估回填历史。reported_weeks 独立返回用户提供的历史百分比(QAReportedHistoricalRate),没有分子分母或冻结时间;只含所选截止前已结束周,关键词非空时为空,原冻结记录与当前指标保持不变。cumulative_weeks 独立返回2026WK40–WK43的master_live_cumulative_v1实时累计曲线,policy_version为weekly-strict-yes-over-yes-no-v1:各周成员为整张总表中提问时间早于北京时间下周一的记录,当前周截止min(查询end,事务当前时点),不被查询start截断;字段取当前有效结论,补值和后续点踩会追溯更新相关累计周。覆盖率与正确率分别按是÷(是+否),待确认/部分覆盖及其他值不入严格分母,零分母percent=null;各点保留成员数、两组分子分母、周边界、实际截止和计算时点。仅查询治理本地总表,不写历史上报/冻结证据。新增 quality_period(统计月、月初、实际截止、计算时点及规则)和 monthly_quality(该月内成员数、独立覆盖及正确率分子分母);以所选周周一所在月为质量归属月,跨月周截止该月下月1日,非跨月周截止查询end,均不超过事务当前时点。calendar_months 返回从首个总表数据月起逐自然月独立计算的月点,policy_version=calendar-month-strict-yes-over-yes-no-v1,source=master_live_calendar_month_v1;每月重置,使用当前有效字段,待确认等排除,零分母null,空月保留,最后点与仪表使用相同截止。旧 reported_weeks、frozen_weeks、cumulative_weeks 保留但不混画到月轴;不平均旧周百分比。网页30秒顺序刷新,已闭查询周不推进到后周。 可选filters对象传递与列表相同的五项字段精确筛选;统计只使用匹配当前有效标签的记录,无明细的上报/冻结历史在字段筛选下不挪用。","parameters":[{"name":"X-Governance-Action","in":"header","required":true,"schema":{"const":"qa-dashboard"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","additionalProperties":false,"required":["start","end"],"properties":{"start":{"type":"string","format":"date-time"},"end":{"type":"string","format":"date-time"},"keyword":{"type":"string","maxLength":200},"filters":{"type":"object","additionalProperties":false,"properties":{"coverage":{"type":"string","maxLength":200},"is_correct":{"type":"string","maxLength":200},"product_category":{"type":"string","maxLength":200},"problem_type":{"type":"string","maxLength":200},"root_cause":{"type":"string","maxLength":200}}}}}}}},"responses":{"200":{"$ref":"#/components/responses/Envelope"},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"description":"需要登录"},"403":{"description":"仅管理员同源读取"},"409":{"description":"质量输入指纹变化,拒绝混合数据"},"500":{"$ref":"#/components/responses/InternalError"},"503":{"description":"本地副本或一致性投影不可用,不返回伪零"}}}}, + "/api/v1/qa-ops/dashboard": {"post":{"operationId":"readQAOperationsDashboard","tags":["QA operations"],"summary":"管理员只读正式总表一致性看板:累计数量、自然月质量及区域统计;跨月周展示上月月报","description":"同一只读可重复读事务读取独立正式总表有效字段、质量及组织版本。不访问源端,不冻结或写入指标;区域分母为当前配置通讯录去重人数而非在职/绩效名单。冻结记录按保存的原始标量分子分母及问题文字支持字面关键词筛选,不用当前评估回填历史。reported_weeks 独立返回用户提供的历史百分比(QAReportedHistoricalRate),没有分子分母或冻结时间;只含所选截止前已结束周,关键词非空时为空,原冻结记录与当前指标保持不变。cumulative_weeks 独立返回2026WK40–WK43的master_live_cumulative_v1实时累计曲线,policy_version为weekly-strict-yes-over-yes-no-v1:各周成员为整张总表中提问时间早于北京时间下周一的记录,当前周截止min(查询end,事务当前时点),不被查询start截断;字段取当前有效结论,补值和后续点踩会追溯更新相关累计周。覆盖率与正确率分别按是÷(是+否),待确认/部分覆盖及其他值不入严格分母,零分母percent=null;各点保留成员数、两组分子分母、周边界、实际截止和计算时点。仅查询治理本地总表,不写历史上报/冻结证据。新增 quality_period(统计月、月初、实际截止、计算时点及规则)和 monthly_quality(该月内成员数、独立覆盖及正确率分子分母);以所选周周一所在月为质量归属月,跨月周截止该月下月1日,非跨月周截止查询end,均不超过事务当前时点。calendar_months 返回从首个总表数据月起逐自然月独立计算的月点,policy_version=calendar-month-strict-yes-over-yes-no-v2(无法判定不计为错误:根因为“结果不清”或未归因的判错记录移出正确率分母,并以 unjudged 单独返回,覆盖率口径不变),source=master_live_calendar_month_v1;每月重置,使用当前有效字段,待确认等排除,零分母null,空月保留,最后点与仪表使用相同截止。旧 reported_weeks、frozen_weeks、cumulative_weeks 保留但不混画到月轴;不平均旧周百分比。网页30秒顺序刷新,已闭查询周不推进到后周。 可选filters对象传递与列表相同的五项字段精确筛选;统计只使用匹配当前有效标签的记录,无明细的上报/冻结历史在字段筛选下不挪用。","parameters":[{"name":"X-Governance-Action","in":"header","required":true,"schema":{"const":"qa-dashboard"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","additionalProperties":false,"required":["start","end"],"properties":{"start":{"type":"string","format":"date-time"},"end":{"type":"string","format":"date-time"},"keyword":{"type":"string","maxLength":200},"filters":{"type":"object","additionalProperties":false,"properties":{"coverage":{"type":"string","maxLength":200},"is_correct":{"type":"string","maxLength":200},"product_category":{"type":"string","maxLength":200},"problem_type":{"type":"string","maxLength":200},"root_cause":{"type":"string","maxLength":200}}}}}}}},"responses":{"200":{"$ref":"#/components/responses/Envelope"},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"description":"需要登录"},"403":{"description":"仅管理员同源读取"},"409":{"description":"质量输入指纹变化,拒绝混合数据"},"500":{"$ref":"#/components/responses/InternalError"},"503":{"description":"本地副本或一致性投影不可用,不返回伪零"}}}}, "/api/v1/qa-ops/organization": { "get": { "tags": ["QA operations"], diff --git a/internal/qaopsmirror/dashboard_monthly.go b/internal/qaopsmirror/dashboard_monthly.go index a330990..a7ee9d1 100644 --- a/internal/qaopsmirror/dashboard_monthly.go +++ b/internal/qaopsmirror/dashboard_monthly.go @@ -2,11 +2,12 @@ package qaopsmirror import ( "knowledge-governance/internal/qaopsquality" + "strings" "time" ) const dashboardMonthlySource = "master_live_calendar_month_v1" -const dashboardMonthlyPolicy = "calendar-month-strict-yes-over-yes-no-v1" +const dashboardMonthlyPolicy = "calendar-month-strict-yes-over-yes-no-v2" type DashboardQualityPeriod struct { Month string `json:"month"` @@ -20,6 +21,7 @@ type DashboardStrictQuality struct { Records int `json:"records"` Coverage qaopsquality.Rate `json:"coverage"` Correctness qaopsquality.Rate `json:"correctness"` + Unjudged int `json:"unjudged"` } type DashboardMonth struct { Month string `json:"month"` @@ -52,6 +54,14 @@ func dashboardStrictQuality(records []dashboardTimedQuality, start, end time.Tim case "uncovered": uncovered++ } + // An answer whose correctness cannot be established is not a wrong answer: + // "结果不清" and a missing root cause both mean the judge could not attribute a + // concrete defect, so they leave the correctness denominator and are surfaced + // separately instead of silently inflating the error count. + if dashboardUnjudged(r.view) { + out.Unjudged++ + continue + } switch r.view.Effective.Correctness { case "correct": correct++ @@ -64,6 +74,16 @@ func dashboardStrictQuality(records []dashboardTimedQuality, start, end time.Tim return out } +func dashboardUnjudged(v qaopsquality.View) bool { + if v.State == "stale" || v.Effective.Correctness != "incorrect" { + return false + } + cause := strings.TrimSpace(v.Effective.RootCause) + return cause == "" || cause == dashboardUnclearCause +} + +const dashboardUnclearCause = "结果不清" + func dashboardCalendarMonths(records []dashboardTimedQuality, end, now time.Time) []DashboardMonth { out := []DashboardMonth{} cutoff := end diff --git a/internal/qaopsmirror/dashboard_monthly_test.go b/internal/qaopsmirror/dashboard_monthly_test.go index cf0ca58..256ceb3 100644 --- a/internal/qaopsmirror/dashboard_monthly_test.go +++ b/internal/qaopsmirror/dashboard_monthly_test.go @@ -1,6 +1,7 @@ package qaopsmirror import ( + "knowledge-governance/internal/qaopsquality" "testing" "time" ) @@ -31,3 +32,32 @@ func TestQualityMonthCrossingWeekIsPreviousMonthReport(t *testing.T) { }) } } + +func TestStrictQualityLeavesUnjudgeableAnswersOutOfTheCorrectnessDenominator(t *testing.T) { + start := monthlyTestTime(t, "2026-10-01T00:00:00+08:00") + end := monthlyTestTime(t, "2026-11-01T00:00:00+08:00") + at := start.Add(time.Hour) + view := func(coverage, correctness, cause string) qaopsquality.View { + return qaopsquality.View{State: "unreviewed", Effective: qaopsquality.Fields{Coverage: coverage, Correctness: correctness, RootCause: cause}} + } + records := []dashboardTimedQuality{ + {id: "correct", at: at, view: view("covered", "correct", "")}, + {id: "real-error", at: at, view: view("covered", "incorrect", "答案生成错误")}, + {id: "unclear", at: at, view: view("covered", "incorrect", dashboardUnclearCause)}, + {id: "unlabelled", at: at, view: view("covered", "incorrect", "")}, + {id: "pending", at: at, view: view("uncovered", "pending", "")}, + } + got := dashboardStrictQuality(records, start, end) + if got.Correctness.Numerator != 1 || got.Correctness.Denominator != 2 { + t.Fatalf("correctness = %d/%d, want 1/2 (only genuinely judged answers)", got.Correctness.Numerator, got.Correctness.Denominator) + } + if got.Unjudged != 2 { + t.Fatalf("unjudged = %d, want 2 (结果不清 + missing root cause)", got.Unjudged) + } + if got.Coverage.Numerator != 4 || got.Coverage.Denominator != 5 { + t.Fatalf("coverage = %d/%d, want 4/5 unchanged by the correctness rule", got.Coverage.Numerator, got.Coverage.Denominator) + } + if dashboardMonthlyPolicy != "calendar-month-strict-yes-over-yes-no-v2" { + t.Fatal("policy version must change when the denominator changes") + } +} diff --git a/web/src/qaDashboardData.ts b/web/src/qaDashboardData.ts index 2a6b1c1..b3a8dc1 100644 --- a/web/src/qaDashboardData.ts +++ b/web/src/qaDashboardData.ts @@ -40,6 +40,7 @@ export interface DashboardStrictQuality { records: number coverage: QualityRate correctness: QualityRate + unjudged: number } export interface DashboardQualityPeriod { month: string @@ -47,7 +48,7 @@ export interface DashboardQualityPeriod { cutoff_at: string calculated_at: string source: 'master_live_calendar_month_v1' - policy_version: 'calendar-month-strict-yes-over-yes-no-v1' + policy_version: 'calendar-month-strict-yes-over-yes-no-v2' } export interface DashboardMonth extends DashboardStrictQuality { month: string @@ -56,7 +57,7 @@ export interface DashboardMonth extends DashboardStrictQuality { cutoff_at: string calculated_at: string source: 'master_live_calendar_month_v1' - policy_version: 'calendar-month-strict-yes-over-yes-no-v1' + policy_version: 'calendar-month-strict-yes-over-yes-no-v2' } export interface DashboardRegion { id: string @@ -335,7 +336,7 @@ export function parseQADashboard(value: unknown): QADashboardData { let monthly_quality: DashboardStrictQuality | undefined let calendar_months: DashboardMonth[] | undefined const monthlySource = 'master_live_calendar_month_v1' as const, - monthlyPolicy = 'calendar-month-strict-yes-over-yes-no-v1' as const, + monthlyPolicy = 'calendar-month-strict-yes-over-yes-no-v2' as const, nextMonth = (at: number) => { const d = new Date(at + offset) return Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 1) - offset @@ -343,10 +344,17 @@ export function parseQADashboard(value: unknown): QADashboardData { monthLabel = (at: number) => new Date(at + offset).toISOString().slice(0, 7), strictRates = (raw: Record): DashboardStrictQuality => { const records = count(raw.records), + unjudged = count(raw.unjudged), coverage = parseQualityRate(raw.coverage), correctness = parseQualityRate(raw.correctness) - if (records > total_issues || coverage.denominator > records || correctness.denominator > records) return bad() - return { records, coverage, correctness } + if ( + records > total_issues || + unjudged > records || + coverage.denominator > records || + correctness.denominator + unjudged > records + ) + return bad() + return { records, coverage, correctness, unjudged } } if ( v.quality_period !== undefined || @@ -374,7 +382,8 @@ export function parseQADashboard(value: unknown): QADashboardData { ) return bad() const rawQuality = obj(v.monthly_quality) - if (Object.keys(rawQuality).some((key) => !['records', 'coverage', 'correctness'].includes(key))) return bad() + if (Object.keys(rawQuality).some((key) => !['records', 'unjudged', 'coverage', 'correctness'].includes(key))) + return bad() quality_period = { month: text(p.month), start: begin, @@ -415,6 +424,7 @@ export function parseQADashboard(value: unknown): QADashboardData { 'cutoff_at', 'calculated_at', 'records', + 'unjudged', 'coverage', 'correctness', 'source', diff --git a/web/src/qaDashboardMonthly.fixture.ts b/web/src/qaDashboardMonthly.fixture.ts index 09a7428..769cb49 100644 --- a/web/src/qaDashboardMonthly.fixture.ts +++ b/web/src/qaDashboardMonthly.fixture.ts @@ -7,12 +7,13 @@ export const calendarDashboardFixture = { cutoff_at: dashboardFixture.end, calculated_at: dashboardFixture.as_of, source: 'master_live_calendar_month_v1', - policy_version: 'calendar-month-strict-yes-over-yes-no-v1', + policy_version: 'calendar-month-strict-yes-over-yes-no-v2', }, monthly_quality: { records: 3, coverage: { numerator: 2, denominator: 3, percent: 200 / 3 }, correctness: { numerator: 1, denominator: 2, percent: 50 }, + unjudged: 0, }, calendar_months: [ { @@ -24,8 +25,9 @@ export const calendarDashboardFixture = { records: 1, coverage: { numerator: 0, denominator: 1, percent: 0 }, correctness: { numerator: 0, denominator: 0, percent: null }, + unjudged: 0, source: 'master_live_calendar_month_v1', - policy_version: 'calendar-month-strict-yes-over-yes-no-v1', + policy_version: 'calendar-month-strict-yes-over-yes-no-v2', }, { month: '2026-10', @@ -36,8 +38,9 @@ export const calendarDashboardFixture = { records: 3, coverage: { numerator: 2, denominator: 3, percent: 200 / 3 }, correctness: { numerator: 1, denominator: 2, percent: 50 }, + unjudged: 0, source: 'master_live_calendar_month_v1', - policy_version: 'calendar-month-strict-yes-over-yes-no-v1', + policy_version: 'calendar-month-strict-yes-over-yes-no-v2', }, ], }