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(
+
+ 分子 {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)