From 828c2c5fa0d4e3e0eee3987085a4d782872f7e95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A7=9A=E7=AB=A0=E6=B5=A9?= Date: Sun, 18 Oct 2026 02:00:31 +0800 Subject: [PATCH] =?UTF-8?q?feat(qa-ops):=20=E6=8C=89=E7=A1=AE=E8=AE=A4?= =?UTF-8?q?=E5=8F=A3=E5=BE=84=E8=A1=A5=E9=BD=90=E8=B4=A8=E9=87=8F=E6=AF=94?= =?UTF-8?q?=E4=BE=8B=E4=B8=8E=E5=88=86=E6=AF=8D=E6=98=8E=E7=BB=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 使用完整筛选范围的当前有效结论计算覆盖率和正确率,人工优先,失效与对应待确认/未评估/不计入排除;零分母返回null,界面展示原始分子分母和排除构成,前端拒绝不守恒或比例不一致的响应。 验证:make verify退出码0,Go658及前端257通过,91项声明可选集成跳过;隔离PG、三包竞态及合成/真实只读源浏览器验证通过。无新增迁移,不接AI模型,不改绩效,不写源库;本批未推送或部署,8088运行版保持0.1.21。 --- CHANGELOG.md | 4 +- api/openapi.yaml | 2 +- internal/httpapi/qa_quality_test.go | 8 +++ internal/qaopsquality/projection.go | 2 +- internal/qaopsquality/quality.go | 13 ++--- internal/qaopsquality/rates.go | 72 +++++++++++++++++++++++++++ internal/qaopsquality/rates_test.go | 44 +++++++++++++++++ web/src/QAQuality.test.tsx | 53 ++++++++++++++++++++ web/src/QAQuality.tsx | 41 +++++++++++++++- web/src/qaOps.css | 14 ++++++ web/src/qaQuality.fixture.ts | 27 ++++++++++- web/src/qaQuality.test.ts | 23 +++++++++ web/src/qaQuality.ts | 75 +++++++++++++++++++++++++++++ 13 files changed, 367 insertions(+), 11 deletions(-) create mode 100644 internal/qaopsquality/rates.go create mode 100644 internal/qaopsquality/rates_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e85801..3539a56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,10 @@ ### Added +- 问答运营按已确认口径显示知识覆盖率、回答正确率及原始分子/分母、排除数量与构成。使用完整筛选范围和当前有效人工优先结论,精确规则命中的未覆盖可进入覆盖分母;部分覆盖不算完全覆盖,待确认/未评估/失效/不计入按对应指标排除,零分母显示“暂无可计算数据”。返回独立规则版本,AI 模型与绩效范围仍未接入。 + - 问答详情支持治理侧人工复核及不可变审计:覆盖、正确性、产品分类、问题类型、根因与理由由管理员明确填写,操作人取登录身份;保存后独立读回复核历史。保留源端判断和自动规则,输入变化使旧人工结论失效,已观测的内容回退不自动恢复旧结论;并发修改冲突、重复请求不重复留档。 -- 新增覆盖复检历史、已采集输入快照和完整筛选范围质量数量。批量统计只传指纹与规则命中元数据,原文在读取问答/复核时采集,未采集版本明确提示而非伪造空快照;源事务时点阻止延迟旧扫描回退新输入。仅提供数量,不接 AI 模型、不定义质量率或绩效分母、不回写 DobotAI 原问答。 +- 新增覆盖复检历史、已采集输入快照和完整筛选范围质量数量。批量统计只传指纹与规则命中元数据,原文在读取问答/复核时采集,未采集版本明确提示而非伪造空快照;源事务时点阻止延迟旧扫描回退新输入。基础数量与本期经确认新增的质量比例分开显示;不接 AI 模型、不定义绩效分母、不回写 DobotAI 原问答。 - 「使用统计 → 个人月明细」按北京时间自然月及截止时点展示当前应用可见名册的人员用量(包含本源零记录),支持部门含子部门、区域/待核对状态、姓名/完整部门路径和已有提问/零记录筛选。稳定身份关联去重、不可关联问答单列、过期组织版本阻断翻页;源端姓名可缺失并提示重新同步,不按姓名匹配身份。姓名筛选通过有界只读 POST 请求体传递、不进入 URL,原始员工标识不返回浏览器;名册不作为在职/考核分母,当月进度不作为绩效结论。 diff --git a/api/openapi.yaml b/api/openapi.yaml index c931a07..7877a12 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -101,7 +101,7 @@ }, "/api/v1/qa-ops/usage/people": {"post": {"tags": ["QA operations"], "operationId": "queryQAOperationsMonthlyPeople", "summary": "按北京时间自然月列出当前名册人员的2.0用量(含零记录),仅管理员,不返回原始员工标识,不作为在职/考核名单", "responses": {"200": {"$ref": "#/components/responses/Envelope"}, "400": {"$ref": "#/components/responses/BadRequest"}, "401": {"description": "需要登录"}, "403": {"description": "仅管理员访问"}, "503": {"description": "独立只读源或治理组织存储不可用,不返回伪造的零值"}, "500": {"$ref": "#/components/responses/InternalError"}, "409": {"description": "完整名册缺失或目录/区域版本变化,请同步/重新查询,不返回伪零"}}, "description": "只读查询,不写治理库或生产库;姓名筛选置于 JSON 请求体,不进入 URL;整名册指标不随人员筛选变化。第2页起须带首页目录、区域版本及 end 截止。", "requestBody": {"required": true, "content": {"application/json": {"schema": {"type": "object", "additionalProperties": false, "required": ["month"], "properties": {"month": {"type": "string", "pattern": "^(?!0000)[0-9]{4}-(0[1-9]|1[0-2])$"}, "cutoff": {"type": "string", "format": "date-time"}, "region": {"type": "string", "enum": ["国内工业", "APAC", "EMEA", "America", "unassigned", "cross_region"]}, "department_id": {"type": "string"}, "activity": {"type": "string", "enum": ["all", "used", "zero"]}, "search": {"type": "string", "maxLength": 100}, "page": {"type": "integer", "minimum": 1, "maximum": 100000, "default": 1}, "limit": {"type": "integer", "minimum": 1, "maximum": 100, "default": 30}, "directory_id": {"type": "string"}, "revision_id": {"type": "string"}}}}}}, "parameters": [{"name": "X-Governance-Action", "in": "header", "required": true, "schema": {"type": "string", "enum": ["qa-usage-read"]}}]}}, "/api/v1/qa-ops/usage": {"get": {"tags": ["QA operations"], "operationId": "getQAOperationsUsage", "summary": "完整筛选范围内按当前通讯录和区域版本回看2.0问答使用;固定租户、管理员访问,不输出绩效结论", "parameters": [{"name": "start", "in": "query", "required": true, "schema": {"type": "string", "format": "date-time"}}, {"name": "end", "in": "query", "required": true, "schema": {"type": "string", "format": "date-time"}}, {"name": "keyword", "in": "query", "schema": {"type": "string", "maxLength": 200}}], "responses": {"200": {"$ref": "#/components/responses/Envelope"}, "400": {"$ref": "#/components/responses/BadRequest"}, "401": {"description": "需要登录"}, "403": {"description": "仅管理员访问"}, "503": {"description": "独立只读源或治理组织存储不可用,不返回伪造的零值"}, "500": {"$ref": "#/components/responses/InternalError"}}}}, - "/api/v1/qa-ops/quality/query": {"post": {"operationId": "queryQAQualityCounts", "summary": "按完整时间/原问题字面关键词读取指纹及规则命中元数据并留档统计,原文仅在读取问答/复核时采集;不计算质量率,最多5000条", "parameters": [{"name": "X-Governance-Action", "in": "header", "required": true, "schema": {"type": "string", "enum": ["qa-quality"]}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"type": "object", "additionalProperties": false, "properties": {"start": {"type": "string", "format": "date-time"}, "end": {"type": "string", "format": "date-time"}, "keyword": {"type": "string", "maxLength": 200}}, "required": ["start", "end"]}}}}, "tags": ["QA operations"], "responses": {"200": {"$ref": "#/components/responses/Envelope"}, "400": {"$ref": "#/components/responses/BadRequest"}, "401": {"description": "需要登录"}, "403": {"description": "仅管理员访问"}, "503": {"description": "独立只读源或治理组织存储不可用,不返回伪造的零值"}, "500": {"$ref": "#/components/responses/InternalError"}, "409": {"description": "输入或复核版本变化/重复操作载荷不一致,未覆盖原结论"}, "404": {"description": "当前授权范围内未找到记录/历史输入"}}}}, + "/api/v1/qa-ops/quality/query": {"post": {"operationId": "queryQAQualityCounts", "summary": "按完整筛选范围读取指纹/规则元数据并留档统计,最多5000条;summary.rates 返回 quality-rates-human-first-v1、原始分子/分母、百分比与分母构成。覆盖率=covered/(covered+uncovered+partial),正确率=covered_correct/(covered_correct+covered_incorrect);失效/不计入/相应待确认与未评估排除,零分母percent=null。原文仅在读取问答/复核时采集", "parameters": [{"name": "X-Governance-Action", "in": "header", "required": true, "schema": {"type": "string", "enum": ["qa-quality"]}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"type": "object", "additionalProperties": false, "properties": {"start": {"type": "string", "format": "date-time"}, "end": {"type": "string", "format": "date-time"}, "keyword": {"type": "string", "maxLength": 200}}, "required": ["start", "end"]}}}}, "tags": ["QA operations"], "responses": {"200": {"$ref": "#/components/responses/Envelope"}, "400": {"$ref": "#/components/responses/BadRequest"}, "401": {"description": "需要登录"}, "403": {"description": "仅管理员访问"}, "503": {"description": "独立只读源或治理组织存储不可用,不返回伪造的零值"}, "500": {"$ref": "#/components/responses/InternalError"}, "409": {"description": "输入或复核版本变化/重复操作载荷不一致,未覆盖原结论"}, "404": {"description": "当前授权范围内未找到记录/历史输入"}}}}, "/api/v1/qa-ops/records/{id}/reviews": {"post": {"operationId": "saveQAQualityReview", "summary": "治理库追加人工复核,登录身份审计,源端只读,输入/复核乐观锁和nonce幂等;正确时根因为空,不计入须说明原因", "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string"}}, {"name": "X-Governance-Action", "in": "header", "required": true, "schema": {"type": "string", "enum": ["qa-quality"]}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"type": "object", "additionalProperties": false, "properties": {"input_hash": {"type": "string", "pattern": "^[a-f0-9]{64}$"}, "input_epoch": {"type": "integer", "minimum": 1}, "base_revision": {"type": "integer", "minimum": 0}, "nonce": {"type": "string", "pattern": "^[A-Za-z0-9_-]{16,128}$"}, "fields": {"type": "object", "additionalProperties": false, "properties": {"coverage": {"type": "string", "enum": ["covered", "uncovered", "partial", "pending", "excluded"]}, "correctness": {"type": "string", "enum": ["correct", "incorrect", "pending"]}, "product_category": {"type": "string", "maxLength": 128}, "problem_type": {"type": "string", "maxLength": 128}, "root_cause": {"type": "string", "maxLength": 2000}, "exclusion_reason": {"type": "string", "maxLength": 2000}}, "required": ["coverage", "correctness"]}, "reason": {"type": "string", "minLength": 1, "maxLength": 4000}}, "required": ["input_hash", "input_epoch", "base_revision", "nonce", "fields", "reason"]}}}}, "tags": ["QA operations"], "responses": {"200": {"$ref": "#/components/responses/Envelope"}, "400": {"$ref": "#/components/responses/BadRequest"}, "401": {"description": "需要登录"}, "403": {"description": "仅管理员访问"}, "503": {"description": "独立只读源或治理组织存储不可用,不返回伪造的零值"}, "500": {"$ref": "#/components/responses/InternalError"}, "409": {"description": "输入或复核版本变化/重复操作载荷不一致,未覆盖原结论"}, "404": {"description": "当前授权范围内未找到记录/历史输入"}}}}, "/api/v1/qa-ops/records/{id}/quality/history": {"get": {"operationId": "listQAQualityHistory", "summary": "按当前固定租户查询不可变复检/人工历史,分别倒序分页,各20条", "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string"}}, {"name": "review_before", "in": "query", "schema": {"type": "integer", "minimum": 1}}, {"name": "check_before", "in": "query", "schema": {"type": "integer", "minimum": 1}}], "tags": ["QA operations"], "responses": {"200": {"$ref": "#/components/responses/Envelope"}, "400": {"$ref": "#/components/responses/BadRequest"}, "401": {"description": "需要登录"}, "403": {"description": "仅管理员访问"}, "503": {"description": "独立只读源或治理组织存储不可用,不返回伪造的零值"}, "500": {"$ref": "#/components/responses/InternalError"}, "409": {"description": "输入或复核版本变化/重复操作载荷不一致,未覆盖原结论"}, "404": {"description": "当前授权范围内未找到记录/历史输入"}}}}, "/api/v1/qa-ops/records/{id}/quality/inputs/{epoch}": {"get": {"operationId": "getQAQualityInputSnapshot", "summary": "管理员读取已采集的历史质量输入快照,不另存人员关联/反馈字段;元数据留档但未采集原文返回409,不编造空快照", "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string"}}, {"name": "epoch", "in": "path", "required": true, "schema": {"type": "integer", "minimum": 1}}], "tags": ["QA operations"], "responses": {"200": {"$ref": "#/components/responses/Envelope"}, "400": {"$ref": "#/components/responses/BadRequest"}, "401": {"description": "需要登录"}, "403": {"description": "仅管理员访问"}, "503": {"description": "独立只读源或治理组织存储不可用,不返回伪造的零值"}, "500": {"$ref": "#/components/responses/InternalError"}, "409": {"description": "该输入版本仅有指纹/规则元数据、原文未采集"}, "404": {"description": "当前授权范围内未找到记录/历史输入"}}}}, diff --git a/internal/httpapi/qa_quality_test.go b/internal/httpapi/qa_quality_test.go index c8395ab..32f909b 100644 --- a/internal/httpapi/qa_quality_test.go +++ b/internal/httpapi/qa_quality_test.go @@ -117,6 +117,14 @@ func TestQAQualityDetailListAndWholeScopeCounts(t *testing.T) { if w.Code != 200 || !strings.Contains(w.Body.String(), `"unreviewed":2`) || source.scopeCalls != 1 || source.tenant != "fixed" || store.calls < 2 { t.Fatal("quality summary estimated from page instead of scope", w.Code, w.Body.String()) } + var counts struct { + Data struct { + Summary qaopsquality.Summary `json:"summary"` + } `json:"data"` + } + if json.Unmarshal(w.Body.Bytes(), &counts) != nil || counts.Data.Summary.Rates.Coverage.Denominator != 2 || counts.Data.Summary.Rates.Coverage.Numerator != 0 || counts.Data.Summary.Rates.Correctness.Percent != nil { + t.Fatal("confirmed quality metrics were not computed for the whole scope") + } store.fail = true w = qualityRequest(h, "GET", "/api/v1/qa-ops/records/fixture-turn", "quality-admin", "") if w.Code != 503 || !strings.Contains(w.Body.String(), "quality_unavailable") { diff --git a/internal/qaopsquality/projection.go b/internal/qaopsquality/projection.go index b6e6392..0a1d407 100644 --- a/internal/qaopsquality/projection.go +++ b/internal/qaopsquality/projection.go @@ -104,7 +104,7 @@ func Project(hash string, epoch, revision int64, automatic Check, review *Review return view } func Summarize(views map[string]View) Summary { - result := Summary{Total: len(views)} + result := Summary{Total: len(views), Rates: ComputeRates(views)} for _, v := range views { switch v.State { case "reviewed": diff --git a/internal/qaopsquality/quality.go b/internal/qaopsquality/quality.go index 0344d0f..c244034 100644 --- a/internal/qaopsquality/quality.go +++ b/internal/qaopsquality/quality.go @@ -64,12 +64,13 @@ type History struct { NextCheck int64 `json:"next_check"` } type Summary struct { - Total int `json:"total"` - Reviewed int `json:"reviewed"` - Unreviewed int `json:"unreviewed"` - Stale int `json:"stale"` - PendingConfirmation int `json:"pending_confirmation"` - RuleUncovered int `json:"rule_uncovered"` + Rates Rates `json:"rates"` + Total int `json:"total"` + Reviewed int `json:"reviewed"` + Unreviewed int `json:"unreviewed"` + Stale int `json:"stale"` + PendingConfirmation int `json:"pending_confirmation"` + RuleUncovered int `json:"rule_uncovered"` } type Receipt struct { View View `json:"view"` diff --git a/internal/qaopsquality/rates.go b/internal/qaopsquality/rates.go new file mode 100644 index 0000000..8bb2283 --- /dev/null +++ b/internal/qaopsquality/rates.go @@ -0,0 +1,72 @@ +package qaopsquality + +type Rate struct { + Numerator int `json:"numerator"` + Denominator int `json:"denominator"` + Percent *float64 `json:"percent"` +} +type RateBreakdown struct { + Covered int `json:"covered"` + Uncovered int `json:"uncovered"` + Partial int `json:"partial"` + Pending int `json:"pending"` + Unassessed int `json:"unassessed"` + Excluded int `json:"excluded"` + Stale int `json:"stale"` + CoveredCorrect int `json:"covered_correct"` + CoveredIncorrect int `json:"covered_incorrect"` + CoveredPending int `json:"covered_pending"` + CoveredUnassessed int `json:"covered_unassessed"` +} +type Rates struct { + PolicyVersion string `json:"policy_version"` + Coverage Rate `json:"coverage"` + Correctness Rate `json:"correctness"` + Breakdown RateBreakdown `json:"breakdown"` +} + +func ComputeRates(views map[string]View) Rates { + out := Rates{PolicyVersion: "quality-rates-human-first-v1"} + b := &out.Breakdown + for _, v := range views { + if v.State == "stale" { + b.Stale++ + continue + } + switch v.Effective.Coverage { + case "covered": + b.Covered++ + switch v.Effective.Correctness { + case "correct": + b.CoveredCorrect++ + case "incorrect": + b.CoveredIncorrect++ + case "pending": + b.CoveredPending++ + default: + b.CoveredUnassessed++ + } + case "uncovered": + b.Uncovered++ + case "partial": + b.Partial++ + case "pending": + b.Pending++ + case "excluded": + b.Excluded++ + default: + b.Unassessed++ + } + } + out.Coverage = makeRate(b.Covered, b.Covered+b.Uncovered+b.Partial) + out.Correctness = makeRate(b.CoveredCorrect, b.CoveredCorrect+b.CoveredIncorrect) + return out +} +func makeRate(numerator, denominator int) Rate { + r := Rate{Numerator: numerator, Denominator: denominator} + if denominator > 0 { + p := 100 * float64(numerator) / float64(denominator) + r.Percent = &p + } + return r +} diff --git a/internal/qaopsquality/rates_test.go b/internal/qaopsquality/rates_test.go new file mode 100644 index 0000000..e4875e7 --- /dev/null +++ b/internal/qaopsquality/rates_test.go @@ -0,0 +1,44 @@ +package qaopsquality + +import ( + "encoding/json" + "knowledge-governance/internal/dobotaiops" + "math" + "strings" + "testing" +) + +func TestQualityRatesUseEffectiveCurrentConclusionsAndExplicitDenominators(t *testing.T) { + views := map[string]View{} + for i, fields := range []Fields{{Coverage: "covered", Correctness: "correct"}, {Coverage: "covered", Correctness: "incorrect"}, {Coverage: "covered", Correctness: "pending"}, {Coverage: "uncovered"}, {Coverage: "partial", Correctness: "correct"}, {Coverage: "pending", Correctness: "correct"}, {}, {Coverage: "excluded", Correctness: "pending"}, {Coverage: "covered", Correctness: "correct"}, {Coverage: "covered"}} { + state := "reviewed" + if i == 3 || i == 6 { + state = "unreviewed" + } + if i == 8 { + state = "stale" + } + views[string(rune('a'+i))] = View{State: state, Effective: fields, Automatic: Check{Result: dobotaiops.CheckAnswerCoverage("暂无已发布")}} + } + r := ComputeRates(views) + if r.PolicyVersion != "quality-rates-human-first-v1" || r.Coverage.Numerator != 4 || r.Coverage.Denominator != 6 || r.Correctness.Numerator != 1 || r.Correctness.Denominator != 2 { + t.Fatal("effective decisions, partial or exclusion scopes incorrect", r) + } + if r.Coverage.Percent == nil || math.Abs(*r.Coverage.Percent-100*float64(4)/6) > 1e-10 || r.Correctness.Percent == nil || *r.Correctness.Percent != 50 { + t.Fatal("ratio value disagrees with raw counts") + } + b := r.Breakdown + if b.Covered+b.Uncovered+b.Partial+b.Pending+b.Unassessed+b.Excluded+b.Stale != len(views) || b.CoveredCorrect+b.CoveredIncorrect+b.CoveredPending+b.CoveredUnassessed != b.Covered { + t.Fatal("denominator breakdown not conserved") + } + if got := Summarize(views); got.Rates.Coverage.Denominator != 6 { + t.Fatal("scope summary did not consume the one shared calculation") + } +} +func TestQualityRatesEmptyScopeHasNullPercent(t *testing.T) { + r := ComputeRates(map[string]View{}) + raw, _ := json.Marshal(r) + if r.Coverage.Percent != nil || r.Correctness.Percent != nil || strings.Count(string(raw), `"percent":null`) != 2 { + t.Fatal("zero denominator fabricated zero or full rate") + } +} diff --git a/web/src/QAQuality.test.tsx b/web/src/QAQuality.test.tsx index bd05fb3..1823e98 100644 --- a/web/src/QAQuality.test.tsx +++ b/web/src/QAQuality.test.tsx @@ -41,3 +41,56 @@ describe('manual quality operations', () => { expect(ok).toContain('AI 评估未接入') }) }) + +it('displays approved rates with raw denominators and exclusions', () => { + const html = renderToStaticMarkup( + {}} /> + ) + for (const value of [ + '知识覆盖率', + '回答正确率', + '50.0%', + '100.0%', + '分子', + '分母', + '排除', + '部分覆盖', + 'quality-rates-human-first-v1', + ]) + expect(html).toContain(value) + expect(html).not.toContain('不计算比例') +}) +it('does not turn zero denominators into 0% or 100%', () => { + const v = parseQualitySummary(structuredClone(summaryFixture)) + v.summary = { + total: 0, + reviewed: 0, + unreviewed: 0, + stale: 0, + pending_confirmation: 0, + rule_uncovered: 0, + rates: { + policy_version: 'quality-rates-human-first-v1', + coverage: { numerator: 0, denominator: 0, percent: null }, + correctness: { numerator: 0, denominator: 0, percent: null }, + breakdown: { + covered: 0, + uncovered: 0, + partial: 0, + pending: 0, + unassessed: 0, + excluded: 0, + stale: 0, + covered_correct: 0, + covered_incorrect: 0, + covered_pending: 0, + covered_unassessed: 0, + }, + }, + } as typeof v.summary + const html = renderToStaticMarkup( + {}} /> + ) + expect(html).toContain('暂无可计算数据') + expect(html).not.toContain('0.0%') +}) diff --git a/web/src/QAQuality.tsx b/web/src/QAQuality.tsx index 6a87909..14e39f1 100644 --- a/web/src/QAQuality.tsx +++ b/web/src/QAQuality.tsx @@ -421,8 +421,47 @@ export function QAQualitySummaryView({ data, loading, error, onRefresh }: Summar 待重新核对 {data.summary.stale} · 人工待确认 {data.summary.pending_confirmation} · 规则命中未覆盖{' '} {data.summary.rule_uncovered}

+
+ {( + [ + ['知识覆盖率', data.summary.rates.coverage], + ['回答正确率', data.summary.rates.correctness], + ] as const + ).map(([label, rate]) => ( +
+ + {label}:{rate.percent === null ? '暂无可计算数据' : `${rate.percent.toFixed(1)}%`} + +

+ 分子 {rate.numerator} / 分母 {rate.denominator};排除 {data.summary.total - rate.denominator} 条 +

+
+ ))} +
+
+ 查看比例口径和分母构成 +

+ 覆盖分母:已覆盖 {data.summary.rates.breakdown.covered} + 未覆盖 {data.summary.rates.breakdown.uncovered}{' '} + + 部分覆盖 {data.summary.rates.breakdown.partial};部分覆盖不计完全覆盖。 +

+

+ 覆盖分母排除:待确认 {data.summary.rates.breakdown.pending}、未评估{' '} + {data.summary.rates.breakdown.unassessed}、不计入 {data.summary.rates.breakdown.excluded}、待重新核对{' '} + {data.summary.rates.breakdown.stale}。 +

+

+ 正确性分母仅含已覆盖且判断明确:正确 {data.summary.rates.breakdown.covered_correct} + 错误{' '} + {data.summary.rates.breakdown.covered_incorrect};已覆盖中的待确认{' '} + {data.summary.rates.breakdown.covered_pending}、未评估 {data.summary.rates.breakdown.covered_unassessed}{' '} + 不计入,其余未覆盖/部分覆盖等记录也不计入。 +

+

+ 当前有效人工结论优先;精确规则命中的未覆盖可进入覆盖分母,但不证明回答错误。失效记录暂不参与两项比例,原始问答运行成功也不代表答案正确。 +

+

统计规则:{data.summary.rates.policy_version}

+

- AI 评估未接入;人工待确认是已复核中的子集,规则命中可与人工结论不同。不计算比例。读取:{stamp(data.as_of)} + AI 评估未接入;人工待确认是已复核中的子集,规则命中可与人工结论不同。读取:{stamp(data.as_of)}

) : !error && !loading ? ( diff --git a/web/src/qaOps.css b/web/src/qaOps.css index b5651ec..7c0c1a2 100644 --- a/web/src/qaOps.css +++ b/web/src/qaOps.css @@ -394,3 +394,17 @@ grid-template-columns: 100px 1fr; } } + +.qa-quality-rates { + display: flex; + flex-wrap: wrap; + gap: 12px 32px; + margin: 12px 0; +} +.qa-quality-rates > div { + min-width: 0; + overflow-wrap: anywhere; +} +.qa-quality-rates p { + margin: 4px 0 0; +} diff --git a/web/src/qaQuality.fixture.ts b/web/src/qaQuality.fixture.ts index bfa65c5..150aba9 100644 --- a/web/src/qaQuality.fixture.ts +++ b/web/src/qaQuality.fixture.ts @@ -40,7 +40,32 @@ export const qualityFixture = { effective: fields, } export const summaryFixture = { - summary: { total: 3, reviewed: 1, unreviewed: 1, stale: 1, pending_confirmation: 0, rule_uncovered: 2 }, + summary: { + total: 3, + reviewed: 1, + unreviewed: 1, + stale: 1, + pending_confirmation: 0, + rule_uncovered: 2, + rates: { + policy_version: 'quality-rates-human-first-v1', + coverage: { numerator: 1, denominator: 2, percent: 50 }, + correctness: { numerator: 1, denominator: 1, percent: 100 }, + breakdown: { + covered: 1, + uncovered: 1, + partial: 0, + pending: 0, + unassessed: 0, + excluded: 0, + stale: 1, + covered_correct: 1, + covered_incorrect: 0, + covered_pending: 0, + covered_unassessed: 0, + }, + }, + }, start: '2026-10-14T00:00:00Z', end: '2026-10-15T00:00:00Z', keyword: '', diff --git a/web/src/qaQuality.test.ts b/web/src/qaQuality.test.ts index 8ee04b5..848f2cb 100644 --- a/web/src/qaQuality.test.ts +++ b/web/src/qaQuality.test.ts @@ -49,3 +49,26 @@ describe('quality evidence contracts', () => { ).toHaveLength(1) }) }) + +describe('confirmed quality rate contract', () => { + it('retains raw counts, null rates, scope breakdown and policy version', () => { + const result = parseQualitySummary(summaryFixture) + expect(result.summary.rates.coverage.numerator).toBe(1) + expect(result.summary.rates.coverage.denominator).toBe(2) + expect(result.summary.rates.correctness.percent).toBe(100) + }) + it('rejects invalid or inconsistent numerator, denominator, percent and category sums', () => { + for (const change of [ + (r: any) => (r.coverage.numerator = 3), + (r: any) => (r.coverage.denominator = 3), + (r: any) => (r.coverage.percent = 99), + (r: any) => (r.breakdown.stale = 0), + (r: any) => (r.correctness.percent = null), + (r: any) => (r.policy_version = 'unknown'), + ]) { + const v = structuredClone(summaryFixture) + change(v.summary.rates) + expect(() => parseQualitySummary(v)).toThrow() + } + }) +}) diff --git a/web/src/qaQuality.ts b/web/src/qaQuality.ts index 7e8f61b..c733e47 100644 --- a/web/src/qaQuality.ts +++ b/web/src/qaQuality.ts @@ -45,6 +45,29 @@ export interface QualitySnapshot { created_at: string payload: { id: string; question: string; answer: string; status: string; references: unknown; media: unknown } } +export interface QualityRate { + numerator: number + denominator: number + percent: number | null +} +export interface QualityRates { + policy_version: 'quality-rates-human-first-v1' + coverage: QualityRate + correctness: QualityRate + breakdown: { + covered: number + uncovered: number + partial: number + pending: number + unassessed: number + excluded: number + stale: number + covered_correct: number + covered_incorrect: number + covered_pending: number + covered_unassessed: number + } +} export interface QualitySummary { summary: { total: number @@ -53,6 +76,7 @@ export interface QualitySummary { stale: number pending_confirmation: number rule_uncovered: number + rates: QualityRates } start: string end: string @@ -195,6 +219,56 @@ export function parseQAQuality(value: unknown): QAQuality { for (const key of Object.keys(expected) as (keyof QualityFields)[]) if (expected[key] !== effective[key]) return bad() return { input_hash, input_epoch, revision, state: state as QAQuality['state'], automatic, latest_review, effective } } +function qualityRate(value: unknown): QualityRate { + const v = obj(value), + numerator = num(v.numerator), + denominator = num(v.denominator) + if (numerator > denominator) return bad() + if (denominator === 0) { + if (v.percent !== null) return bad() + return { numerator, denominator, percent: null } + } + if ( + typeof v.percent !== 'number' || + !Number.isFinite(v.percent) || + v.percent < 0 || + v.percent > 100 || + Math.abs(v.percent - (100 * numerator) / denominator) > 1e-8 + ) + return bad() + return { numerator, denominator, percent: v.percent } +} +function qualityRates(value: unknown, total: number, stale: number): QualityRates { + const v = obj(value), + r = obj(v.breakdown) + const b = { + covered: num(r.covered), + uncovered: num(r.uncovered), + partial: num(r.partial), + pending: num(r.pending), + unassessed: num(r.unassessed), + excluded: num(r.excluded), + stale: num(r.stale), + covered_correct: num(r.covered_correct), + covered_incorrect: num(r.covered_incorrect), + covered_pending: num(r.covered_pending), + covered_unassessed: num(r.covered_unassessed), + } + const coverage = qualityRate(v.coverage), + correctness = qualityRate(v.correctness) + if ( + v.policy_version !== 'quality-rates-human-first-v1' || + b.stale !== stale || + num(b.covered + b.uncovered + b.partial + b.pending + b.unassessed + b.excluded + b.stale) !== total || + num(b.covered_correct + b.covered_incorrect + b.covered_pending + b.covered_unassessed) !== b.covered || + coverage.numerator !== b.covered || + coverage.denominator !== b.covered + b.uncovered + b.partial || + correctness.numerator !== b.covered_correct || + correctness.denominator !== b.covered_correct + b.covered_incorrect + ) + return bad() + return { policy_version: 'quality-rates-human-first-v1', coverage, correctness, breakdown: b } +} export function parseQualitySummary(value: unknown): QualitySummary { const v = obj(value), s = obj(v.summary), @@ -205,6 +279,7 @@ export function parseQualitySummary(value: unknown): QualitySummary { stale: num(s.stale), pending_confirmation: num(s.pending_confirmation), rule_uncovered: num(s.rule_uncovered), + rates: qualityRates(s.rates, num(s.total), num(s.stale)), } const start = stamp(v.start), end = stamp(v.end)