diff --git a/CHANGELOG.md b/CHANGELOG.md index c7e2a37..30bbe63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/api/openapi.yaml b/api/openapi.yaml index f57c5c0..eafcc7d 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -125,7 +125,7 @@ "get": {"tags":["QA operations"],"operationId":"getQAOrganizationSync","summary":"管理员读取通讯录同步执行器状态及最近任务的安全汇总,不返回人员名单或运行配置","responses":{"200":{"$ref":"#/components/responses/Envelope"},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"description":"需要登录"},"403":{"description":"仅管理员"},"500":{"$ref":"#/components/responses/InternalError"},"503":{"description":"状态存储不可用,不伪装为空记录"}}}, "post": {"tags":["QA operations"],"operationId":"startQAOrganizationSync","summary":"管理员同源发起持久化通讯录同步;重复请求重放同一任务,进行中任务合并;失败保留现有快照","parameters":[{"$ref":"#/components/parameters/IdempotencyKey"},{"name":"X-Governance-Action","in":"header","required":true,"schema":{"const":"qa-organization"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","additionalProperties":false}}}},"responses":{"202":{"$ref":"#/components/responses/OperationEnvelope"},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"description":"需要登录"},"403":{"description":"仅管理员同源操作"},"409":{"description":"操作标识与原请求冲突"},"500":{"$ref":"#/components/responses/InternalError"},"503":{"description":"同步未接线、执行器离线或入队失败;未创建新任务"}}} }, - "/api/v1/qa-ops/dashboard": {"post":{"operationId":"readQAOperationsDashboard","tags":["QA operations"],"summary":"管理员只读正式总表一致性看板:累计数量、自然月质量及区域统计;跨月周展示上月月报","description":"同一只读可重复读事务读取独立正式总表有效字段、质量及组织版本。不访问源端,不冻结或写入指标;区域分母为当前配置通讯录去重人数而非在职/绩效名单。冻结记录按保存的原始标量分子分母及问题文字支持字面关键词筛选,不用当前评估回填历史。reported_weeks 独立返回用户提供的历史百分比(QAReportedHistoricalRate),没有分子分母或冻结时间;只含所选截止前已结束周,关键词非空时为空,原冻结记录与当前指标保持不变。cumulative_weeks 独立返回2026WK40–WK43的master_live_cumulative_v1实时累计曲线,policy_version为weekly-strict-yes-over-yes-no-v1:各周成员为整张总表中提问时间早于北京时间下周一的记录,当前周截止min(查询end,事务当前时点),不被查询start截断;字段取当前有效结论,补值和后续点踩会追溯更新相关累计周。覆盖率与正确率分别按是÷(是+否),待确认/部分覆盖及其他值不入严格分母,零分母percent=null;各点保留成员数、两组分子分母、周边界、实际截止和计算时点。仅查询治理本地总表,不写历史上报/冻结证据。新增 quality_period(统计月、月初、实际截止、计算时点及规则)和 monthly_quality(该月内成员数、独立覆盖及正确率分子分母);以所选周周一所在月为质量归属月,跨月周截止该月下月1日,非跨月周截止查询end,均不超过事务当前时点。calendar_months 返回从首个总表数据月起逐自然月独立计算的月点,policy_version=calendar-month-strict-yes-over-yes-no-v1,source=master_live_calendar_month_v1;每月重置,使用当前有效字段,待确认等排除,零分母null,空月保留,最后点与仪表使用相同截止。旧 reported_weeks、frozen_weeks、cumulative_weeks 保留但不混画到月轴;不平均旧周百分比。网页30秒顺序刷新,已闭查询周不推进到后周。","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", diff --git a/internal/dobotaiops/field_filters.go b/internal/dobotaiops/field_filters.go new file mode 100644 index 0000000..465a468 --- /dev/null +++ b/internal/dobotaiops/field_filters.go @@ -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 +} diff --git a/internal/dobotaiops/record_repository.go b/internal/dobotaiops/record_repository.go index cc3c391..b107622 100644 --- a/internal/dobotaiops/record_repository.go +++ b/internal/dobotaiops/record_repository.go @@ -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}) } diff --git a/internal/dobotaiops/records.go b/internal/dobotaiops/records.go index e27db40..1d425ba 100644 --- a/internal/dobotaiops/records.go +++ b/internal/dobotaiops/records.go @@ -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) } diff --git a/internal/httpapi/qa_dashboard.go b/internal/httpapi/qa_dashboard.go index faf7004..2792cff 100644 --- a/internal/httpapi/qa_dashboard.go +++ b/internal/httpapi/qa_dashboard.go @@ -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) diff --git a/internal/httpapi/qa_ops.go b/internal/httpapi/qa_ops.go index fdc9462..6b3ca00 100644 --- a/internal/httpapi/qa_ops.go +++ b/internal/httpapi/qa_ops.go @@ -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}) } diff --git a/internal/httpapi/qa_preview.go b/internal/httpapi/qa_preview.go new file mode 100644 index 0000000..37cbc85 --- /dev/null +++ b/internal/httpapi/qa_preview.go @@ -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 +} diff --git a/internal/httpapi/qa_preview_test.go b/internal/httpapi/qa_preview_test.go new file mode 100644 index 0000000..8f37d67 --- /dev/null +++ b/internal/httpapi/qa_preview_test.go @@ -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") + } + } +} diff --git a/internal/qaopsmirror/dashboard.go b/internal/qaopsmirror/dashboard.go index 3017848..2b5d56b 100644 --- a/internal/qaopsmirror/dashboard.go +++ b/internal/qaopsmirror/dashboard.go @@ -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"` diff --git a/internal/qaopsmirror/dashboard_cumulative.go b/internal/qaopsmirror/dashboard_cumulative.go index 97868b4..74e258a 100644 --- a/internal/qaopsmirror/dashboard_cumulative.go +++ b/internal/qaopsmirror/dashboard_cumulative.go @@ -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 } diff --git a/internal/qaopsmirror/dashboard_store.go b/internal/qaopsmirror/dashboard_store.go index 10b9ee2..a1dfe1b 100644 --- a/internal/qaopsmirror/dashboard_store.go +++ b/internal/qaopsmirror/dashboard_store.go @@ -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 } diff --git a/internal/qaopsmirror/master.go b/internal/qaopsmirror/master.go index 8680999..d05419d 100644 --- a/internal/qaopsmirror/master.go +++ b/internal/qaopsmirror/master.go @@ -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 { diff --git a/internal/qaopsmirror/master_filters.go b/internal/qaopsmirror/master_filters.go new file mode 100644 index 0000000..a8c4d9e --- /dev/null +++ b/internal/qaopsmirror/master_filters.go @@ -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 +} diff --git a/internal/qaopsmirror/master_filters_integration_test.go b/internal/qaopsmirror/master_filters_integration_test.go new file mode 100644 index 0000000..fabba15 --- /dev/null +++ b/internal/qaopsmirror/master_filters_integration_test.go @@ -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 +} diff --git a/internal/qaopsmirror/store.go b/internal/qaopsmirror/store.go index 9dfd367..17dd6c6 100644 --- a/internal/qaopsmirror/store.go +++ b/internal/qaopsmirror/store.go @@ -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} } diff --git a/web/src/QAAI.tsx b/web/src/QAAI.tsx index 5acdc35..ed22b3c 100644 --- a/web/src/QAAI.tsx +++ b/web/src/QAAI.tsx @@ -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 (
当前已执行筛选 - + {status ? ( {status.worker_ready ? '执行器在线' : '执行器未在线,任务会持久保留'} diff --git a/web/src/QAFieldFilters.tsx b/web/src/QAFieldFilters.tsx new file mode 100644 index 0000000..941dc90 --- /dev/null +++ b/web/src/QAFieldFilters.tsx @@ -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 + onChange: (value: NonNullable) => void +}) { + const [options, setOptions] = useState(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 ( +
+ {editableFieldKeys.map((key) => ( + + ))} + + {error ? ( + + {error}{' '} + + + ) : null} +
+ ) +} diff --git a/web/src/QAOperations.test.tsx b/web/src/QAOperations.test.tsx index eed58bc..14a8a6e 100644 --- a/web/src/QAOperations.test.tsx +++ b/web/src/QAOperations.test.tsx @@ -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() + expect(loading).toContain('data-qa-row="fixture"') + expect(loading).toContain('后台加载中') + expect(loading).not.toContain('正在读取全部问答') + const failed = renderToStaticMarkup() + 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( diff --git a/web/src/QAOperations.tsx b/web/src/QAOperations.tsx index 97872fb..15c5634 100644 --- a/web/src/QAOperations.tsx +++ b/web/src/QAOperations.tsx @@ -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) {
- {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 + ? '正在读取首批问答…' : '尚未读取问答'} 双击行查看详情 @@ -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>({}) + const [exporting, setExporting] = useState(false), + [exportError, setExportError] = useState('') + const exportRequest = useRef(null) const [query, setQuery] = useState(null) const [page, setPage] = useState(null) const [status, setStatus] = useState(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 })} /> - + { if (detail || detailLoading) { @@ -583,7 +625,23 @@ export function QAOperations() { /> ) : 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 diff --git a/web/src/qaAllRecords.test.ts b/web/src/qaAllRecords.test.ts index ba75a61..c4da3db 100644 --- a/web/src/qaAllRecords.test.ts +++ b/web/src/qaAllRecords.test.ts @@ -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((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)], diff --git a/web/src/qaDashboardData.ts b/web/src/qaDashboardData.ts index 5abfbf4..dba15f8 100644 --- a/web/src/qaDashboardData.ts +++ b/web/src/qaDashboardData.ts @@ -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 diff --git a/web/src/qaOps.css b/web/src/qaOps.css index 82fb750..3c2bdae 100644 --- a/web/src/qaOps.css +++ b/web/src/qaOps.css @@ -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; + } +} diff --git a/web/src/qaOps.ts b/web/src/qaOps.ts index 703d165..c51676f 100644 --- a/web/src/qaOps.ts +++ b/web/src/qaOps.ts @@ -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> 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 { + if (value === undefined) return {} + const fields = object(value), + out: NonNullable = {} + 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(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 { 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() 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 diff --git a/web/src/qaSummaryExport.test.ts b/web/src/qaSummaryExport.test.ts new file mode 100644 index 0000000..259e775 --- /dev/null +++ b/web/src/qaSummaryExport.test.ts @@ -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('预览') + }) +}) diff --git a/web/src/qaWeekly.ts b/web/src/qaWeekly.ts index b8f6784..d6ed26c 100644 --- a/web/src/qaWeekly.ts +++ b/web/src/qaWeekly.ts @@ -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 =