使用完整筛选范围的当前有效结论计算覆盖率和正确率,人工优先,失效与对应待确认/未评估/不计入排除;零分母返回null,界面展示原始分子分母和排除构成,前端拒绝不守恒或比例不一致的响应。 验证:make verify退出码0,Go658及前端257通过,91项声明可选集成跳过;隔离PG、三包竞态及合成/真实只读源浏览器验证通过。无新增迁移,不接AI模型,不改绩效,不写源库;本批未推送或部署,8088运行版保持0.1.21。
This commit is contained in:
+3
-1
@@ -4,8 +4,10 @@
|
||||
|
||||
### Added
|
||||
|
||||
- 问答运营按已确认口径显示知识覆盖率、回答正确率及原始分子/分母、排除数量与构成。使用完整筛选范围和当前有效人工优先结论,精确规则命中的未覆盖可进入覆盖分母;部分覆盖不算完全覆盖,待确认/未评估/失效/不计入按对应指标排除,零分母显示“暂无可计算数据”。返回独立规则版本,AI 模型与绩效范围仍未接入。
|
||||
|
||||
- 问答详情支持治理侧人工复核及不可变审计:覆盖、正确性、产品分类、问题类型、根因与理由由管理员明确填写,操作人取登录身份;保存后独立读回复核历史。保留源端判断和自动规则,输入变化使旧人工结论失效,已观测的内容回退不自动恢复旧结论;并发修改冲突、重复请求不重复留档。
|
||||
- 新增覆盖复检历史、已采集输入快照和完整筛选范围质量数量。批量统计只传指纹与规则命中元数据,原文在读取问答/复核时采集,未采集版本明确提示而非伪造空快照;源事务时点阻止延迟旧扫描回退新输入。仅提供数量,不接 AI 模型、不定义质量率或绩效分母、不回写 DobotAI 原问答。
|
||||
- 新增覆盖复检历史、已采集输入快照和完整筛选范围质量数量。批量统计只传指纹与规则命中元数据,原文在读取问答/复核时采集,未采集版本明确提示而非伪造空快照;源事务时点阻止延迟旧扫描回退新输入。基础数量与本期经确认新增的质量比例分开显示;不接 AI 模型、不定义绩效分母、不回写 DobotAI 原问答。
|
||||
|
||||
- 「使用统计 → 个人月明细」按北京时间自然月及截止时点展示当前应用可见名册的人员用量(包含本源零记录),支持部门含子部门、区域/待核对状态、姓名/完整部门路径和已有提问/零记录筛选。稳定身份关联去重、不可关联问答单列、过期组织版本阻断翻页;源端姓名可缺失并提示重新同步,不按姓名匹配身份。姓名筛选通过有界只读 POST 请求体传递、不进入 URL,原始员工标识不返回浏览器;名册不作为在职/考核分母,当月进度不作为绩效结论。
|
||||
|
||||
|
||||
+1
-1
@@ -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": "当前授权范围内未找到记录/历史输入"}}}},
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -41,3 +41,56 @@ describe('manual quality operations', () => {
|
||||
expect(ok).toContain('AI 评估未接入')
|
||||
})
|
||||
})
|
||||
|
||||
it('displays approved rates with raw denominators and exclusions', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<QAQualitySummaryView data={parseQualitySummary(summaryFixture)} error="" loading={false} onRefresh={() => {}} />
|
||||
)
|
||||
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(
|
||||
<QAQualitySummaryView data={parseQualitySummary(v)} error="" loading={false} onRefresh={() => {}} />
|
||||
)
|
||||
expect(html).toContain('暂无可计算数据')
|
||||
expect(html).not.toContain('0.0%')
|
||||
})
|
||||
|
||||
+40
-1
@@ -421,8 +421,47 @@ export function QAQualitySummaryView({ data, loading, error, onRefresh }: Summar
|
||||
待重新核对 {data.summary.stale} · 人工待确认 {data.summary.pending_confirmation} · 规则命中未覆盖{' '}
|
||||
{data.summary.rule_uncovered}
|
||||
</p>
|
||||
<div className="qa-quality-rates" aria-label="质量比例">
|
||||
{(
|
||||
[
|
||||
['知识覆盖率', data.summary.rates.coverage],
|
||||
['回答正确率', data.summary.rates.correctness],
|
||||
] as const
|
||||
).map(([label, rate]) => (
|
||||
<div key={label}>
|
||||
<strong>
|
||||
{label}:{rate.percent === null ? '暂无可计算数据' : `${rate.percent.toFixed(1)}%`}
|
||||
</strong>
|
||||
<p>
|
||||
分子 {rate.numerator} / 分母 {rate.denominator};排除 {data.summary.total - rate.denominator} 条
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<details className="qa-ops-organization">
|
||||
<summary>查看比例口径和分母构成</summary>
|
||||
<p>
|
||||
覆盖分母:已覆盖 {data.summary.rates.breakdown.covered} + 未覆盖 {data.summary.rates.breakdown.uncovered}{' '}
|
||||
+ 部分覆盖 {data.summary.rates.breakdown.partial};部分覆盖不计完全覆盖。
|
||||
</p>
|
||||
<p>
|
||||
覆盖分母排除:待确认 {data.summary.rates.breakdown.pending}、未评估{' '}
|
||||
{data.summary.rates.breakdown.unassessed}、不计入 {data.summary.rates.breakdown.excluded}、待重新核对{' '}
|
||||
{data.summary.rates.breakdown.stale}。
|
||||
</p>
|
||||
<p>
|
||||
正确性分母仅含已覆盖且判断明确:正确 {data.summary.rates.breakdown.covered_correct} + 错误{' '}
|
||||
{data.summary.rates.breakdown.covered_incorrect};已覆盖中的待确认{' '}
|
||||
{data.summary.rates.breakdown.covered_pending}、未评估 {data.summary.rates.breakdown.covered_unassessed}{' '}
|
||||
不计入,其余未覆盖/部分覆盖等记录也不计入。
|
||||
</p>
|
||||
<p>
|
||||
当前有效人工结论优先;精确规则命中的未覆盖可进入覆盖分母,但不证明回答错误。失效记录暂不参与两项比例,原始问答运行成功也不代表答案正确。
|
||||
</p>
|
||||
<p className="qa-ops-muted">统计规则:{data.summary.rates.policy_version}</p>
|
||||
</details>
|
||||
<p className="qa-ops-muted">
|
||||
AI 评估未接入;人工待确认是已复核中的子集,规则命中可与人工结论不同。不计算比例。读取:{stamp(data.as_of)}
|
||||
AI 评估未接入;人工待确认是已复核中的子集,规则命中可与人工结论不同。读取:{stamp(data.as_of)}
|
||||
</p>
|
||||
</>
|
||||
) : !error && !loading ? (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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: '',
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user