列表正文改为按需读取;保留完整导出、版本隔离和有效标签筛选。 验证:前端全量、Go全量及隔离PG全mirror竞态通过;覆盖首批回调、续载失败与筛选分页。
This commit is contained in:
@@ -76,6 +76,17 @@
|
||||
> `0.1.12`),由 `scripts/version.py` 从 git **算出来**;开发交付的独立说明写入本文件“开发快照”章节,并打 `snapshot/vX.Y.Z` 注释标签 ⇒ 每次开发交付均可追溯,
|
||||
> 但"发布版本"仍然只能由人定(CI 不会自动升版本、不会打 tag)。
|
||||
|
||||
## 开发快照 snapshot/v0.1.55 - 2026-10-25
|
||||
|
||||
### Changed
|
||||
|
||||
- 问题流水表首批到达即可浏览,后续在后台自动续载;列表仅传轻量文本预览,完整问答/引用/媒体按需读取,完整明细另行导出,避免等全部正文下载后才显示。
|
||||
- 新增是否覆盖、是否正确、产品分类、问题类型、根因分析五项筛选,可与时间/关键词组合并查询未设置值;后端按完整总表的当前有效标签筛选,列表、看板及导出条件一致。字段筛选时不套用无明细的历史汇总。
|
||||
|
||||
### Fixed
|
||||
|
||||
- 续载异常明确保留已读部分和失败状态,查询变更取消旧请求;详情保存和AI更新不被迟到分页覆盖,完整导出拒绝使用截断预览或不完整结果。
|
||||
|
||||
## 开发快照 snapshot/v0.1.54 - 2026-10-24
|
||||
|
||||
### Changed
|
||||
|
||||
+7
-1
@@ -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秒顺序刷新,已闭查询周不推进到后周。","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}}}}}},"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-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/organization": {
|
||||
"get": {
|
||||
"tags": ["QA operations"],
|
||||
@@ -292,6 +292,12 @@
|
||||
"operationId": "listQAOperationsRecordsMaster",
|
||||
"summary": "正式主表:按半开提问时间范围、原问题关键词、稳定游标列出问答;租户固定。通过持久来源关联读取本地镜像最新反馈,点踩强制有效是否正确为否,quality.feedback_rule=downvote-is-incorrect-v1;原人工证据保留,点赞不推定正确。答案包含暂无已发布则未覆盖",
|
||||
"parameters": [
|
||||
{"name": "projection", "in": "query", "description": "summary返回400码点问题/答案预览及content_projection=summary,不带引用/媒体;full或不传保留完整内容供导出。", "schema": {"type": "string", "enum": ["summary", "full"]}},
|
||||
{"name": "coverage", "in": "query", "description": "当前有效字段精确匹配;__empty__为未设置;五项AND组合。先在完整总表筛选再计数/分页,游标绑定筛选及数据版本。", "schema": {"type": "string", "maxLength": 200}},
|
||||
{"name": "is_correct", "in": "query", "description": "当前有效字段精确匹配;__empty__为未设置;五项AND组合。先在完整总表筛选再计数/分页,游标绑定筛选及数据版本。", "schema": {"type": "string", "maxLength": 200}},
|
||||
{"name": "product_category", "in": "query", "description": "当前有效字段精确匹配;__empty__为未设置;五项AND组合。先在完整总表筛选再计数/分页,游标绑定筛选及数据版本。", "schema": {"type": "string", "maxLength": 200}},
|
||||
{"name": "problem_type", "in": "query", "description": "当前有效字段精确匹配;__empty__为未设置;五项AND组合。先在完整总表筛选再计数/分页,游标绑定筛选及数据版本。", "schema": {"type": "string", "maxLength": 200}},
|
||||
{"name": "root_cause", "in": "query", "description": "当前有效字段精确匹配;__empty__为未设置;五项AND组合。先在完整总表筛选再计数/分页,游标绑定筛选及数据版本。", "schema": {"type": "string", "maxLength": 200}},
|
||||
{
|
||||
"name": "start",
|
||||
"in": "query",
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package dobotaiops
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
var QualityFilterKeys = []string{"coverage", "is_correct", "product_category", "problem_type", "root_cause"}
|
||||
|
||||
func ValidQualityFilters(filters map[string]string) bool {
|
||||
if len(filters) > 5 {
|
||||
return false
|
||||
}
|
||||
for key, value := range filters {
|
||||
found := false
|
||||
for _, allowed := range QualityFilterKeys {
|
||||
if key == allowed {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found || value == "" || !utf8.ValidString(value) || utf8.RuneCountInString(value) > 200 || strings.ContainsRune(value, 0) {
|
||||
return false
|
||||
}
|
||||
if value == "__empty__" {
|
||||
continue
|
||||
}
|
||||
if key == "coverage" && value != "是" && value != "否" && value != "否但不计入" && value != "部分覆盖" && value != "待确认" {
|
||||
return false
|
||||
}
|
||||
if key == "is_correct" && value != "是" && value != "否" && value != "待确认" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -38,6 +38,11 @@ func NewRecordsRepository(o RecordsRepositoryOptions) *RecordsRepository {
|
||||
}
|
||||
return &RecordsRepository{summarySQL: o.SummarySQL, qualityScopeSQL: o.QualityScopeSQL, baseSQL: o.BaseSQL, transact: o.Transaction, validateQueryWindow: o.ValidateWindow, readAsOf: asOf, tenant: o.TenantKey}
|
||||
}
|
||||
func (c *RecordsRepository) WithSummarySQL(sql string) *RecordsRepository {
|
||||
out := *c
|
||||
out.summarySQL = sql
|
||||
return &out
|
||||
}
|
||||
func (c *Client) recordRepository() *RecordsRepository {
|
||||
return NewRecordsRepository(RecordsRepositoryOptions{BaseSQL: recordsBaseSQL, Transaction: c.withReadOnlyTx, ValidateWindow: c.validateQueryWindow})
|
||||
}
|
||||
|
||||
@@ -69,6 +69,7 @@ type RecordFeedback struct {
|
||||
}
|
||||
|
||||
type Record struct {
|
||||
ContentProjection string `json:"content_projection,omitempty"`
|
||||
FieldEditRevision int64 `json:"field_edit_revision,omitempty"`
|
||||
BackgroundTasks json.RawMessage `json:"background_tasks,omitempty"`
|
||||
RecordOrigin string `json:"record_origin,omitempty"`
|
||||
@@ -96,10 +97,14 @@ type Record struct {
|
||||
}
|
||||
|
||||
type RecordQuery struct {
|
||||
Window Window
|
||||
Keyword string
|
||||
Cursor string
|
||||
Limit int
|
||||
Summary bool
|
||||
Filters map[string]string
|
||||
MatchIDs []string
|
||||
MatchVersion string
|
||||
Window Window
|
||||
Keyword string
|
||||
Cursor string
|
||||
Limit int
|
||||
}
|
||||
|
||||
type QualityKey struct {
|
||||
@@ -200,6 +205,16 @@ func (c *RecordsRepository) ListRecords(ctx context.Context, query RecordQuery)
|
||||
return RecordPage{}, fmt.Errorf("%w: invalid record filters", ErrInvalidQuery)
|
||||
}
|
||||
scopeBytes, _ := json.Marshal([]string{query.Window.TenantKey, query.Window.Start.UTC().Format(time.RFC3339Nano), query.Window.End.UTC().Format(time.RFC3339Nano), query.Keyword})
|
||||
if len(query.Filters) > 0 {
|
||||
if query.MatchIDs == nil {
|
||||
return RecordPage{}, ErrInvalidQuery
|
||||
}
|
||||
extra, _ := json.Marshal(struct {
|
||||
Filters map[string]string
|
||||
Version string
|
||||
}{query.Filters, query.MatchVersion})
|
||||
scopeBytes = append(scopeBytes, extra...)
|
||||
}
|
||||
scopeSum := sha256.Sum256(scopeBytes)
|
||||
scope := hex.EncodeToString(scopeSum[:])
|
||||
var cursor recordCursor
|
||||
@@ -226,7 +241,13 @@ func (c *RecordsRepository) ListRecords(ctx context.Context, query RecordQuery)
|
||||
if c.summarySQL != "" {
|
||||
summarySQL = c.summarySQL
|
||||
}
|
||||
if err := q.QueryRow(ctx, summarySQL+recordFilterSQL, append(args, noPublishedMarker)...).Scan(&page.Total, &rawColumns, &rawActors, &rawQuality); err != nil {
|
||||
summaryFilter := recordFilterSQL
|
||||
summaryArgs := append(append([]any{}, args...), noPublishedMarker)
|
||||
if query.MatchIDs != nil {
|
||||
summaryFilter += " AND id=ANY($6::text[])"
|
||||
summaryArgs = append(summaryArgs, query.MatchIDs)
|
||||
}
|
||||
if err := q.QueryRow(ctx, summarySQL+summaryFilter, summaryArgs...).Scan(&page.Total, &rawColumns, &rawActors, &rawQuality); err != nil {
|
||||
return fmt.Errorf("%w: record count failed", ErrUnavailable)
|
||||
}
|
||||
if json.Unmarshal([]byte(rawQuality), &page.ScopeQuality) != nil {
|
||||
@@ -247,8 +268,14 @@ func (c *RecordsRepository) ListRecords(ctx context.Context, query RecordQuery)
|
||||
page.Columns = append(page.Columns, column)
|
||||
}
|
||||
}
|
||||
rows, err := q.Query(ctx, c.baseSQL+` SELECT payload::text FROM records WHERE `+recordFilterSQL+`
|
||||
AND ($5::timestamptz IS NULL OR (asked_at,id)<($5,$6)) ORDER BY asked_at DESC,id DESC LIMIT $7`, append(args, before, cursor.ID, query.Limit+1)...)
|
||||
listFilter := recordFilterSQL
|
||||
listArgs := append(append([]any{}, args...), before, cursor.ID, query.Limit+1)
|
||||
if query.MatchIDs != nil {
|
||||
listFilter += " AND id=ANY($8::text[])"
|
||||
listArgs = append(listArgs, query.MatchIDs)
|
||||
}
|
||||
rows, err := q.Query(ctx, c.baseSQL+` SELECT payload::text FROM records WHERE `+listFilter+`
|
||||
AND ($5::timestamptz IS NULL OR (asked_at,id)<($5,$6)) ORDER BY asked_at DESC,id DESC LIMIT $7`, listArgs...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: record list failed", ErrUnavailable)
|
||||
}
|
||||
|
||||
@@ -19,9 +19,10 @@ func qaOperationsDashboard(w http.ResponseWriter, r *http.Request, c Config) {
|
||||
return
|
||||
}
|
||||
var request struct {
|
||||
Start string `json:"start"`
|
||||
End string `json:"end"`
|
||||
Keyword string `json:"keyword"`
|
||||
Start string `json:"start"`
|
||||
End string `json:"end"`
|
||||
Keyword string `json:"keyword"`
|
||||
Filters map[string]string `json:"filters,omitempty"`
|
||||
}
|
||||
if decodeQualityJSON(w, r, &request, "start", "end") != nil {
|
||||
qaOpsError(w, dobotaiops.ErrInvalidQuery)
|
||||
@@ -37,7 +38,7 @@ func qaOperationsDashboard(w http.ResponseWriter, r *http.Request, c Config) {
|
||||
qaOpsError(w, e)
|
||||
return
|
||||
}
|
||||
dashboard, e := reader.ReadMasterDashboard(r.Context(), dobotaiops.RecordQuery{Window: window, Keyword: request.Keyword})
|
||||
dashboard, e := reader.ReadMasterDashboard(r.Context(), dobotaiops.RecordQuery{Window: window, Keyword: request.Keyword, Filters: request.Filters})
|
||||
if e != nil {
|
||||
if errors.Is(e, qaopsquality.ErrConflict) {
|
||||
writeQualityError(w, e)
|
||||
|
||||
@@ -82,7 +82,10 @@ func listQAOpsRecords(w http.ResponseWriter, r *http.Request, config Config) {
|
||||
return
|
||||
}
|
||||
values := r.URL.Query()
|
||||
allowed := map[string]bool{"start": true, "end": true, "keyword": true, "cursor": true, "limit": true}
|
||||
allowed := map[string]bool{"start": true, "end": true, "keyword": true, "cursor": true, "limit": true, "projection": true}
|
||||
for _, key := range dobotaiops.QualityFilterKeys {
|
||||
allowed[key] = true
|
||||
}
|
||||
for key, items := range values {
|
||||
if !allowed[key] || len(items) != 1 {
|
||||
qaOpsError(w, dobotaiops.ErrInvalidQuery)
|
||||
@@ -102,12 +105,27 @@ func listQAOpsRecords(w http.ResponseWriter, r *http.Request, config Config) {
|
||||
return
|
||||
}
|
||||
}
|
||||
filters := map[string]string{}
|
||||
for _, key := range dobotaiops.QualityFilterKeys {
|
||||
if value := values.Get(key); value != "" {
|
||||
filters[key] = value
|
||||
}
|
||||
}
|
||||
if !dobotaiops.ValidQualityFilters(filters) {
|
||||
qaOpsError(w, dobotaiops.ErrInvalidQuery)
|
||||
return
|
||||
}
|
||||
mode := values.Get("projection")
|
||||
if mode != "" && mode != "summary" && mode != "full" {
|
||||
qaOpsError(w, dobotaiops.ErrInvalidQuery)
|
||||
return
|
||||
}
|
||||
projection, err := recordAttribution(r.Context(), config)
|
||||
if err != nil {
|
||||
qaOpsError(w, err)
|
||||
return
|
||||
}
|
||||
page, err := config.QAOps.ListRecords(r.Context(), dobotaiops.RecordQuery{Window: window, Keyword: values.Get("keyword"), Cursor: values.Get("cursor"), Limit: limit})
|
||||
page, err := config.QAOps.ListRecords(r.Context(), dobotaiops.RecordQuery{Window: window, Keyword: values.Get("keyword"), Cursor: values.Get("cursor"), Limit: limit, Summary: mode == "summary", Filters: filters})
|
||||
if err != nil {
|
||||
qaOpsError(w, err)
|
||||
return
|
||||
@@ -117,6 +135,11 @@ func listQAOpsRecords(w http.ResponseWriter, r *http.Request, config Config) {
|
||||
writeQualityError(w, err)
|
||||
return
|
||||
}
|
||||
if mode == "summary" {
|
||||
for i := range page.Items {
|
||||
page.Items[i] = qaRecordPreview(page.Items[i])
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"data": page})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package httpapi
|
||||
|
||||
import "knowledge-governance/internal/dobotaiops"
|
||||
|
||||
func qaPreviewText(s string) string {
|
||||
r := []rune(s)
|
||||
if len(r) > 400 {
|
||||
return string(r[:400]) + "…"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// Summaries never replace immutable business content or a full export response.
|
||||
func qaRecordPreview(item dobotaiops.Record) dobotaiops.Record {
|
||||
item.ContentProjection = "summary"
|
||||
item.Question = qaPreviewText(item.Question)
|
||||
item.Answer = qaPreviewText(item.Answer)
|
||||
fields := make(map[string]string, len(item.MasterFields))
|
||||
for key, value := range item.MasterFields {
|
||||
if key == "question" || key == "answer" || key == "question_chinese" {
|
||||
value = qaPreviewText(value)
|
||||
}
|
||||
fields[key] = value
|
||||
}
|
||||
item.MasterFields = fields
|
||||
item.References = nil
|
||||
item.Media = nil
|
||||
return item
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"knowledge-governance/internal/dobotaiops"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
type qaPreviewSpy struct {
|
||||
qaOpsSpy
|
||||
record dobotaiops.Record
|
||||
}
|
||||
|
||||
func (s *qaPreviewSpy) ListRecords(_ context.Context, q dobotaiops.RecordQuery) (dobotaiops.RecordPage, error) {
|
||||
s.query = q
|
||||
return dobotaiops.RecordPage{Items: []dobotaiops.Record{s.record}, Total: 1}, nil
|
||||
}
|
||||
func TestQALightweightListPreservesFullRecordAndRejectsUnknownProjection(t *testing.T) {
|
||||
full := strings.Repeat("长😀", 900)
|
||||
spy := &qaPreviewSpy{record: dobotaiops.Record{ID: "preview-fixture", Question: full, Answer: full, MasterFields: map[string]string{"question_chinese": full, "product_category": "MG400"}, References: json.RawMessage(`[{"content":"private-reference-body"}]`), Media: json.RawMessage(`[{"url":"private-media"}]`)}}
|
||||
auth := newFakeAuthStore()
|
||||
auth.sessions["admin-qa-fixture"] = "usr-boss"
|
||||
h, err := NewHandler(Config{Store: &fakeJobStore{}, QAOps: spy, QAOpsTenantKey: "approved-tenant", Auth: &AuthConfig{Store: auth}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
base := "/api/v1/qa-ops/records?start=2026-10-14T00:00:00Z&end=2026-10-15T00:00:00Z"
|
||||
for _, mode := range []string{"summary", "full", "invalid"} {
|
||||
req := httptest.NewRequest("GET", base+"&projection="+mode, nil)
|
||||
req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "admin-qa-fixture"})
|
||||
out := httptest.NewRecorder()
|
||||
h.ServeHTTP(out, req)
|
||||
if mode == "invalid" {
|
||||
if out.Code != 400 {
|
||||
t.Fatal("unknown projection accepted")
|
||||
}
|
||||
continue
|
||||
}
|
||||
if out.Code != 200 {
|
||||
t.Fatalf("projection %s status %d", mode, out.Code)
|
||||
}
|
||||
var data struct{ Data dobotaiops.RecordPage }
|
||||
if json.Unmarshal(out.Body.Bytes(), &data) != nil || len(data.Data.Items) != 1 {
|
||||
t.Fatal("invalid page")
|
||||
}
|
||||
row := data.Data.Items[0]
|
||||
if mode == "summary" {
|
||||
if utf8.RuneCountInString(row.Answer) > 401 || row.Answer == full || strings.Contains(out.Body.String(), "private-reference-body") || strings.Contains(out.Body.String(), "private-media") {
|
||||
t.Fatal("summary leaked full content")
|
||||
}
|
||||
} else if row.Answer != full || !strings.Contains(out.Body.String(), "private-reference-body") {
|
||||
t.Fatal("full export content changed")
|
||||
}
|
||||
}
|
||||
if spy.record.Answer != full || spy.record.MasterFields["question_chinese"] != full {
|
||||
t.Fatal("preview mutated original")
|
||||
}
|
||||
_ = http.StatusOK
|
||||
}
|
||||
|
||||
func TestQARecordsAcceptFiveExactFiltersAndRejectUnsupportedValues(t *testing.T) {
|
||||
spy := &qaOpsSpy{}
|
||||
auth := newFakeAuthStore()
|
||||
auth.sessions["admin-qa-fixture"] = "usr-boss"
|
||||
h, err := NewHandler(Config{Store: &fakeJobStore{}, QAOps: spy, QAOpsTenantKey: "approved-tenant", Auth: &AuthConfig{Store: auth}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
base := "/api/v1/qa-ops/records?start=2026-10-14T00:00:00Z&end=2026-10-15T00:00:00Z"
|
||||
for _, suffix := range []string{"&coverage=是&is_correct=否&product_category=MG400&problem_type=参数&root_cause=知识缺失", "&root_cause=__empty__"} {
|
||||
req := httptest.NewRequest("GET", base+suffix, nil)
|
||||
req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "admin-qa-fixture"})
|
||||
out := httptest.NewRecorder()
|
||||
h.ServeHTTP(out, req)
|
||||
if out.Code != 200 || len(spy.query.Filters) == 0 {
|
||||
t.Fatalf("filter not forwarded: %d", out.Code)
|
||||
}
|
||||
}
|
||||
for _, suffix := range []string{"&coverage=invalid", "&is_correct=是&is_correct=否", "&product_category=" + strings.Repeat("x", 501)} {
|
||||
req := httptest.NewRequest("GET", base+suffix, nil)
|
||||
req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "admin-qa-fixture"})
|
||||
out := httptest.NewRecorder()
|
||||
h.ServeHTTP(out, req)
|
||||
if out.Code != 400 {
|
||||
t.Fatal("invalid filters accepted")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,7 @@ type Dashboard struct {
|
||||
Start time.Time `json:"start"`
|
||||
End time.Time `json:"end"`
|
||||
Keyword string `json:"keyword"`
|
||||
Filters map[string]string `json:"filters,omitempty"`
|
||||
AsOf time.Time `json:"as_of"`
|
||||
LocalRevision int64 `json:"local_revision"`
|
||||
Organization dobotaiops.OrganizationVersion `json:"organization"`
|
||||
|
||||
@@ -24,6 +24,7 @@ type DashboardCumulative struct {
|
||||
}
|
||||
|
||||
type dashboardTimedQuality struct {
|
||||
id string
|
||||
at time.Time
|
||||
view qaopsquality.View
|
||||
}
|
||||
@@ -84,7 +85,7 @@ func (s *Store) readMasterQualityTimeline(ctx context.Context, tx pgx.Tx, keywor
|
||||
if !ok {
|
||||
return nil, now, qaopsquality.ErrUnavailable
|
||||
}
|
||||
out = append(out, dashboardTimedQuality{at: r.at, view: qaopsquality.ProjectMasterWithEdits(v, r.fields, r.coverage, r.edits, r.feedback)})
|
||||
out = append(out, dashboardTimedQuality{id: r.id, at: r.at, view: qaopsquality.ProjectMasterWithEdits(v, r.fields, r.coverage, r.edits, r.feedback)})
|
||||
}
|
||||
return out, now, nil
|
||||
}
|
||||
|
||||
@@ -69,6 +69,20 @@ func (s *Store) readDashboard(ctx context.Context, q dobotaiops.RecordQuery, mas
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, 20*time.Second)
|
||||
defer cancel()
|
||||
if !dobotaiops.ValidQualityFilters(q.Filters) {
|
||||
return out, dobotaiops.ErrInvalidQuery
|
||||
}
|
||||
if len(q.Filters) > 0 {
|
||||
if !master {
|
||||
return out, dobotaiops.ErrInvalidQuery
|
||||
}
|
||||
var err error
|
||||
q, err = s.resolveMasterFilters(ctx, q)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
out.Filters = q.Filters
|
||||
}
|
||||
tx, e := s.pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.RepeatableRead, AccessMode: pgx.ReadOnly})
|
||||
if e != nil {
|
||||
return out, dobotaiops.ErrUnavailable
|
||||
@@ -107,7 +121,12 @@ func (s *Store) readDashboard(ctx context.Context, q dobotaiops.RecordQuery, mas
|
||||
totalSQL = strings.ReplaceAll(totalSQL, "qa_ops_mirror_records", "qa_ops_master_read_model")
|
||||
totalSQL = strings.ReplaceAll(totalSQL, "lower(question)", "lower(question||E'\\n'||chinese_question||E'\\n'||COALESCE(ai_fields->>'question_chinese',''))")
|
||||
}
|
||||
if e = tx.QueryRow(ctx, totalSQL, s.tenant, q.Window.End, q.Keyword).Scan(&out.TotalIssues, &first); e != nil {
|
||||
totalArgs := []any{s.tenant, q.Window.End, q.Keyword}
|
||||
if q.MatchIDs != nil {
|
||||
totalSQL += " AND id=ANY($4::text[])"
|
||||
totalArgs = append(totalArgs, q.MatchIDs)
|
||||
}
|
||||
if e = tx.QueryRow(ctx, totalSQL, totalArgs...).Scan(&out.TotalIssues, &first); e != nil {
|
||||
return out, dobotaiops.ErrUnavailable
|
||||
}
|
||||
lower := q.Window.Start
|
||||
@@ -123,7 +142,12 @@ func (s *Store) readDashboard(ctx context.Context, q dobotaiops.RecordQuery, mas
|
||||
rowsSQL = strings.ReplaceAll(rowsSQL, "SELECT id,asked_at,identity,quality_hash FROM", "SELECT id,asked_at,identity,quality_hash,effective_fields,coverage_result,inline_fields,current_feedback FROM")
|
||||
rowsSQL = strings.ReplaceAll(rowsSQL, "lower(question)", "lower(question||E'\\n'||chinese_question||E'\\n'||COALESCE(ai_fields->>'question_chinese',''))")
|
||||
}
|
||||
rows, e := tx.Query(ctx, rowsSQL, s.tenant, lower, q.Window.End, q.Keyword)
|
||||
rowsArgs := []any{s.tenant, lower, q.Window.End, q.Keyword}
|
||||
if q.MatchIDs != nil {
|
||||
rowsSQL = strings.Replace(rowsSQL, " ORDER BY id LIMIT", " AND id=ANY($5::text[]) ORDER BY id LIMIT", 1)
|
||||
rowsArgs = append(rowsArgs, q.MatchIDs)
|
||||
}
|
||||
rows, e := tx.Query(ctx, rowsSQL, rowsArgs...)
|
||||
if e != nil {
|
||||
return out, dobotaiops.ErrUnavailable
|
||||
}
|
||||
@@ -254,7 +278,7 @@ func (s *Store) readDashboard(ctx context.Context, q dobotaiops.RecordQuery, mas
|
||||
}
|
||||
}
|
||||
}
|
||||
if first != nil {
|
||||
if first != nil && len(q.Filters) == 0 {
|
||||
snapshots, err := s.readFrozenDashboard(ctx, tx, q.Keyword, q.Window.End)
|
||||
if err != nil {
|
||||
return out, err
|
||||
@@ -269,6 +293,15 @@ func (s *Store) readDashboard(ctx context.Context, q dobotaiops.RecordQuery, mas
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
if len(q.Filters) > 0 {
|
||||
filtered := make([]dashboardTimedQuality, 0)
|
||||
for _, row := range timeline {
|
||||
if matchesMasterFilters(row.view, q.Filters) {
|
||||
filtered = append(filtered, row)
|
||||
}
|
||||
}
|
||||
timeline = filtered
|
||||
}
|
||||
out.CumulativeWeeks = dashboardCumulativeFromTimeline(timeline, q.Window.End, calculated)
|
||||
month, cutoff := dashboardQualityMonth(out.WeekStart, q.Window.End, calculated)
|
||||
out.QualityPeriod = &DashboardQualityPeriod{Month: month.Format("2006-01"), Start: month, CutoffAt: cutoff, CalculatedAt: calculated, Source: dashboardMonthlySource, PolicyVersion: dashboardMonthlyPolicy}
|
||||
@@ -279,10 +312,17 @@ func (s *Store) readDashboard(ctx context.Context, q dobotaiops.RecordQuery, mas
|
||||
out.CumulativeWeeks = []DashboardCumulative{}
|
||||
out.CalendarMonths = []DashboardMonth{}
|
||||
}
|
||||
out.ReportedWeeks, e = s.readReportedDashboard(ctx, tx, q.Keyword, q.Window.End)
|
||||
if len(q.Filters) == 0 {
|
||||
out.ReportedWeeks, e = s.readReportedDashboard(ctx, tx, q.Keyword, q.Window.End)
|
||||
} else {
|
||||
out.ReportedWeeks = []DashboardReported{}
|
||||
}
|
||||
if e != nil {
|
||||
return out, e
|
||||
}
|
||||
if e = s.masterFiltersStillCurrent(ctx, q); e != nil {
|
||||
return out, e
|
||||
}
|
||||
if tx.Commit(ctx) != nil {
|
||||
return out, dobotaiops.ErrUnavailable
|
||||
}
|
||||
|
||||
@@ -24,8 +24,19 @@ var masterSummarySQL = masterMetadataBase + ` SELECT count(*),'{}'::text,COALESC
|
||||
var masterQualityScopeSQL = masterMetadataBase + ` SELECT id,quality_hash,marker,source_coverage::text FROM records WHERE $5::text IS NOT NULL AND `
|
||||
|
||||
func (s *Store) ListMasterRecords(ctx context.Context, q dobotaiops.RecordQuery) (dobotaiops.RecordPage, error) {
|
||||
q, err := s.resolveMasterFilters(ctx, q)
|
||||
if err != nil {
|
||||
return dobotaiops.RecordPage{}, err
|
||||
}
|
||||
var revision int64
|
||||
p, e := s.repositoryFor(&revision, true).ListRecords(ctx, q)
|
||||
repo := s.repositoryFor(&revision, true)
|
||||
if q.Summary {
|
||||
repo = repo.WithSummarySQL(masterMetadataBase + ` SELECT count(*),'{}'::text,'[]'::text,'[]'::text FROM records WHERE $5::text IS NOT NULL AND `)
|
||||
}
|
||||
p, e := repo.ListRecords(ctx, q)
|
||||
if e == nil {
|
||||
e = s.masterFiltersStillCurrent(ctx, q)
|
||||
}
|
||||
p.LocalRevision = revision
|
||||
if e == nil {
|
||||
for i := range p.Items {
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
package qaopsmirror
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"knowledge-governance/internal/dobotaiops"
|
||||
"knowledge-governance/internal/qaopsquality"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type masterFilterCache struct {
|
||||
mu sync.Mutex
|
||||
key string
|
||||
version string
|
||||
ids []string
|
||||
expires time.Time
|
||||
}
|
||||
|
||||
func matchesMasterFilters(v qaopsquality.View, filters map[string]string) bool {
|
||||
values := map[string]string{"coverage": map[string]string{"covered": "是", "uncovered": "否", "excluded": "否但不计入", "partial": "部分覆盖", "pending": "待确认"}[v.Effective.Coverage], "is_correct": map[string]string{"correct": "是", "incorrect": "否", "pending": "待确认"}[v.Effective.Correctness], "product_category": v.Effective.ProductCategory, "problem_type": v.Effective.ProblemType, "root_cause": v.Effective.RootCause}
|
||||
for key, wanted := range filters {
|
||||
value := values[key]
|
||||
if wanted == "__empty__" {
|
||||
if value != "" {
|
||||
return false
|
||||
}
|
||||
} else if value != wanted {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
func (s *Store) masterFilterVersion(ctx context.Context) (string, error) {
|
||||
var version string
|
||||
err := s.pool.QueryRow(ctx, `SELECT COALESCE((SELECT revision FROM qa_ops_master_state WHERE tenant_key=$1),0)::text||':'||COALESCE((SELECT sum(input_epoch+review_revision) FROM qa_ops_quality_records WHERE tenant_key=$1||'::master'),0)::text||':'||COALESCE((SELECT count(*) FROM qa_ops_quality_records WHERE tenant_key=$1||'::master'),0)::text`, s.tenant).Scan(&version)
|
||||
return version, err
|
||||
}
|
||||
|
||||
// Cache contains only IDs, never answers; every request checks data/review versions.
|
||||
func (s *Store) resolveMasterFilters(ctx context.Context, q dobotaiops.RecordQuery) (dobotaiops.RecordQuery, error) {
|
||||
if !dobotaiops.ValidQualityFilters(q.Filters) {
|
||||
return q, dobotaiops.ErrInvalidQuery
|
||||
}
|
||||
if len(q.Filters) == 0 {
|
||||
return q, nil
|
||||
}
|
||||
if !s.valid() {
|
||||
return q, dobotaiops.ErrUnavailable
|
||||
}
|
||||
version, err := s.masterFilterVersion(ctx)
|
||||
if err != nil {
|
||||
return q, err
|
||||
}
|
||||
raw, _ := json.Marshal(struct {
|
||||
Keyword string
|
||||
End time.Time
|
||||
Filters map[string]string
|
||||
Version string
|
||||
}{q.Keyword, q.Window.End, q.Filters, version})
|
||||
sum := sha256.Sum256(raw)
|
||||
key := hex.EncodeToString(sum[:])
|
||||
cache := &s.masterFilters
|
||||
cache.mu.Lock()
|
||||
defer cache.mu.Unlock()
|
||||
if cache.key == key && time.Now().Before(cache.expires) {
|
||||
q.MatchIDs = append([]string{}, cache.ids...)
|
||||
q.MatchVersion = version
|
||||
return q, nil
|
||||
}
|
||||
tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.RepeatableRead, AccessMode: pgx.ReadOnly})
|
||||
if err != nil {
|
||||
return q, err
|
||||
}
|
||||
defer tx.Rollback(context.Background())
|
||||
timeline, _, err := s.readMasterQualityTimeline(ctx, tx, q.Keyword, q.Window.End)
|
||||
if err != nil {
|
||||
return q, err
|
||||
}
|
||||
ids := []string{}
|
||||
for _, row := range timeline {
|
||||
if matchesMasterFilters(row.view, q.Filters) {
|
||||
ids = append(ids, row.id)
|
||||
}
|
||||
}
|
||||
if err = tx.Commit(ctx); err != nil {
|
||||
return q, err
|
||||
}
|
||||
latest, err := s.masterFilterVersion(ctx)
|
||||
if err != nil {
|
||||
return q, err
|
||||
}
|
||||
if latest != version {
|
||||
return q, qaopsquality.ErrConflict
|
||||
}
|
||||
cache.key = key
|
||||
cache.version = version
|
||||
cache.ids = ids
|
||||
cache.expires = time.Now().Add(30 * time.Second)
|
||||
q.MatchIDs = append([]string{}, ids...)
|
||||
q.MatchVersion = version
|
||||
return q, nil
|
||||
}
|
||||
func (s *Store) masterFiltersStillCurrent(ctx context.Context, q dobotaiops.RecordQuery) error {
|
||||
if q.MatchVersion == "" {
|
||||
return nil
|
||||
}
|
||||
now, err := s.masterFilterVersion(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if now != q.MatchVersion {
|
||||
return qaopsquality.ErrConflict
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package qaopsmirror
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"knowledge-governance/internal/dobotaiops"
|
||||
pg "knowledge-governance/internal/platform/postgres"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestMasterFiveFiltersUseEffectiveValuesAcrossAllPagesAndDashboard(t *testing.T) {
|
||||
p := mirrorFixture(t)
|
||||
ctx := context.Background()
|
||||
s := NewStore(p, "fixture-five-filters")
|
||||
queue := pg.NewStore(p)
|
||||
entry := fixtureEntry("filter-one", "有效答案")
|
||||
job := claimMirrorJob(t, s, queue, "filter-seed")
|
||||
if err := s.HandleSync(ctx, *job, func(context.Context) (Source, error) {
|
||||
return &fixtureSource{rows: []dobotaiops.MirrorRecord{entry}}, nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := p.Exec(ctx, `UPDATE qa_ops_master_records SET local_fields='{"coverage":"是","is_correct":"否","product_category":"MG400","problem_type":"参数","root_cause":"知识缺失"}',row_revision=row_revision+1 WHERE tenant_key=$1`, s.tenant)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = p.Exec(ctx, `INSERT INTO qa_ops_master_records(tenant_key,id,event_key,origin,asked_at,question,status,identity,quality_hash,payload,local_fields) SELECT tenant_key,'filter-two',repeat('c',64),origin,asked_at,question,status,identity,repeat('d',64),payload||jsonb_build_object('id','filter-two','_quality_hash',repeat('d',64)),'{"coverage":"是","is_correct":"是","product_category":"CR","problem_type":"参数","root_cause":"知识缺失"}' FROM qa_ops_master_records WHERE tenant_key=$1`, s.tenant)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// The real INSERT trigger initializes local_fields from the payload; use a coherent post-insert edit.
|
||||
_, err = p.Exec(ctx, `UPDATE qa_ops_master_records SET local_fields='{"coverage":"是","is_correct":"是","product_category":"CR","problem_type":"参数","root_cause":"知识缺失"}',row_revision=row_revision+1 WHERE tenant_key=$1 AND id='filter-two'`, s.tenant)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
start := time.Now().UTC().Add(-48 * time.Hour)
|
||||
q := dobotaiops.RecordQuery{Window: dobotaiops.Window{TenantKey: s.tenant, Start: start, End: time.Now().UTC()}, Limit: 1, Filters: map[string]string{"is_correct": "否", "product_category": "MG400"}}
|
||||
page, err := s.ListMasterRecords(ctx, q)
|
||||
if err != nil || page.Total != 1 || len(page.Items) != 1 || page.Items[0].ID != "filter-one" {
|
||||
t.Fatalf("effective filters ignored: total=%d err=%v", page.Total, err)
|
||||
}
|
||||
q.Filters = map[string]string{"root_cause": "__empty__"}
|
||||
page, err = s.ListMasterRecords(ctx, q)
|
||||
if err != nil || page.Total != 1 || page.Items[0].ID != "filter-two" {
|
||||
t.Fatal("correct answers must have effective empty root cause", err, page.Total)
|
||||
}
|
||||
q.Filters = map[string]string{"product_category": "MG400", "root_cause": "知识缺失", "problem_type": "参数", "coverage": "是", "is_correct": "否"}
|
||||
got, err := s.ReadMasterDashboard(ctx, q)
|
||||
if err != nil || got.Quality.Total != 1 || got.TotalIssues != 1 {
|
||||
t.Fatal("dashboard/list filters differ", err, got.Quality.Total, got.TotalIssues)
|
||||
}
|
||||
|
||||
q.Filters = map[string]string{"problem_type": "参数"}
|
||||
q.Cursor = ""
|
||||
first, err := s.ListMasterRecords(ctx, q)
|
||||
if err != nil || !first.HasMore || first.Total != 2 {
|
||||
t.Fatalf("filter pagination first page err=%v total=%d hasMore=%v items=%d", err, first.Total, first.HasMore, len(first.Items))
|
||||
}
|
||||
q.Cursor = first.NextCursor
|
||||
last, err := s.ListMasterRecords(ctx, q)
|
||||
if err != nil || last.HasMore || len(last.Items) != 1 || last.Items[0].ID == first.Items[0].ID {
|
||||
t.Fatal("filter pagination duplicate/missing", err)
|
||||
}
|
||||
q.Filters = map[string]string{"product_category": "MG400"}
|
||||
if _, err = s.ListMasterRecords(ctx, q); err == nil {
|
||||
t.Fatal("cursor reused under another filter")
|
||||
}
|
||||
q.Cursor = ""
|
||||
q.Filters = map[string]string{"product_category": "MG400", "root_cause": "知识缺失", "problem_type": "参数", "coverage": "是", "is_correct": "否"}
|
||||
// A label update must invalidate a cached scope without restarting the service.
|
||||
_, err = p.Exec(ctx, `UPDATE qa_ops_master_records SET local_fields=local_fields||'{"product_category":"CR"}',row_revision=row_revision+1 WHERE tenant_key=$1 AND id='filter-one'`, s.tenant)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
page, err = s.ListMasterRecords(ctx, q)
|
||||
if err != nil || page.Total != 0 {
|
||||
t.Fatal("cached filter scope survived changed labels", err)
|
||||
}
|
||||
raw, _ := json.Marshal(page)
|
||||
_ = raw
|
||||
}
|
||||
@@ -22,6 +22,7 @@ type Store struct {
|
||||
mu sync.RWMutex
|
||||
safety dobotaiops.SourceSafety
|
||||
departmentStats departmentStatisticsCache
|
||||
masterFilters masterFilterCache
|
||||
}
|
||||
|
||||
func NewStore(pool *pgxpool.Pool, tenant string) *Store { return &Store{pool: pool, tenant: tenant} }
|
||||
|
||||
+16
-3
@@ -191,11 +191,15 @@ function QAAIBatchControls({ batch, onChanged }: { batch: AIBatch; onChanged: ()
|
||||
export function QAAIWorkbench({
|
||||
initial,
|
||||
query,
|
||||
filteredIDs,
|
||||
filterRevision,
|
||||
onStatus,
|
||||
onRecordChanged,
|
||||
}: {
|
||||
initial?: AIStatus
|
||||
query: QAQuery | null
|
||||
filteredIDs?: string[]
|
||||
filterRevision?: number
|
||||
onStatus?: (status: AIStatus) => void
|
||||
onRecordChanged?: (id: string) => void
|
||||
}) {
|
||||
@@ -265,13 +269,22 @@ export function QAAIWorkbench({
|
||||
}
|
||||
}
|
||||
const ready = Boolean(status?.settings.verification === 'verified' && status.settings.storage_ready)
|
||||
const scope = query ? { start: query.start, end: query.end, keyword: query.keyword } : null
|
||||
const scopeKey = query ? `${query.start}:${query.end}:${query.keyword}` : 'none'
|
||||
const filtered = Boolean(query?.filters && Object.keys(query.filters).length)
|
||||
const scope = filtered
|
||||
? filteredIDs?.length
|
||||
? { ids: filteredIDs }
|
||||
: null
|
||||
: query
|
||||
? { start: query.start, end: query.end, keyword: query.keyword }
|
||||
: null
|
||||
const scopeKey = query
|
||||
? `${query.start}:${query.end}:${query.keyword}:${JSON.stringify(query.filters ?? {})}:${filteredIDs?.length ?? 'loading'}:${filterRevision ?? ''}`
|
||||
: 'none'
|
||||
return (
|
||||
<section className="qa-ai-workbench" aria-label="问答后台任务">
|
||||
<div className="qa-ai-actions">
|
||||
<span>当前已执行筛选</span>
|
||||
<QAAIRequest key={scopeKey} scope={scope} label="AI分析" disabled={!ready} onQueued={refresh} />
|
||||
<QAAIRequest key={scopeKey} scope={scope} label="AI分析" disabled={!ready || !scope} onQueued={refresh} />
|
||||
{status ? (
|
||||
<small>
|
||||
{status.worker_ready ? '执行器在线' : '执行器未在线,任务会持久保留'}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { editableFieldKeys, fetchFieldOptions } from './qaInlineFields'
|
||||
import type { FieldOptions } from './qaInlineFields'
|
||||
import type { QAQuery } from './qaOps'
|
||||
export const qualityFilterLabels = {
|
||||
coverage: '是否覆盖',
|
||||
is_correct: '是否正确',
|
||||
product_category: '产品分类',
|
||||
problem_type: '问题类型',
|
||||
root_cause: '根因分析',
|
||||
}
|
||||
export function QAFieldFilters({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: NonNullable<QAQuery['filters']>
|
||||
onChange: (value: NonNullable<QAQuery['filters']>) => void
|
||||
}) {
|
||||
const [options, setOptions] = useState<FieldOptions | null>(null),
|
||||
[error, setError] = useState(''),
|
||||
[revision, setRevision] = useState(0)
|
||||
useEffect(() => {
|
||||
const abort = new AbortController()
|
||||
setError('')
|
||||
void fetchFieldOptions(abort.signal)
|
||||
.then((v) => {
|
||||
if (!abort.signal.aborted) setOptions(v)
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!abort.signal.aborted) setError(e instanceof Error ? e.message : '筛选选项读取失败')
|
||||
})
|
||||
return () => abort.abort()
|
||||
}, [revision])
|
||||
return (
|
||||
<div className="qa-field-filters" aria-label="质量字段筛选">
|
||||
{editableFieldKeys.map((key) => (
|
||||
<label key={key}>
|
||||
{qualityFilterLabels[key]}
|
||||
<select
|
||||
aria-label={'筛选' + qualityFilterLabels[key]}
|
||||
value={value[key] ?? ''}
|
||||
disabled={!options}
|
||||
onChange={(e) => {
|
||||
const next = { ...value }
|
||||
if (e.target.value) next[key] = e.target.value
|
||||
else delete next[key]
|
||||
onChange(next)
|
||||
}}
|
||||
>
|
||||
<option value="">全部</option>
|
||||
<option value="__empty__">未设置</option>
|
||||
{options?.[key].map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{key === 'is_correct'
|
||||
? ({ 是: '正确', 否: '错误', 待确认: '待确认' }[option] ?? option)
|
||||
: key === 'coverage'
|
||||
? ({ 是: '已覆盖', 否: '未覆盖', 否但不计入: '不计入' }[option] ?? option)
|
||||
: option}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
))}
|
||||
<button type="button" disabled={!Object.keys(value).length} onClick={() => onChange({})}>
|
||||
清除字段筛选
|
||||
</button>
|
||||
{error ? (
|
||||
<span role="alert">
|
||||
{error}{' '}
|
||||
<button type="button" onClick={() => setRevision((v) => v + 1)}>
|
||||
重试选项
|
||||
</button>
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -52,6 +52,18 @@ const props = {
|
||||
}
|
||||
|
||||
describe('QA operations workbench', () => {
|
||||
it('keeps the first rows interactive while later batches load or fail', () => {
|
||||
const prefix = { ...page, total: 2, has_more: true, next_cursor: 'next' }
|
||||
const loading = renderToStaticMarkup(<QAOperationsView {...props} page={prefix} loading={true} />)
|
||||
expect(loading).toContain('data-qa-row="fixture"')
|
||||
expect(loading).toContain('后台加载中')
|
||||
expect(loading).not.toContain('正在读取全部问答')
|
||||
const failed = renderToStaticMarkup(<QAOperationsView {...props} page={prefix} error="明确续载失败" />)
|
||||
expect(failed).toContain('未完整读取')
|
||||
expect(failed).toContain('明确续载失败')
|
||||
expect(failed).toContain('data-qa-row="fixture"')
|
||||
})
|
||||
|
||||
it('edits five fields directly in the first row without a separate review or feedback form', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<QAOperationsView {...props} detail={{ ...item, record_origin: 'historical_excel' }} />
|
||||
|
||||
+70
-11
@@ -9,6 +9,8 @@ import { QARegionalStatistics } from './QADepartmentStatistics'
|
||||
import { QADashboard } from './QADashboard'
|
||||
import { QAWeekly } from './QAWeekly'
|
||||
import { QAInlineFields } from './QAInlineFields'
|
||||
import { QAFieldFilters } from './QAFieldFilters'
|
||||
import { buildQAWeeklyCSV, downloadQAWeekly } from './qaWeekly'
|
||||
import { qaFieldColors, qaTagColumns } from './qaFieldColors'
|
||||
import { qualityLabels } from './qaQuality'
|
||||
import { QAWeekPicker } from './QAWeekPicker'
|
||||
@@ -169,10 +171,10 @@ export function QAOperationsView(props: ViewProps) {
|
||||
<section className="qa-ops-results" aria-label="问答记录">
|
||||
<div className="qa-ops-result-bar">
|
||||
<span>
|
||||
{props.loading
|
||||
? `正在读取全部问答…${props.progress ? `(${props.progress.loaded} / ${props.progress.total})` : ''}`
|
||||
: page
|
||||
? `共 ${page.total} 条 · 已载入 ${page.items.length} 条`
|
||||
{page
|
||||
? `共 ${page.total} 条 · 已载入 ${page.items.length} 条${props.loading ? ' · 后台加载中' : props.error || page.has_more ? ' · 未完整读取' : ''}`
|
||||
: props.loading
|
||||
? '正在读取首批问答…'
|
||||
: '尚未读取问答'}
|
||||
</span>
|
||||
<small className="qa-ops-muted">双击行查看详情</small>
|
||||
@@ -360,6 +362,10 @@ export function QAOperations() {
|
||||
const auth = useOptionalAuth()
|
||||
const canRead = auth?.identity?.role === 'admin'
|
||||
const [draft, setDraft] = useState(initialDraft)
|
||||
const [filters, setFilters] = useState<NonNullable<QAQuery['filters']>>({})
|
||||
const [exporting, setExporting] = useState(false),
|
||||
[exportError, setExportError] = useState('')
|
||||
const exportRequest = useRef<AbortController | null>(null)
|
||||
const [query, setQuery] = useState<QAQuery | null>(null)
|
||||
const [page, setPage] = useState<QARecordPage | null>(null)
|
||||
const [status, setStatus] = useState<QAStatus | null>(null)
|
||||
@@ -415,9 +421,18 @@ export function QAOperations() {
|
||||
.catch(() => {
|
||||
if (!controller.signal.aborted) setStatus(null)
|
||||
})
|
||||
void fetchAllQARecords({ ...query, dataset: 'master' }, controller.signal, (loaded, total) => {
|
||||
if (!controller.signal.aborted) setProgress({ loaded, total })
|
||||
})
|
||||
const publish = (result: QARecordPage) => {
|
||||
if (!controller.signal.aborted)
|
||||
setPage({ ...result, items: result.items.map((item) => freshAI.current.get(item.id) ?? item) })
|
||||
}
|
||||
void fetchAllQARecords(
|
||||
{ ...query, dataset: 'master', projection: 'summary' },
|
||||
controller.signal,
|
||||
(loaded, total) => {
|
||||
if (!controller.signal.aborted) setProgress({ loaded, total })
|
||||
},
|
||||
publish
|
||||
)
|
||||
.then((result) => {
|
||||
if (!controller.signal.aborted)
|
||||
setPage({ ...result, items: result.items.map((item) => freshAI.current.get(item.id) ?? item) })
|
||||
@@ -428,8 +443,33 @@ export function QAOperations() {
|
||||
.finally(() => {
|
||||
if (!controller.signal.aborted) setLoading(false)
|
||||
})
|
||||
return () => controller.abort()
|
||||
return () => {
|
||||
controller.abort()
|
||||
generation.current += 1
|
||||
}
|
||||
}, [query, canRead, revision, view])
|
||||
useEffect(() => {
|
||||
exportRequest.current?.abort()
|
||||
setExporting(false)
|
||||
setExportError('')
|
||||
return () => exportRequest.current?.abort()
|
||||
}, [query, view])
|
||||
const exportCSV = async () => {
|
||||
if (!query || exporting) return
|
||||
const abort = new AbortController()
|
||||
exportRequest.current = abort
|
||||
setExporting(true)
|
||||
setExportError('')
|
||||
try {
|
||||
const result = await fetchAllQARecords({ ...query, dataset: 'master', projection: 'full' }, abort.signal)
|
||||
if (!abort.signal.aborted)
|
||||
downloadQAWeekly(buildQAWeeklyCSV(result), 'DobotAI-Questions.csv', 'text/csv;charset=utf-8')
|
||||
} catch (e) {
|
||||
if (!abort.signal.aborted) setExportError(e instanceof Error ? e.message : '完整明细导出失败')
|
||||
} finally {
|
||||
if (!abort.signal.aborted) setExporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
@@ -445,7 +485,7 @@ export function QAOperations() {
|
||||
end = qaDateTimeToISO(draft.end)
|
||||
if (!(Date.parse(start) < Date.parse(end))) throw new Error('开始时间必须早于结束时间')
|
||||
if (Date.parse(start) >= Date.now()) throw new Error('所选周或时间尚未开始,请选择已开始的时间范围')
|
||||
setQuery({ start, end, keyword: draft.keyword, cursor: '' })
|
||||
setQuery({ start, end, keyword: draft.keyword, cursor: '', filters: { ...filters } })
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : '时间格式无效')
|
||||
}
|
||||
@@ -531,6 +571,7 @@ export function QAOperations() {
|
||||
end: qaDateTimeToISO(current.end),
|
||||
keyword: draft.keyword,
|
||||
cursor: '',
|
||||
filters: { ...filters },
|
||||
})
|
||||
}}
|
||||
onChange={(year, week) =>
|
||||
@@ -567,9 +608,10 @@ export function QAOperations() {
|
||||
onChange={(e) => setDraft({ ...draft, keyword: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<button className="qa-query-action" type="submit" disabled={loading}>
|
||||
<button className="qa-query-action" type="submit">
|
||||
查询
|
||||
</button>
|
||||
<QAFieldFilters value={filters} onChange={setFilters} />
|
||||
<QAMirrorSync
|
||||
onChanged={() => {
|
||||
if (detail || detailLoading) {
|
||||
@@ -583,7 +625,23 @@ export function QAOperations() {
|
||||
/>
|
||||
</form>
|
||||
<div hidden={view !== 'records'}>
|
||||
<QAAIWorkbench query={query} onStatus={setAIStatus} onRecordChanged={refreshAIRecord} />
|
||||
<QAAIWorkbench
|
||||
query={query}
|
||||
filteredIDs={
|
||||
query?.filters && Object.keys(query.filters).length && page && !loading && !error && !page.has_more
|
||||
? page.items.map((item) => item.id)
|
||||
: undefined
|
||||
}
|
||||
filterRevision={page?.local_revision}
|
||||
onStatus={setAIStatus}
|
||||
onRecordChanged={refreshAIRecord}
|
||||
/>
|
||||
<div className="qa-ledger-export">
|
||||
<button type="button" disabled={!query || exporting} onClick={() => void exportCSV()}>
|
||||
{exporting ? '正在导出完整明细…' : '导出全部明细'}
|
||||
</button>
|
||||
{exportError ? <span role="alert">{exportError}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
@@ -621,6 +679,7 @@ export function QAOperations() {
|
||||
}}
|
||||
onQualitySaved={(fresh) => {
|
||||
if (fresh.id !== detail?.id) return
|
||||
freshAI.current.set(fresh.id, fresh)
|
||||
setDetail(fresh)
|
||||
setPage((old) =>
|
||||
old ? { ...old, items: old.items.map((item) => (item.id === fresh.id ? fresh : item)) } : old
|
||||
|
||||
@@ -29,6 +29,57 @@ const query = { start: '2026-10-11T16:00:00Z', end: '2026-10-18T08:00:00Z', keyw
|
||||
const respond = (data: unknown) => new Response(JSON.stringify({ data }), { status: 200 })
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
describe('all QA records without manual pagination', () => {
|
||||
it('retains only the verified prefix when a later local version changes', async () => {
|
||||
const first = { ...page(['a'], 2, 'n'), local_revision: 1 },
|
||||
second = { ...page(['b'], 2), local_revision: 2 }
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(respond(first)).mockResolvedValueOnce(respond(second)))
|
||||
const batches = vi.fn()
|
||||
await expect(fetchAllQARecords(query, undefined, undefined, batches)).rejects.toThrow('本地问答已更新')
|
||||
expect(batches).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
it('carries every active quality filter and summary projection to every batch', async () => {
|
||||
const fetcher = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(respond(page(['a'], 2, 'n')))
|
||||
.mockResolvedValueOnce(respond(page(['b'], 2)))
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
const filters = {
|
||||
coverage: '是',
|
||||
is_correct: '否',
|
||||
product_category: 'MG400',
|
||||
problem_type: '参数',
|
||||
root_cause: '__empty__',
|
||||
}
|
||||
await fetchAllQARecords({ ...query, dataset: 'master', projection: 'summary', filters })
|
||||
for (const [url] of fetcher.mock.calls) {
|
||||
const u = new URL(String(url), 'http://localhost')
|
||||
expect(u.searchParams.get('projection')).toBe('summary')
|
||||
for (const [key, value] of Object.entries(filters)) expect(u.searchParams.get(key)).toBe(value)
|
||||
}
|
||||
})
|
||||
|
||||
it('publishes a usable first batch before the next response completes', async () => {
|
||||
let release!: (response: Response) => void
|
||||
const waiting = new Promise<Response>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(respond(page(['a'], 2, 'n')))
|
||||
.mockReturnValueOnce(waiting)
|
||||
)
|
||||
const batches = vi.fn()
|
||||
const done = fetchAllQARecords(query, undefined, undefined, batches)
|
||||
await vi.waitFor(() => expect(batches).toHaveBeenCalledTimes(1))
|
||||
expect(batches.mock.calls[0][0].items.map((r: { id: string }) => r.id)).toEqual(['a'])
|
||||
expect(batches.mock.calls[0][0].has_more).toBe(true)
|
||||
release(respond(page(['b'], 2)))
|
||||
expect((await done).items).toHaveLength(2)
|
||||
expect(batches).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('rejects incomplete, duplicate, drifting and cycling batches', async () => {
|
||||
const cases = [
|
||||
[page(['a'], 2)],
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { parseQAOrganizationVersion } from './qaOps'
|
||||
import { parseQAOrganizationVersion, parseQualityFilters, qualityFiltersKey } from './qaOps'
|
||||
import { currentQAWeek } from './qaWeeks'
|
||||
import type { QAOrganizationVersion, QAQuery } from './qaOps'
|
||||
import { parseQualitySummary, parseQualityRate } from './qaQuality'
|
||||
@@ -68,6 +68,7 @@ export interface DashboardRegion {
|
||||
usage_coverage: QualityRate
|
||||
}
|
||||
export interface QADashboardData {
|
||||
filters?: QAQuery['filters']
|
||||
start: string
|
||||
end: string
|
||||
keyword: string
|
||||
@@ -448,6 +449,7 @@ export function parseQADashboard(value: unknown): QADashboardData {
|
||||
} else if (monthly_quality.records !== 0) return bad()
|
||||
}
|
||||
return {
|
||||
filters: parseQualityFilters(v.filters),
|
||||
quality_period,
|
||||
monthly_quality,
|
||||
calendar_months,
|
||||
@@ -490,7 +492,12 @@ export async function fetchQADashboard(query: QAQuery, signal?: AbortSignal): Pr
|
||||
cache: 'no-store',
|
||||
signal,
|
||||
headers: { 'Content-Type': 'application/json', 'X-Governance-Action': 'qa-dashboard' },
|
||||
body: JSON.stringify({ start: query.start, end: query.end, keyword: query.keyword }),
|
||||
body: JSON.stringify({
|
||||
start: query.start,
|
||||
end: query.end,
|
||||
keyword: query.keyword,
|
||||
...(query.filters && Object.keys(query.filters).length ? { filters: query.filters } : {}),
|
||||
}),
|
||||
})
|
||||
const body: unknown = await response.json().catch(() => null)
|
||||
if (!response.ok) {
|
||||
@@ -501,7 +508,8 @@ export async function fetchQADashboard(query: QAQuery, signal?: AbortSignal): Pr
|
||||
if (
|
||||
Date.parse(data.start) !== Date.parse(query.start) ||
|
||||
Date.parse(data.end) !== Date.parse(query.end) ||
|
||||
data.keyword !== query.keyword
|
||||
data.keyword !== query.keyword ||
|
||||
qualityFiltersKey(data.filters) !== qualityFiltersKey(query.filters)
|
||||
)
|
||||
throw new Error('看板返回范围与当前查询不一致,请重新读取')
|
||||
return data
|
||||
|
||||
@@ -704,3 +704,41 @@
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border-base);
|
||||
}
|
||||
|
||||
.qa-field-filters {
|
||||
display: flex;
|
||||
flex: 1 0 100%;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
.qa-ops-query .qa-field-filters label {
|
||||
flex: 1 1 140px;
|
||||
min-width: 0;
|
||||
}
|
||||
.qa-field-filters select {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: 36px;
|
||||
padding: 6px 9px;
|
||||
color: var(--ink-strong);
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--r-sm);
|
||||
}
|
||||
.qa-ledger-export {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.qa-ledger-export [role='alert'] {
|
||||
color: var(--danger-ink);
|
||||
font-size: 12px;
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.qa-ops-query .qa-field-filters label {
|
||||
flex: 1 1 120px;
|
||||
}
|
||||
}
|
||||
|
||||
+32
-2
@@ -78,12 +78,14 @@ export function mergeQAPages(current: QARecordPage, next: QARecordPage): QARecor
|
||||
current.organization?.revision_id !== next.organization?.revision_id
|
||||
)
|
||||
throw new Error('通讯录或区域配置已变化,请重新查询,不能混合不同版本的分页结果')
|
||||
const ids = new Set(current.items.map((item) => item.id))
|
||||
return {
|
||||
...next,
|
||||
items: [...current.items, ...next.items.filter((item) => !current.items.some((old) => old.id === item.id))],
|
||||
items: [...current.items, ...next.items.filter((item) => !ids.has(item.id))],
|
||||
}
|
||||
}
|
||||
export interface QARecord {
|
||||
content_projection?: 'summary'
|
||||
field_edit_revision?: number
|
||||
background_tasks?: AIWorkState[]
|
||||
record_origin?: 'dobotai_2_0' | 'historical_excel'
|
||||
@@ -128,6 +130,8 @@ export interface QARecordPage {
|
||||
as_of: string
|
||||
}
|
||||
export interface QAQuery {
|
||||
projection?: 'summary' | 'full'
|
||||
filters?: Partial<Record<'coverage' | 'is_correct' | 'product_category' | 'problem_type' | 'root_cause', string>>
|
||||
dataset?: 'master'
|
||||
start: string
|
||||
end: string
|
||||
@@ -135,6 +139,25 @@ export interface QAQuery {
|
||||
cursor: string
|
||||
limit?: number
|
||||
}
|
||||
export function qualityFiltersKey(filters: QAQuery['filters']): string {
|
||||
return JSON.stringify(Object.entries(filters ?? {}).sort(([a], [b]) => a.localeCompare(b)))
|
||||
}
|
||||
export function parseQualityFilters(value: unknown): NonNullable<QAQuery['filters']> {
|
||||
if (value === undefined) return {}
|
||||
const fields = object(value),
|
||||
out: NonNullable<QAQuery['filters']> = {}
|
||||
for (const [key, v] of Object.entries(fields)) {
|
||||
if (
|
||||
!['coverage', 'is_correct', 'product_category', 'problem_type', 'root_cause'].includes(key) ||
|
||||
typeof v !== 'string' ||
|
||||
!v ||
|
||||
v.length > 400
|
||||
)
|
||||
return invalid()
|
||||
out[key as keyof typeof out] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
export interface QAStatus {
|
||||
state: 'disabled' | 'unavailable' | 'blocked' | 'connected'
|
||||
code: string
|
||||
@@ -163,6 +186,7 @@ const columnKeys = new Set<string>(qaColumns.map(({ key }) => key))
|
||||
|
||||
export function parseQARecord(value: unknown): QARecord {
|
||||
const row = object(value)
|
||||
if (row.content_projection !== undefined && row.content_projection !== 'summary') return invalid()
|
||||
const feedback = object(row.feedback)
|
||||
const source = object(row.source_coverage)
|
||||
const coverage = object(row.coverage)
|
||||
@@ -182,6 +206,7 @@ export function parseQARecord(value: unknown): QARecord {
|
||||
const askerValue = text(feedback.asker_value)
|
||||
if (!['', 'up', 'down'].includes(askerValue)) return invalid()
|
||||
const item: QARecord = {
|
||||
...(row.content_projection === 'summary' ? { content_projection: 'summary' as const } : {}),
|
||||
...(row.quality === undefined
|
||||
? {}
|
||||
: {
|
||||
@@ -332,12 +357,15 @@ export async function fetchQARecords(query: QAQuery, signal?: AbortSignal): Prom
|
||||
cursor: query.cursor,
|
||||
limit: String(query.limit ?? 20),
|
||||
})
|
||||
if (query.projection) parameters.set('projection', query.projection)
|
||||
for (const [key, value] of Object.entries(query.filters ?? {})) if (value) parameters.set(key, value)
|
||||
return parseQARecordPage(await getPayload(`${qaDatasetPrefix(query.dataset)}/records?${parameters}`, signal))
|
||||
}
|
||||
export async function fetchAllQARecords(
|
||||
query: QAQuery,
|
||||
signal?: AbortSignal,
|
||||
onProgress?: (loaded: number, total: number) => void
|
||||
onProgress?: (loaded: number, total: number) => void,
|
||||
onPage?: (page: QARecordPage) => void
|
||||
): Promise<QARecordPage> {
|
||||
if (signal?.aborted) throw new DOMException('读取已取消', 'AbortError')
|
||||
let page = await fetchQARecords({ ...query, cursor: '', limit: 100 }, signal)
|
||||
@@ -345,6 +373,7 @@ export async function fetchAllQARecords(
|
||||
const ids = new Set(page.items.map((item) => item.id))
|
||||
const cursors = new Set<string>()
|
||||
onProgress?.(ids.size, total)
|
||||
onPage?.(page)
|
||||
while (page.has_more) {
|
||||
if (signal?.aborted) throw new DOMException('读取已取消', 'AbortError')
|
||||
const cursor = page.next_cursor
|
||||
@@ -358,6 +387,7 @@ export async function fetchAllQARecords(
|
||||
}
|
||||
page = mergeQAPages(page, next)
|
||||
onProgress?.(ids.size, total)
|
||||
onPage?.(page)
|
||||
}
|
||||
if (ids.size !== total) throw new Error('全量读取数量不完整,请重试,未将部分记录当作全部结果')
|
||||
return page
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { buildQAWeeklyCSV } from './qaWeekly'
|
||||
import { parseQualityFilters, qualityFiltersKey } from './qaOps'
|
||||
import type { QARecordPage, QARecord } from './qaOps'
|
||||
describe('quality filter and full-export contracts', () => {
|
||||
it('compares filters independently of property ordering and rejects unknown fields', () => {
|
||||
expect(qualityFiltersKey({ coverage: '是', is_correct: '否' })).toBe(
|
||||
qualityFiltersKey({ is_correct: '否', coverage: '是' })
|
||||
)
|
||||
expect(() => parseQualityFilters({ tenant: 'x' })).toThrow()
|
||||
expect(() => parseQualityFilters({ coverage: 4 })).toThrow()
|
||||
})
|
||||
it('rejects a complete-count page of truncated previews as a full export', () => {
|
||||
const page = {
|
||||
has_more: false,
|
||||
total: 1,
|
||||
items: [{ id: 'fixture', content_projection: 'summary' } as QARecord],
|
||||
} as QARecordPage
|
||||
expect(() => buildQAWeeklyCSV(page)).toThrow('预览')
|
||||
})
|
||||
})
|
||||
@@ -35,6 +35,8 @@ function csvCell(value: unknown) {
|
||||
return '"' + safe.replace(/"/g, '""') + '"'
|
||||
}
|
||||
export function buildQAWeeklyCSV(page: QARecordPage): string {
|
||||
if (page.items.some((row) => row.content_projection === 'summary'))
|
||||
throw Error('列表仅为预览,请重新读取完整明细后导出')
|
||||
if (page.has_more || page.total !== page.items.length || new Set(page.items.map((r) => r.id)).size !== page.total)
|
||||
throw Error('明细未完整读取,未导出部分数据')
|
||||
const csv =
|
||||
|
||||
Reference in New Issue
Block a user