diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c8c9ed..54041d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -76,6 +76,19 @@ > `0.1.12`),由 `scripts/version.py` 从 git **算出来**;开发交付的独立说明写入本文件“开发快照”章节,并打 `snapshot/vX.Y.Z` 注释标签 ⇒ 每次开发交付均可追溯, > 但"发布版本"仍然只能由人定(CI 不会自动升版本、不会打 tag)。 +## 开发快照 snapshot/v0.1.41 - 2026-10-20 + +### Added + +- 周报生成升级为按北京时间自然周保存的 Markdown 编辑器,提供编辑、预览、对照、自动保存、立即保存及 MD/本周明细 CSV 下载;每个租户每周一份共享草稿,切周或离开前保存未提交输入,刷新与重新打开恢复正文。 +- 按周报模板预填英文在上、中文在下的各六节正文,复用正式总表和已保存区域统计;历史周优先使用相应上报/冻结质量指标,缺失才使用当周实时指标。统计建议可人工修改,重点保留待填写,不编造 FAQ 调整或英文事项,不自动发送或归档。 +- 治理库新增迁移 58:独立稀疏字段覆盖、不可变字段改动审计、周草稿及逐版保存收据。保存绑定输入与编辑版本,同 nonce 重试不重复留档,冲突和读回失败保留输入;已有草稿重新预填须明确确认,存在人工数据时拒绝删除式降级迁移。 + +### Changed + +- 详情顶部五个质量字段可直接下拉修改,范围取源主表实际使用的覆盖/正确性选项及与模型共享的产品、问题、根因字典;只保存明确选择的字段,不补齐其他空白、不把模型值冒充整条人工复核。移除单独人工复核/反馈展示,保留原始问答、旧人工历史和后台留痕。 +- 区域统计的月份及 ISO 周同时支持下拉选择和直接输入,合法值在回车或失焦后应用;未完成、非法及未来输入在本地提示,不发送错误统计请求,保留原自然月/周和缓存口径。 + ## 开发快照 snapshot/v0.1.40 - 2026-10-19 ### Added diff --git a/api/openapi.yaml b/api/openapi.yaml index ad45d34..331eaae 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -44,6 +44,10 @@ } ], "paths": { + "/api/v1/qa-ops/master/field-options": {"get":{"operationId":"getQAMasterFieldOptions","tags":["QA operations"],"summary":"只读主表五字段的正式可选范围,与模型共享同一分类字典;不返回业务问答","parameters":[],"responses":{"200":{"$ref":"#/components/responses/Envelope"},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"description":"需要登录"},"403":{"description":"仅管理员且写入须同源操作意图"},"409":{"description":"版本、输入或nonce冲突,未覆盖现有数据"},"500":{"$ref":"#/components/responses/InternalError"},"503":{"description":"独立治理存储不可用,不将失败伪装为已保存"}}}}, + "/api/v1/qa-ops/master/records/{id}/fields": {"post":{"operationId":"patchQAMasterFields","tags":["QA operations"],"summary":"只更新明确选择的字段并留幂等审计;不改原始正文、其他空白、旧复核或冻结证据","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"name":"X-Governance-Action","in":"header","required":true,"schema":{"const":"qa-master-fields"}}],"responses":{"200":{"$ref":"#/components/responses/Envelope"},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"description":"需要登录"},"403":{"description":"仅管理员且写入须同源操作意图"},"409":{"description":"版本、输入或nonce冲突,未覆盖现有数据"},"500":{"$ref":"#/components/responses/InternalError"},"503":{"description":"独立治理存储不可用,不将失败伪装为已保存"}},"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","additionalProperties":false,"required":["input_hash","input_epoch","base_revision","base_review_revision","nonce","fields"],"properties":{"input_hash":{"type":"string","pattern":"^[a-f0-9]{64}$"},"input_epoch":{"type":"integer","minimum":1},"base_revision":{"type":"integer","minimum":0},"base_review_revision":{"type":"integer","minimum":0},"nonce":{"type":"string","pattern":"^[a-zA-Z0-9][a-zA-Z0-9_.:-]{15,127}$"},"fields":{"type":"object","additionalProperties":false,"minProperties":1,"maxProperties":5,"properties":{"is_correct":{"type":"string","enum":["是","否","待确认"]},"product_category":{"type":"string","enum":["CR/CRA/H/AP","CRV","CR配件","Nova及系列","Nova配件","MG400","MG400配件","M1 Pro","VX500","新示教器","DobotStudio Pro","仿真软件","维护工具","教育所有产品","人形机器人","四六足机器人","X-Trainer","码垛解决方案","码垛插件","焊接解决方案","焊接插件","移动复合机器人","生态插件","生态合作","安全皮肤","未知"]},"problem_type":{"type":"string","enum":["参数","故障","使用配置","二次开发","资料获取","咨询","认证","案例","售后","培训与学习","其他"]},"root_cause":{"type":"string","enum":["知识缺失","检索不到","结果不清","答案生成错误","问题质量差","非知识范围","知识过期"]},"coverage":{"type":"string","enum":["是","否","否但不计入"]}}}}}}}}}}, + "/api/v1/qa-ops/master/weekly-drafts/{week}": {"get":{"operationId":"getQAWeeklyDraft","tags":["QA operations"],"summary":"读取租户指定北京时间ISO周的持久草稿;不存在时revision=0且不创建记录","parameters":[{"name":"week","in":"path","required":true,"schema":{"type":"string","pattern":"^[0-9]{4}-W[0-9]{2}$"}}],"responses":{"200":{"$ref":"#/components/responses/Envelope"},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"description":"需要登录"},"403":{"description":"仅管理员且写入须同源操作意图"},"409":{"description":"版本、输入或nonce冲突,未覆盖现有数据"},"500":{"$ref":"#/components/responses/InternalError"},"503":{"description":"独立治理存储不可用,不将失败伪装为已保存"}}},"post":{"operationId":"saveQAWeeklyDraft","tags":["QA operations"],"summary":"防抖编辑保存:版本锁、逐版历史及同nonce重放;正文不会触发外发/归档","parameters":[{"name":"week","in":"path","required":true,"schema":{"type":"string","pattern":"^[0-9]{4}-W[0-9]{2}$"}},{"name":"X-Governance-Action","in":"header","required":true,"schema":{"const":"qa-weekly-draft"}}],"responses":{"200":{"$ref":"#/components/responses/Envelope"},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"description":"需要登录"},"403":{"description":"仅管理员且写入须同源操作意图"},"409":{"description":"版本、输入或nonce冲突,未覆盖现有数据"},"500":{"$ref":"#/components/responses/InternalError"},"503":{"description":"独立治理存储不可用,不将失败伪装为已保存"}},"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","additionalProperties":false,"required":["base_revision","nonce","markdown"],"properties":{"base_revision":{"type":"integer","minimum":0},"nonce":{"type":"string","pattern":"^[a-zA-Z0-9][a-zA-Z0-9_.:-]{15,127}$"},"markdown":{"type":"string","maxLength":262144,"description":"UTF-8正文最多256KiB;允许空正文,保存草稿不等于发布"}}}}}}}}, + "/api/v1/qa-ops/master/weekly-drafts/{week}/prefill": {"post":{"operationId":"prefillQAWeeklyDraft","tags":["QA operations"],"summary":"从同一正式总表截止和保存区域生成中英各六节草稿;优先匹配已上报/冻结历史指标,其余用当周实时指标;不编造FAQ调整或重点。须带当前草稿版本,防止覆盖旧编辑","parameters":[{"name":"week","in":"path","required":true,"schema":{"type":"string","pattern":"^[0-9]{4}-W[0-9]{2}$"}},{"name":"X-Governance-Action","in":"header","required":true,"schema":{"const":"qa-weekly-draft"}}],"responses":{"200":{"$ref":"#/components/responses/Envelope"},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"description":"需要登录"},"403":{"description":"仅管理员且写入须同源操作意图"},"409":{"description":"版本、输入或nonce冲突,未覆盖现有数据"},"500":{"$ref":"#/components/responses/InternalError"},"503":{"description":"独立治理存储不可用,不将失败伪装为已保存"}},"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","additionalProperties":false,"required":["base_revision","nonce"],"properties":{"base_revision":{"type":"integer","minimum":0},"nonce":{"type":"string","pattern":"^[a-zA-Z0-9][a-zA-Z0-9_.:-]{15,127}$"}}}}}}}}, "/api/v1/qa-ops/departments/statistics": { "post": { "operationId": "qaDepartmentStatistics", diff --git a/internal/dobotaiops/records.go b/internal/dobotaiops/records.go index 862058d..e27db40 100644 --- a/internal/dobotaiops/records.go +++ b/internal/dobotaiops/records.go @@ -69,6 +69,7 @@ type RecordFeedback struct { } type Record struct { + FieldEditRevision int64 `json:"field_edit_revision,omitempty"` BackgroundTasks json.RawMessage `json:"background_tasks,omitempty"` RecordOrigin string `json:"record_origin,omitempty"` MasterFields map[string]string `json:"master_fields,omitempty"` diff --git a/internal/httpapi/qa_master_fields.go b/internal/httpapi/qa_master_fields.go new file mode 100644 index 0000000..00702be --- /dev/null +++ b/internal/httpapi/qa_master_fields.go @@ -0,0 +1,56 @@ +package httpapi + +import ( + "context" + "errors" + "knowledge-governance/internal/dobotaiops" + "knowledge-governance/internal/qaopsfields" + "knowledge-governance/internal/qaopsmirror" + "knowledge-governance/internal/qaopsquality" + "net/http" +) + +type masterFieldWriter interface { + PatchMasterFields(context.Context, string, qaopsmirror.MasterFieldPatch, string) (qaopsmirror.MasterFieldReceipt, error) +} + +func qaMasterFieldOptions(w http.ResponseWriter, r *http.Request, c Config) { + if !authorizeQAOps(w, r) { + return + } + if r.URL.RawQuery != "" { + qaOpsError(w, dobotaiops.ErrInvalidQuery) + return + } + w.Header().Set("Cache-Control", "no-store") + writeJSON(w, 200, map[string]any{"data": map[string]any{"version": "qa-master-options-v1", "fields": qaopsfields.EditableOptions()}}) +} +func qaMasterFieldPatch(w http.ResponseWriter, r *http.Request, c Config) { + if !authorizeQAOps(w, r) || !authorizeQAIntent(w, r, "qa-master-fields") { + return + } + var q qaopsmirror.MasterFieldPatch + if decodeQualityJSON(w, r, &q, "input_hash", "input_epoch", "base_revision", "base_review_revision", "nonce", "fields") != nil { + writeJSON(w, 400, map[string]string{"error": "字段选项或编辑参数无效"}) + return + } + writer, ok := c.QAOps.(masterFieldWriter) + if !ok || c.QAOpsTenantKey == "" { + qaOpsError(w, dobotaiops.ErrUnavailable) + return + } + identity, _ := sessionFrom(r.Context()) + result, err := writer.PatchMasterFields(r.Context(), r.PathValue("id"), q, identity.Username) + if err != nil { + switch { + case errors.Is(err, qaopsquality.ErrConflict): + writeJSON(w, 409, map[string]string{"error": "字段或问答版本已变化,请重新读取后修改;未覆盖其他人的内容"}) + case errors.Is(err, dobotaiops.ErrInvalidQuery): + writeJSON(w, 400, map[string]string{"error": "字段选项无效,未保存;请选择主表允许的内容"}) + default: + qaOpsError(w, err) + } + return + } + writeJSON(w, 200, map[string]any{"data": result}) +} diff --git a/internal/httpapi/qa_quality.go b/internal/httpapi/qa_quality.go index b61ad67..cc25062 100644 --- a/internal/httpapi/qa_quality.go +++ b/internal/httpapi/qa_quality.go @@ -98,10 +98,13 @@ func enrichQAQualityPage(ctx context.Context, c Config, page *dobotaiops.RecordP } func walkQualityJSON(d *json.Decoder) error { return strictjson.Walk(d) } func decodeQualityJSON(w http.ResponseWriter, r *http.Request, target any, required ...string) error { + return decodeGovernanceJSON(w, r, target, 64<<10, required...) +} +func decodeGovernanceJSON(w http.ResponseWriter, r *http.Request, target any, maxBytes int64, required ...string) error { if r.URL.RawQuery != "" { return qaopsquality.ErrInvalid } - raw, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 64<<10)) + raw, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxBytes)) if err != nil || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { return qaopsquality.ErrInvalid } diff --git a/internal/httpapi/qa_weekly_drafts.go b/internal/httpapi/qa_weekly_drafts.go new file mode 100644 index 0000000..bd88ab9 --- /dev/null +++ b/internal/httpapi/qa_weekly_drafts.go @@ -0,0 +1,82 @@ +package httpapi + +import ( + "context" + "errors" + "knowledge-governance/internal/dobotaiops" + "knowledge-governance/internal/qaopsmirror" + "knowledge-governance/internal/qaopsquality" + "net/http" + "time" +) + +type weeklyDraftStore interface { + ReadWeeklyDraft(context.Context, string, time.Time) (qaopsmirror.WeeklyDraft, error) + SaveWeeklyDraft(context.Context, string, qaopsmirror.WeeklyDraftRequest, string, time.Time) (qaopsmirror.WeeklyDraftReceipt, error) + PrefillWeeklyDraft(context.Context, string, qaopsmirror.WeeklyDraftPrefillRequest, string, time.Time) (qaopsmirror.WeeklyDraftReceipt, error) +} + +func weeklyDraftError(w http.ResponseWriter, err error) { + switch { + case errors.Is(err, qaopsquality.ErrConflict): + writeJSON(w, 409, map[string]string{"error": "本周草稿已被其他窗口修改;你的文字未被覆盖,请先下载或核对服务器版本", "code": "weekly_draft_conflict"}) + case errors.Is(err, dobotaiops.ErrInvalidQuery): + writeJSON(w, 400, map[string]string{"error": "周次或草稿内容无效,请使用已到达的合法ISO周,正文不超过256KiB", "code": "weekly_draft_invalid"}) + default: + writeJSON(w, 503, map[string]string{"error": "草稿存储暂不可用,请保留文字并重试;未标记为已保存", "code": "weekly_draft_unavailable"}) + } +} +func qaWeeklyDraftRead(w http.ResponseWriter, r *http.Request, c Config) { + if !authorizeQAOps(w, r) { + return + } + if r.URL.RawQuery != "" { + weeklyDraftError(w, dobotaiops.ErrInvalidQuery) + return + } + store, ok := c.QAOps.(weeklyDraftStore) + if !ok || c.QAOpsTenantKey == "" { + weeklyDraftError(w, dobotaiops.ErrUnavailable) + return + } + w.Header().Set("Cache-Control", "no-store") + out, err := store.ReadWeeklyDraft(r.Context(), r.PathValue("week"), c.Now()) + if err != nil { + weeklyDraftError(w, err) + return + } + writeJSON(w, 200, map[string]any{"data": out}) +} +func qaWeeklyDraftWrite(w http.ResponseWriter, r *http.Request, c Config, prefill bool) { + if !authorizeQAOps(w, r) || !authorizeQAIntent(w, r, "qa-weekly-draft") { + return + } + store, ok := c.QAOps.(weeklyDraftStore) + if !ok || c.QAOpsTenantKey == "" { + weeklyDraftError(w, dobotaiops.ErrUnavailable) + return + } + identity, _ := sessionFrom(r.Context()) + var out qaopsmirror.WeeklyDraftReceipt + var err error + if prefill { + var q qaopsmirror.WeeklyDraftPrefillRequest + if decodeQualityJSON(w, r, &q, "base_revision", "nonce") != nil { + weeklyDraftError(w, dobotaiops.ErrInvalidQuery) + return + } + out, err = store.PrefillWeeklyDraft(r.Context(), r.PathValue("week"), q, identity.Username, c.Now()) + } else { + var q qaopsmirror.WeeklyDraftRequest + if decodeGovernanceJSON(w, r, &q, 512<<10, "base_revision", "nonce", "markdown") != nil { + weeklyDraftError(w, dobotaiops.ErrInvalidQuery) + return + } + out, err = store.SaveWeeklyDraft(r.Context(), r.PathValue("week"), q, identity.Username, c.Now()) + } + if err != nil { + weeklyDraftError(w, err) + return + } + writeJSON(w, 200, map[string]any{"data": out}) +} diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index af728fa..2be85bf 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -200,6 +200,11 @@ func NewHandler(config Config) (http.Handler, error) { mux.HandleFunc("GET /api/v1/qa-ops/status", func(w http.ResponseWriter, r *http.Request) { qaOpsStatus(w, r, config) }) + mux.HandleFunc("GET /api/v1/qa-ops/master/weekly-drafts/{week}", func(w http.ResponseWriter, r *http.Request) { qaWeeklyDraftRead(w, r, config) }) + mux.HandleFunc("POST /api/v1/qa-ops/master/weekly-drafts/{week}", func(w http.ResponseWriter, r *http.Request) { qaWeeklyDraftWrite(w, r, config, false) }) + mux.HandleFunc("POST /api/v1/qa-ops/master/weekly-drafts/{week}/prefill", func(w http.ResponseWriter, r *http.Request) { qaWeeklyDraftWrite(w, r, config, true) }) + mux.HandleFunc("GET /api/v1/qa-ops/master/field-options", func(w http.ResponseWriter, r *http.Request) { qaMasterFieldOptions(w, r, config) }) + mux.HandleFunc("POST /api/v1/qa-ops/master/records/{id}/fields", func(w http.ResponseWriter, r *http.Request) { qaMasterFieldPatch(w, r, config) }) mux.HandleFunc("GET /api/v1/qa-ops/master/records", func(w http.ResponseWriter, r *http.Request) { withQAMaster(w, r, config, listQAOpsRecords) }) mux.HandleFunc("GET /api/v1/qa-ops/master/records/{id}", func(w http.ResponseWriter, r *http.Request) { withQAMaster(w, r, config, getQAOpsRecord) }) mux.HandleFunc("POST /api/v1/qa-ops/master/quality/query", func(w http.ResponseWriter, r *http.Request) { withQAMaster(w, r, config, qaQualityQuery) }) diff --git a/internal/platform/postgres/migrations/000058_qa_inline_fields_weekly_drafts.down.sql b/internal/platform/postgres/migrations/000058_qa_inline_fields_weekly_drafts.down.sql new file mode 100644 index 0000000..ec13761 --- /dev/null +++ b/internal/platform/postgres/migrations/000058_qa_inline_fields_weekly_drafts.down.sql @@ -0,0 +1,20 @@ +DO $$ BEGIN + IF EXISTS(SELECT 1 FROM qa_ops_weekly_draft_revisions) OR EXISTS(SELECT 1 FROM qa_ops_master_field_edits) THEN + RAISE EXCEPTION 'Saved human edits or weekly drafts exist; retain the additive schema for image rollback'; + END IF; +END $$; +DROP TABLE qa_ops_weekly_draft_revisions; +DROP TABLE qa_ops_weekly_drafts; +DROP VIEW qa_ops_master_read_model; +CREATE VIEW qa_ops_master_read_model AS + SELECT m.*,a.ai_fields,t.background_tasks,qa_ops_fill_empty_fields(m.local_fields||jsonb_build_object('question_chinese',m.chinese_question),a.ai_fields) AS effective_fields + FROM qa_ops_master_records m LEFT JOIN LATERAL ( + SELECT COALESCE(jsonb_object_agg(f.key,f.value ORDER BY o.created_at,o.work_id),'{}'::jsonb) AS ai_fields + FROM qa_ops_ai_outputs o CROSS JOIN LATERAL jsonb_each(o.applied_fields) f + WHERE o.tenant_key=m.tenant_key AND o.record_id=m.id AND o.input_hash=CASE WHEN o.kind='evaluation' THEN m.quality_hash ELSE encode(sha256(convert_to(m.question,'UTF8')),'hex') END + ) a ON true LEFT JOIN LATERAL ( + SELECT COALESCE(jsonb_agg(jsonb_build_object('id',w.id,'record_id',w.record_id,'kind',w.kind,'state',w.state,'stage',w.stage,'attempt',w.attempt,'error_code',w.error_code,'skip_reason',w.skip_reason,'started_at',w.started_at,'updated_at',w.updated_at)),'[]'::jsonb) AS background_tasks + FROM (SELECT DISTINCT ON (kind) * FROM qa_ops_ai_work WHERE tenant_key=m.tenant_key AND record_id=m.id ORDER BY kind,created_at DESC,id DESC) w + ) t ON true; +DROP TABLE qa_ops_master_field_edits; +DROP TABLE qa_ops_master_field_overrides; diff --git a/internal/platform/postgres/migrations/000058_qa_inline_fields_weekly_drafts.up.sql b/internal/platform/postgres/migrations/000058_qa_inline_fields_weekly_drafts.up.sql new file mode 100644 index 0000000..ee71b0c --- /dev/null +++ b/internal/platform/postgres/migrations/000058_qa_inline_fields_weekly_drafts.up.sql @@ -0,0 +1,27 @@ +-- Sparse human overrides keep untouched stored/AI fields and original payload immutable. +CREATE TABLE qa_ops_master_field_overrides ( + tenant_key text NOT NULL,record_id text NOT NULL,revision bigint NOT NULL CHECK(revision>0),input_hash text NOT NULL,review_revision bigint NOT NULL CHECK(review_revision>=0),fields jsonb NOT NULL CHECK(jsonb_typeof(fields)='object'),updated_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY(tenant_key,record_id),FOREIGN KEY(tenant_key,record_id) REFERENCES qa_ops_master_records(tenant_key,id) +); +CREATE TABLE qa_ops_master_field_edits ( + tenant_key text NOT NULL,record_id text NOT NULL,revision bigint NOT NULL CHECK(revision>0),input_hash text NOT NULL,review_revision bigint NOT NULL,changed_fields jsonb NOT NULL,before_fields jsonb NOT NULL,fields jsonb NOT NULL,actor text NOT NULL,nonce text NOT NULL,request_hash text NOT NULL,created_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY(tenant_key,record_id,revision),UNIQUE(tenant_key,record_id,nonce),FOREIGN KEY(tenant_key,record_id) REFERENCES qa_ops_master_records(tenant_key,id) +); +CREATE OR REPLACE VIEW qa_ops_master_read_model AS + SELECT m.*,a.ai_fields,t.background_tasks,qa_ops_fill_empty_fields(m.local_fields||jsonb_build_object('question_chinese',m.chinese_question),a.ai_fields)||COALESCE(CASE WHEN e.input_hash=m.quality_hash AND e.review_revision=COALESCE(h.review_revision,0) THEN e.fields END,'{}'::jsonb) AS effective_fields,COALESCE(e.revision,0) AS field_edit_revision,COALESCE(CASE WHEN e.input_hash=m.quality_hash AND e.review_revision=COALESCE(h.review_revision,0) THEN e.fields END,'{}'::jsonb) AS inline_fields + FROM qa_ops_master_records m LEFT JOIN LATERAL ( + SELECT COALESCE(jsonb_object_agg(f.key,f.value ORDER BY o.created_at,o.work_id),'{}'::jsonb) AS ai_fields + FROM qa_ops_ai_outputs o CROSS JOIN LATERAL jsonb_each(o.applied_fields) f + WHERE o.tenant_key=m.tenant_key AND o.record_id=m.id AND o.input_hash=CASE WHEN o.kind='evaluation' THEN m.quality_hash ELSE encode(sha256(convert_to(m.question,'UTF8')),'hex') END + ) a ON true LEFT JOIN LATERAL ( + SELECT COALESCE(jsonb_agg(jsonb_build_object('id',w.id,'record_id',w.record_id,'kind',w.kind,'state',w.state,'stage',w.stage,'attempt',w.attempt,'error_code',w.error_code,'skip_reason',w.skip_reason,'started_at',w.started_at,'updated_at',w.updated_at)),'[]'::jsonb) AS background_tasks + FROM (SELECT DISTINCT ON (kind) * FROM qa_ops_ai_work WHERE tenant_key=m.tenant_key AND record_id=m.id ORDER BY kind,created_at DESC,id DESC) w + ) t ON true LEFT JOIN qa_ops_quality_records h ON h.tenant_key=m.tenant_key||'::master' AND h.record_id=m.id LEFT JOIN qa_ops_master_field_overrides e ON e.tenant_key=m.tenant_key AND e.record_id=m.id; + +-- One shared editable document per tenant/week, with durable idempotent save receipts. +CREATE TABLE qa_ops_weekly_drafts ( + tenant_key text NOT NULL,week text NOT NULL CHECK(week ~ '^[0-9]{4}-W[0-9]{2}$'),revision bigint NOT NULL CHECK(revision>0),markdown text NOT NULL CHECK(octet_length(markdown)<=262144),seed jsonb NOT NULL CHECK(jsonb_typeof(seed)='object'),updated_by text NOT NULL,created_at timestamptz NOT NULL DEFAULT now(),updated_at timestamptz NOT NULL DEFAULT now(),PRIMARY KEY(tenant_key,week) +); +CREATE TABLE qa_ops_weekly_draft_revisions ( + tenant_key text NOT NULL,week text NOT NULL,revision bigint NOT NULL CHECK(revision>0),operation text NOT NULL CHECK(operation IN ('edit','prefill')),markdown text NOT NULL CHECK(octet_length(markdown)<=262144),seed jsonb NOT NULL CHECK(jsonb_typeof(seed)='object'),actor text NOT NULL,nonce text NOT NULL,request_hash text NOT NULL,created_at timestamptz NOT NULL DEFAULT now(),PRIMARY KEY(tenant_key,week,revision),UNIQUE(tenant_key,week,nonce),FOREIGN KEY(tenant_key,week) REFERENCES qa_ops_weekly_drafts(tenant_key,week) +); diff --git a/internal/platform/postgres/migrations_test.go b/internal/platform/postgres/migrations_test.go index 982c6d7..51c8b21 100644 --- a/internal/platform/postgres/migrations_test.go +++ b/internal/platform/postgres/migrations_test.go @@ -11,8 +11,8 @@ func TestEmbeddedMigrationsAreVersionSorted(t *testing.T) { if err != nil { t.Fatalf("loadMigrations() error = %v", err) } - if len(migrations) != 57 { - t.Fatalf("migration count = %d, want 57", len(migrations)) + if len(migrations) != 58 { + t.Fatalf("migration count = %d, want 58", len(migrations)) } if migrations[0].Version != 1 || !strings.Contains(migrations[0].UpSQL, "CREATE TABLE IF NOT EXISTS jobs") { t.Fatalf("migration 1 = %#v", migrations[0]) diff --git a/internal/platform/postgres/qa_inline_weekly_migration_test.go b/internal/platform/postgres/qa_inline_weekly_migration_test.go new file mode 100644 index 0000000..3793a80 --- /dev/null +++ b/internal/platform/postgres/qa_inline_weekly_migration_test.go @@ -0,0 +1,30 @@ +package postgres + +import ( + "strings" + "testing" +) + +func TestInlineFieldsWeeklyDraftMigrationIsAdditiveTenantScopedAndRollbackProtected(t *testing.T) { + migrations, e := loadMigrations() + if e != nil { + t.Fatal(e) + } + m := migrations[len(migrations)-1] + if m.Version != 58 { + t.Fatal("missing migration 58") + } + for _, marker := range []string{"CREATE TABLE qa_ops_master_field_overrides", "CREATE TABLE qa_ops_master_field_edits", "CREATE TABLE qa_ops_weekly_drafts", "CREATE TABLE qa_ops_weekly_draft_revisions", "UNIQUE(tenant_key,record_id,nonce)", "UNIQUE(tenant_key,week,nonce)", "e.input_hash=m.quality_hash AND e.review_revision=COALESCE(h.review_revision,0)", "field_edit_revision", "inline_fields"} { + if !strings.Contains(m.UpSQL, marker) { + t.Fatalf("migration lost sparse tenant/input/review/idempotency contract: %s", marker) + } + } + if strings.Contains(m.UpSQL, "UPDATE qa_ops_master_records SET payload") || strings.Contains(m.UpSQL, "UPDATE qa_ops_quality_reviews") { + t.Fatal("migration rewrites original records or human history") + } + for _, marker := range []string{"RAISE EXCEPTION", "SELECT 1 FROM qa_ops_weekly_draft_revisions", "SELECT 1 FROM qa_ops_master_field_edits"} { + if !strings.Contains(m.DownSQL, marker) { + t.Fatalf("down migration must refuse to discard saved human data: %s", marker) + } + } +} diff --git a/internal/qaopsai/assessment.go b/internal/qaopsai/assessment.go index b9cf7d4..3a55aa3 100644 --- a/internal/qaopsai/assessment.go +++ b/internal/qaopsai/assessment.go @@ -3,6 +3,7 @@ package qaopsai import ( "encoding/json" "io" + "knowledge-governance/internal/qaopsfields" "knowledge-governance/internal/strictjson" "strings" "unicode/utf8" @@ -12,12 +13,7 @@ const DictionaryVersion = "qa-approved-labels-v1" const EvaluationPromptVersion = "qa-evaluate-evidence-v1" const TranslationPromptVersion = "qa-question-zh-v1" -var Taxonomy = map[string][]string{ - "is_correct": {"是", "否", "待确认"}, - "product_category": {"CR/CRA/H/AP", "CRV", "CR配件", "Nova及系列", "Nova配件", "MG400", "MG400配件", "M1 Pro", "VX500", "新示教器", "DobotStudio Pro", "仿真软件", "维护工具", "教育所有产品", "人形机器人", "四六足机器人", "X-Trainer", "码垛解决方案", "码垛插件", "焊接解决方案", "焊接插件", "移动复合机器人", "生态插件", "生态合作", "安全皮肤", "未知"}, - "problem_type": {"参数", "故障", "使用配置", "二次开发", "资料获取", "咨询", "认证", "案例", "售后", "培训与学习", "其他"}, - "root_cause": {"知识缺失", "检索不到", "结果不清", "答案生成错误", "问题质量差", "非知识范围", "知识过期"}, -} +var Taxonomy = qaopsfields.ClassificationTaxonomy func allowedLabel(field, value string) bool { for _, label := range Taxonomy[field] { diff --git a/internal/qaopsai/work.go b/internal/qaopsai/work.go index 423cc26..5e53895 100644 --- a/internal/qaopsai/work.go +++ b/internal/qaopsai/work.go @@ -53,6 +53,7 @@ type recordMeta struct { ID, Hash, Status, Question string Revision int64 Fields map[string]string + InlineFields map[string]string Coverage dobotaiops.CoverageCheck View qaopsquality.View } @@ -81,7 +82,7 @@ func promptVersion(kind string) string { return EvaluationPromptVersion } func (s *Store) readMetas(ctx context.Context, tx pgx.Tx, ids []string, scope *Scope) ([]recordMeta, error) { - query := `SELECT m.id,m.quality_hash,m.row_revision,m.status,m.question,m.effective_fields,m.coverage_result,EXISTS(SELECT 1 FROM qa_ops_ai_outputs o JOIN qa_ops_ai_work w ON w.tenant_key=o.tenant_key AND w.id=o.work_id WHERE o.tenant_key=m.tenant_key AND o.record_id=m.id AND o.kind='evaluation' AND o.input_hash=m.quality_hash AND w.state='succeeded' AND w.prompt_version='` + EvaluationPromptVersion + `' AND o.payload->'fields'?'root_cause' AND o.payload->'fields'->'root_cause'->>'value'='') FROM qa_ops_master_read_model m WHERE tenant_key=$1` + query := `SELECT m.id,m.quality_hash,m.row_revision,m.status,m.question,m.effective_fields,m.coverage_result,EXISTS(SELECT 1 FROM qa_ops_ai_outputs o JOIN qa_ops_ai_work w ON w.tenant_key=o.tenant_key AND w.id=o.work_id WHERE o.tenant_key=m.tenant_key AND o.record_id=m.id AND o.kind='evaluation' AND o.input_hash=m.quality_hash AND w.state='succeeded' AND w.prompt_version='` + EvaluationPromptVersion + `' AND o.payload->'fields'?'root_cause' AND o.payload->'fields'->'root_cause'->>'value'=''),m.inline_fields FROM qa_ops_master_read_model m WHERE tenant_key=$1` args := []any{s.tenant} if scope != nil && len(scope.IDs) == 0 { window, err := dobotaiops.NewWindow(s.tenant, scope.Start, scope.End, 90*24*time.Hour, time.Now()) @@ -103,8 +104,8 @@ func (s *Store) readMetas(ctx context.Context, tx pgx.Tx, ids []string, scope *S keys := []dobotaiops.QualityKey{} for rows.Next() { var m recordMeta - var fields, coverage []byte - if rows.Scan(&m.ID, &m.Hash, &m.Revision, &m.Status, &m.Question, &fields, &coverage, &m.RootHandled) != nil || json.Unmarshal(fields, &m.Fields) != nil || json.Unmarshal(coverage, &m.Coverage) != nil { + var fields, coverage, edits []byte + if rows.Scan(&m.ID, &m.Hash, &m.Revision, &m.Status, &m.Question, &fields, &coverage, &m.RootHandled, &edits) != nil || json.Unmarshal(fields, &m.Fields) != nil || json.Unmarshal(coverage, &m.Coverage) != nil || json.Unmarshal(edits, &m.InlineFields) != nil { rows.Close() return nil, ErrUnavailable } @@ -139,7 +140,7 @@ func (s *Store) readMetas(ctx context.Context, tx pgx.Tx, ids []string, scope *S if !ok { return nil, ErrUnavailable } - metas[i].View = qaopsquality.ProjectMaster(v, metas[i].Fields, metas[i].Coverage) + metas[i].View = qaopsquality.ProjectMasterWithEdits(v, metas[i].Fields, metas[i].Coverage, metas[i].InlineFields) } return metas, nil } diff --git a/internal/qaopsfields/taxonomy.go b/internal/qaopsfields/taxonomy.go new file mode 100644 index 0000000..a6ad960 --- /dev/null +++ b/internal/qaopsfields/taxonomy.go @@ -0,0 +1,25 @@ +package qaopsfields + +var ClassificationTaxonomy = map[string][]string{ + "is_correct": {"是", "否", "待确认"}, + "product_category": {"CR/CRA/H/AP", "CRV", "CR配件", "Nova及系列", "Nova配件", "MG400", "MG400配件", "M1 Pro", "VX500", "新示教器", "DobotStudio Pro", "仿真软件", "维护工具", "教育所有产品", "人形机器人", "四六足机器人", "X-Trainer", "码垛解决方案", "码垛插件", "焊接解决方案", "焊接插件", "移动复合机器人", "生态插件", "生态合作", "安全皮肤", "未知"}, + "problem_type": {"参数", "故障", "使用配置", "二次开发", "资料获取", "咨询", "认证", "案例", "售后", "培训与学习", "其他"}, + "root_cause": {"知识缺失", "检索不到", "结果不清", "答案生成错误", "问题质量差", "非知识范围", "知识过期"}, +} + +func EditableOptions() map[string][]string { + out := make(map[string][]string, len(ClassificationTaxonomy)+1) + for key, values := range ClassificationTaxonomy { + out[key] = append([]string(nil), values...) + } + out["coverage"] = []string{"是", "否", "否但不计入"} + return out +} +func ValidEditableValue(field, value string) bool { + for _, label := range EditableOptions()[field] { + if value == label { + return true + } + } + return false +} diff --git a/internal/qaopsmirror/dashboard_reported_integration_test.go b/internal/qaopsmirror/dashboard_reported_integration_test.go index 460e579..b684e98 100644 --- a/internal/qaopsmirror/dashboard_reported_integration_test.go +++ b/internal/qaopsmirror/dashboard_reported_integration_test.go @@ -69,6 +69,22 @@ func TestReportedHistoryIsIndependentExactScopedAndImmutable(t *testing.T) { t.Fatal("reported history could be overwritten or deleted") } } + // The assertion belongs to migration 57, not whichever newer empty schema is last. + for { + var version int64 + if e := p.QueryRow(ctx, `SELECT max(version)FROM schema_migrations`).Scan(&version); e != nil { + t.Fatal(e) + } + if version == 57 { + break + } + if version < 57 { + t.Fatal("reported-history migration missing") + } + if e := pg.RollbackLatestMigration(ctx, p); e != nil { + t.Fatalf("could not reach the historical-data rollback guard: %v", e) + } + } e := pg.RollbackLatestMigration(ctx, p) var failure *pgconn.PgError if !errors.As(e, &failure) || failure.Code != "55000" { diff --git a/internal/qaopsmirror/dashboard_store.go b/internal/qaopsmirror/dashboard_store.go index 9219289..bebe8d8 100644 --- a/internal/qaopsmirror/dashboard_store.go +++ b/internal/qaopsmirror/dashboard_store.go @@ -18,6 +18,7 @@ type dashboardRecord struct { at time.Time identity dobotaiops.ActorIdentity fields map[string]string + edits map[string]string coverage dobotaiops.CoverageCheck } @@ -118,7 +119,7 @@ func (s *Store) readDashboard(ctx context.Context, q dobotaiops.RecordQuery, mas rowsSQL := `SELECT id,asked_at,identity,quality_hash FROM qa_ops_mirror_records WHERE tenant_key=$1 AND asked_at>=$2 AND asked_at<$3 AND ($4='' OR position(lower($4) IN lower(question))>0) ORDER BY id LIMIT 100001` if master { rowsSQL = strings.ReplaceAll(rowsSQL, "qa_ops_mirror_records", "qa_ops_master_read_model") - rowsSQL = strings.ReplaceAll(rowsSQL, "SELECT id,asked_at,identity,quality_hash FROM", "SELECT id,asked_at,identity,quality_hash,effective_fields,coverage_result FROM") + 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 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) @@ -132,10 +133,13 @@ func (s *Store) readDashboard(ctx context.Context, q dobotaiops.RecordQuery, mas var identity []byte var scanErr error if master { - var fields, coverage []byte - scanErr = rows.Scan(&r.id, &r.at, &identity, &r.hash, &fields, &coverage) + var fields, coverage, edits []byte + scanErr = rows.Scan(&r.id, &r.at, &identity, &r.hash, &fields, &coverage, &edits) if scanErr == nil { scanErr = json.Unmarshal(fields, &r.fields) + if scanErr == nil { + scanErr = json.Unmarshal(edits, &r.edits) + } } if scanErr == nil { scanErr = json.Unmarshal(coverage, &r.coverage) @@ -183,7 +187,7 @@ func (s *Store) readDashboard(ctx context.Context, q dobotaiops.RecordQuery, mas if master { for _, r := range records { if v, ok := views[r.id]; ok { - views[r.id] = qaopsquality.ProjectMaster(v, r.fields, r.coverage) + views[r.id] = qaopsquality.ProjectMasterWithEdits(v, r.fields, r.coverage, r.edits) } } } diff --git a/internal/qaopsmirror/master.go b/internal/qaopsmirror/master.go index 5f69c97..05292ec 100644 --- a/internal/qaopsmirror/master.go +++ b/internal/qaopsmirror/master.go @@ -16,7 +16,7 @@ func (s *Store) MasterQualityStore() *qaopsquality.Store { return qaopsquality.NewStore(s.pool, s.tenant+"::master") } -const masterBaseSQL = `WITH records AS (SELECT id,asked_at,question||E'\n'||chinese_question||E'\n'||COALESCE(ai_fields->>'question_chinese','') AS question,status,identity,quality_hash,jsonb_set(payload,'{master_fields}',qa_ops_fill_empty_fields(COALESCE(payload->'master_fields','{}'::jsonb),ai_fields))||jsonb_build_object('record_origin',origin,'background_tasks',background_tasks) AS payload FROM qa_ops_master_read_model WHERE tenant_key=$1)` +const masterBaseSQL = `WITH records AS (SELECT id,asked_at,question||E'\n'||chinese_question||E'\n'||COALESCE(ai_fields->>'question_chinese','') AS question,status,identity,quality_hash,jsonb_set(payload,'{master_fields}',effective_fields)||jsonb_build_object('record_origin',origin,'background_tasks',background_tasks,'field_edit_revision',field_edit_revision) AS payload FROM qa_ops_master_read_model WHERE tenant_key=$1)` var masterMetadataBase = strings.NewReplacer("qa_ops_mirror_records", "qa_ops_master_read_model", "h.tenant_key=m.tenant_key", "h.tenant_key=m.tenant_key||'::master'", "m.question", "(m.question||E'\\n'||m.chinese_question||E'\\n'||COALESCE(m.ai_fields->>'question_chinese','')) AS question").Replace(localMetadataBase) diff --git a/internal/qaopsmirror/master_fields.go b/internal/qaopsmirror/master_fields.go new file mode 100644 index 0000000..93ab657 --- /dev/null +++ b/internal/qaopsmirror/master_fields.go @@ -0,0 +1,151 @@ +package qaopsmirror + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "github.com/jackc/pgx/v5" + "knowledge-governance/internal/dobotaiops" + "knowledge-governance/internal/qaopsfields" + "knowledge-governance/internal/qaopsquality" + "regexp" + "strings" + "time" + "unicode/utf8" +) + +type MasterFieldPatch struct { + InputHash string `json:"input_hash"` + InputEpoch int64 `json:"input_epoch"` + BaseRevision int64 `json:"base_revision"` + BaseReviewRevision int64 `json:"base_review_revision"` + Nonce string `json:"nonce"` + Fields map[string]string `json:"fields"` +} +type MasterFieldReceipt struct { + SavedRevision int64 `json:"saved_revision"` + Replayed bool `json:"replayed"` +} + +var inlineHash = regexp.MustCompile(`^[a-f0-9]{64}$`) +var inlineNonce = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_.:-]{15,127}$`) + +func validInlineID(value string) bool { + return value != "" && len(value) <= 256 && utf8.ValidString(value) && !strings.ContainsAny(value, "\x00\x1f") +} +func (s *Store) PatchMasterFields(ctx context.Context, id string, q MasterFieldPatch, actor string) (MasterFieldReceipt, error) { + if !s.valid() { + return MasterFieldReceipt{}, dobotaiops.ErrUnavailable + } + if !validInlineID(id) || !validInlineID(actor) || !inlineHash.MatchString(q.InputHash) || !inlineNonce.MatchString(q.Nonce) || q.InputEpoch < 1 || q.BaseRevision < 0 || q.BaseReviewRevision < 0 || len(q.Fields) < 1 || len(q.Fields) > 5 { + return MasterFieldReceipt{}, dobotaiops.ErrInvalidQuery + } + for field, value := range q.Fields { + if !qaopsfields.ValidEditableValue(field, value) { + return MasterFieldReceipt{}, dobotaiops.ErrInvalidQuery + } + } + ctx, cancel := context.WithTimeout(ctx, 8*time.Second) + defer cancel() + tx, err := s.pool.Begin(ctx) + if err != nil { + return MasterFieldReceipt{}, dobotaiops.ErrUnavailable + } + defer tx.Rollback(context.Background()) + if _, err = tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1||chr(31)||$2,258))`, s.tenant, id); err != nil { + return MasterFieldReceipt{}, dobotaiops.ErrUnavailable + } + payload, _ := json.Marshal(q) + sum := sha256.Sum256(payload) + requestHash := hex.EncodeToString(sum[:]) + var savedRevision int64 + var savedHash, savedActor string + err = tx.QueryRow(ctx, `SELECT revision,request_hash,actor FROM qa_ops_master_field_edits WHERE tenant_key=$1 AND record_id=$2 AND nonce=$3`, s.tenant, id, q.Nonce).Scan(&savedRevision, &savedHash, &savedActor) + if err == nil { + if savedHash != requestHash || savedActor != actor { + return MasterFieldReceipt{}, qaopsquality.ErrConflict + } + return MasterFieldReceipt{SavedRevision: savedRevision, Replayed: true}, nil + } + if !errors.Is(err, pgx.ErrNoRows) { + return MasterFieldReceipt{}, dobotaiops.ErrUnavailable + } + var hash string + var reviewRevision, epoch int64 + err = tx.QueryRow(ctx, `SELECT input_hash,review_revision,input_epoch FROM qa_ops_quality_records WHERE tenant_key=$1 AND record_id=$2 FOR UPDATE`, s.tenant+"::master", id).Scan(&hash, &reviewRevision, &epoch) + if errors.Is(err, pgx.ErrNoRows) { + return MasterFieldReceipt{}, dobotaiops.ErrRecordNotFound + } + if err != nil { + return MasterFieldReceipt{}, dobotaiops.ErrUnavailable + } + if hash != q.InputHash || epoch != q.InputEpoch || reviewRevision != q.BaseReviewRevision { + return MasterFieldReceipt{}, qaopsquality.ErrConflict + } + var currentRevision, previousReview int64 + var previousHash string + var raw []byte + overrides := map[string]string{} + err = tx.QueryRow(ctx, `SELECT revision,input_hash,review_revision,fields FROM qa_ops_master_field_overrides WHERE tenant_key=$1 AND record_id=$2`, s.tenant, id).Scan(¤tRevision, &previousHash, &previousReview, &raw) + if err != nil && !errors.Is(err, pgx.ErrNoRows) { + return MasterFieldReceipt{}, dobotaiops.ErrUnavailable + } + if currentRevision != q.BaseRevision { + return MasterFieldReceipt{}, qaopsquality.ErrConflict + } + if previousHash == hash && previousReview == reviewRevision && json.Unmarshal(raw, &overrides) != nil { + return MasterFieldReceipt{}, dobotaiops.ErrUnavailable + } + var masterHash string + var fieldsRaw, coverageRaw, editsRaw []byte + err = tx.QueryRow(ctx, `SELECT quality_hash,effective_fields,coverage_result,inline_fields FROM qa_ops_master_read_model WHERE tenant_key=$1 AND id=$2`, s.tenant, id).Scan(&masterHash, &fieldsRaw, &coverageRaw, &editsRaw) + if err != nil { + return MasterFieldReceipt{}, dobotaiops.ErrUnavailable + } + if masterHash != q.InputHash { + return MasterFieldReceipt{}, qaopsquality.ErrConflict + } + var fields, edits map[string]string + var coverage dobotaiops.CoverageCheck + if json.Unmarshal(fieldsRaw, &fields) != nil || json.Unmarshal(editsRaw, &edits) != nil || json.Unmarshal(coverageRaw, &coverage) != nil { + return MasterFieldReceipt{}, dobotaiops.ErrUnavailable + } + views, err := s.MasterQualityStore().ReadViewsInTx(ctx, tx, []dobotaiops.QualityKey{{ID: id, InputHash: masterHash}}) + if err != nil { + return MasterFieldReceipt{}, err + } + current := qaopsquality.ProjectMasterWithEdits(views[id], fields, coverage, edits).Effective + beforeAll := map[string]string{"coverage": map[string]string{"covered": "是", "uncovered": "否", "partial": "部分覆盖", "pending": "待确认", "excluded": "否但不计入"}[current.Coverage], "is_correct": map[string]string{"correct": "是", "incorrect": "否", "pending": "待确认"}[current.Correctness], "product_category": current.ProductCategory, "problem_type": current.ProblemType, "root_cause": current.RootCause} + correctness := beforeAll["is_correct"] + if value, ok := q.Fields["is_correct"]; ok { + correctness = value + } + if correctness == "是" && q.Fields["root_cause"] != "" { + return MasterFieldReceipt{}, dobotaiops.ErrInvalidQuery + } + before := map[string]string{} + for key, value := range q.Fields { + before[key] = beforeAll[key] + overrides[key] = value + } + encoded, _ := json.Marshal(overrides) + changed, _ := json.Marshal(q.Fields) + previous, _ := json.Marshal(before) + revision := currentRevision + 1 + if _, err = tx.Exec(ctx, `INSERT INTO qa_ops_master_field_overrides(tenant_key,record_id,revision,input_hash,review_revision,fields) VALUES($1,$2,$3,$4,$5,$6) ON CONFLICT(tenant_key,record_id) DO UPDATE SET revision=EXCLUDED.revision,input_hash=EXCLUDED.input_hash,review_revision=EXCLUDED.review_revision,fields=EXCLUDED.fields,updated_at=now()`, s.tenant, id, revision, hash, reviewRevision, encoded); err != nil { + return MasterFieldReceipt{}, dobotaiops.ErrUnavailable + } + if _, err = tx.Exec(ctx, `INSERT INTO qa_ops_master_field_edits(tenant_key,record_id,revision,input_hash,review_revision,changed_fields,before_fields,fields,actor,nonce,request_hash) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)`, s.tenant, id, revision, hash, reviewRevision, changed, previous, encoded, actor, q.Nonce, requestHash); err != nil { + return MasterFieldReceipt{}, dobotaiops.ErrUnavailable + } + tag, err := tx.Exec(ctx, `UPDATE qa_ops_master_records SET row_revision=row_revision+1 WHERE tenant_key=$1 AND id=$2 AND quality_hash=$3`, s.tenant, id, hash) + if err != nil || tag.RowsAffected() != 1 { + return MasterFieldReceipt{}, qaopsquality.ErrConflict + } + if err = tx.Commit(ctx); err != nil { + return MasterFieldReceipt{}, dobotaiops.ErrUnavailable + } + return MasterFieldReceipt{SavedRevision: revision}, nil +} diff --git a/internal/qaopsmirror/master_inline_fields_integration_test.go b/internal/qaopsmirror/master_inline_fields_integration_test.go new file mode 100644 index 0000000..55e1027 --- /dev/null +++ b/internal/qaopsmirror/master_inline_fields_integration_test.go @@ -0,0 +1,69 @@ +package qaopsmirror + +import ( + "context" + "encoding/json" + "knowledge-governance/internal/dobotaiops" + pg "knowledge-governance/internal/platform/postgres" + "testing" + "time" +) + +func TestMasterInlineFieldPatchPersistsOnlyChosenField(t *testing.T) { + p := mirrorFixture(t) + ctx := context.Background() + s := NewStore(p, "fixture-inline-fields") + queue := pg.NewStore(p) + source := &fixtureSource{rows: []dobotaiops.MirrorRecord{fixtureEntry("inline-field", "first")}} + job := claimMirrorJob(t, s, queue, "inline-initial") + if e := s.HandleSync(ctx, *job, func(context.Context) (Source, error) { return source, nil }); e != nil { + t.Fatal(e) + } + if e := queue.Complete(ctx, job.ID, job.LeaseOwner, time.Now()); e != nil { + t.Fatal(e) + } + rec, e := s.GetMasterRecord(ctx, s.tenant, "inline-field") + if e != nil { + t.Fatal(e) + } + keys := []dobotaiops.QualityKey{{ID: rec.ID, InputHash: rec.QualityInputHash}} + before, e := s.ReadMasterQualityViews(ctx, keys) + if e != nil { + t.Fatal(e) + } + var original string + if e = p.QueryRow(ctx, `SELECT md5(payload::text||local_fields::text) FROM qa_ops_master_records WHERE tenant_key=$1 AND id=$2`, s.tenant, rec.ID).Scan(&original); e != nil { + t.Fatal(e) + } + q := MasterFieldPatch{InputHash: rec.QualityInputHash, InputEpoch: before[rec.ID].InputEpoch, BaseReviewRevision: before[rec.ID].Revision, Nonce: "fixture_inline_field_001", Fields: map[string]string{"product_category": "MG400"}} + receipt, e := s.PatchMasterFields(ctx, rec.ID, q, "fixture-admin") + if e != nil { + t.Fatalf("single-field persistent edit rejected: %v", e) + } + if receipt.SavedRevision != 1 || receipt.Replayed { + t.Fatalf("invalid receipt: %+v", receipt) + } + after, e := NewStore(p, s.tenant).ReadMasterQualityViews(ctx, keys) + if e != nil { + t.Fatal(e) + } + fresh, e := s.GetMasterRecord(ctx, s.tenant, rec.ID) + if e != nil || fresh.FieldEditRevision != 1 || fresh.MasterFields["product_category"] != "MG400" { + t.Fatalf("detail/list read model did not expose saved field version and value: %+v error=%v", fresh, e) + } + b, a := before[rec.ID], after[rec.ID] + b.Effective.ProductCategory = "MG400" + rawB, _ := json.Marshal(b) + rawA, _ := json.Marshal(a) + if string(rawB) != string(rawA) { + t.Fatalf("unselected conclusions/review provenance changed: before=%s after=%s", rawB, rawA) + } + var current string + var audits int + if e = p.QueryRow(ctx, `SELECT md5(payload::text||local_fields::text) FROM qa_ops_master_records WHERE tenant_key=$1 AND id=$2`, s.tenant, rec.ID).Scan(¤t); e != nil || original != current { + t.Fatal("source/master payload or stored labels were rewritten", e) + } + if e = p.QueryRow(ctx, `SELECT count(*) FROM qa_ops_master_field_edits WHERE tenant_key=$1 AND record_id=$2`, s.tenant, rec.ID).Scan(&audits); e != nil || audits != 1 { + t.Fatal("field edit audit missing", e) + } +} diff --git a/internal/qaopsmirror/master_quality.go b/internal/qaopsmirror/master_quality.go index e7834dc..b18d314 100644 --- a/internal/qaopsmirror/master_quality.go +++ b/internal/qaopsmirror/master_quality.go @@ -27,17 +27,17 @@ func (s *Store) ReadMasterQualityViews(ctx context.Context, keys []dobotaiops.Qu ids = append(ids, key.ID) hashes[key.ID] = key.InputHash } - rows, err := tx.Query(ctx, `SELECT id,quality_hash,effective_fields,coverage_result FROM qa_ops_master_read_model WHERE tenant_key=$1 AND id=ANY($2::text[])`, s.tenant, ids) + rows, err := tx.Query(ctx, `SELECT id,quality_hash,effective_fields,coverage_result,inline_fields FROM qa_ops_master_read_model WHERE tenant_key=$1 AND id=ANY($2::text[])`, s.tenant, ids) if err != nil { return nil, dobotaiops.ErrUnavailable } count := 0 for rows.Next() { var id, hash string - var rawFields, rawCoverage []byte - var fields map[string]string + var rawFields, rawCoverage, rawEdits []byte + var fields, edits map[string]string var coverage dobotaiops.CoverageCheck - if rows.Scan(&id, &hash, &rawFields, &rawCoverage) != nil || json.Unmarshal(rawFields, &fields) != nil || json.Unmarshal(rawCoverage, &coverage) != nil { + if rows.Scan(&id, &hash, &rawFields, &rawCoverage, &rawEdits) != nil || json.Unmarshal(rawFields, &fields) != nil || json.Unmarshal(rawCoverage, &coverage) != nil || json.Unmarshal(rawEdits, &edits) != nil { rows.Close() return nil, dobotaiops.ErrUnavailable } @@ -50,7 +50,7 @@ func (s *Store) ReadMasterQualityViews(ctx context.Context, keys []dobotaiops.Qu rows.Close() return nil, qaopsquality.ErrUnavailable } - views[id] = qaopsquality.ProjectMaster(view, fields, coverage) + views[id] = qaopsquality.ProjectMasterWithEdits(view, fields, coverage, edits) count++ } err = rows.Err() diff --git a/internal/qaopsmirror/weekly_draft.go b/internal/qaopsmirror/weekly_draft.go new file mode 100644 index 0000000..aa1d354 --- /dev/null +++ b/internal/qaopsmirror/weekly_draft.go @@ -0,0 +1,179 @@ +package qaopsmirror + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "github.com/jackc/pgx/v5" + "knowledge-governance/internal/dobotaiops" + "knowledge-governance/internal/qaopsquality" + "strings" + "time" + "unicode/utf8" +) + +func (s *Store) PrefillWeeklyDraft(ctx context.Context, week string, q WeeklyDraftPrefillRequest, actor string, now time.Time) (WeeklyDraftReceipt, error) { + if !s.valid() { + return WeeklyDraftReceipt{}, dobotaiops.ErrUnavailable + } + start, end, err := weeklyDraftBounds(week, now) + if err != nil { + return WeeklyDraftReceipt{}, err + } + if q.BaseRevision < 0 || !inlineNonce.MatchString(q.Nonce) || !validInlineID(actor) { + return WeeklyDraftReceipt{}, dobotaiops.ErrInvalidQuery + } + request := WeeklyDraftRequest{BaseRevision: q.BaseRevision, Nonce: q.Nonce} + saved := WeeklyDraft{Week: week} + var hash, owner string + err = s.pool.QueryRow(ctx, `SELECT revision,markdown,seed,created_at,actor,request_hash FROM qa_ops_weekly_draft_revisions WHERE tenant_key=$1 AND week=$2 AND nonce=$3`, s.tenant, week, q.Nonce).Scan(&saved.Revision, &saved.Markdown, &saved.Seed, &saved.UpdatedAt, &owner, &hash) + if err == nil { + if hash != weeklyRequestHash(request, "prefill") || owner != actor { + return WeeklyDraftReceipt{}, qaopsquality.ErrConflict + } + return WeeklyDraftReceipt{Draft: saved, Replayed: true}, nil + } + if !errors.Is(err, pgx.ErrNoRows) { + return WeeklyDraftReceipt{}, dobotaiops.ErrUnavailable + } + data, err := s.ReadMasterDashboard(ctx, dobotaiops.RecordQuery{Window: dobotaiops.Window{TenantKey: s.tenant, Start: start, End: end}}) + if err != nil { + return WeeklyDraftReceipt{}, err + } + markdown, seed, err := renderWeeklyTemplate(week, data, now) + if err != nil { + return WeeklyDraftReceipt{}, err + } + request.Markdown = markdown + return s.saveWeeklyDraft(ctx, week, request, actor, now, "prefill", seed) +} + +const maxWeeklyDraftBytes = 256 << 10 + +type WeeklyDraft struct { + Week string `json:"week"` + Revision int64 `json:"revision"` + Markdown string `json:"markdown"` + Seed json.RawMessage `json:"seed"` + UpdatedAt *time.Time `json:"updated_at"` +} +type WeeklyDraftRequest struct { + BaseRevision int64 `json:"base_revision"` + Nonce string `json:"nonce"` + Markdown string `json:"markdown"` +} +type WeeklyDraftPrefillRequest struct { + BaseRevision int64 `json:"base_revision"` + Nonce string `json:"nonce"` +} +type WeeklyDraftReceipt struct { + Draft WeeklyDraft `json:"draft"` + Replayed bool `json:"replayed"` +} + +func weeklyDraftBounds(week string, now time.Time) (time.Time, time.Time, error) { + start, end, err := (DepartmentStatisticsQuery{Period: "week", Week: week}).Bounds(now) + if err != nil { + return time.Time{}, time.Time{}, err + } + if start == nil { + return time.Time{}, time.Time{}, dobotaiops.ErrInvalidQuery + } + return *start, end, nil +} +func (s *Store) ReadWeeklyDraft(ctx context.Context, week string, now time.Time) (WeeklyDraft, error) { + out := WeeklyDraft{Week: week, Seed: json.RawMessage(`{}`)} + if !s.valid() { + return out, dobotaiops.ErrUnavailable + } + if _, _, e := weeklyDraftBounds(week, now); e != nil { + return out, e + } + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + e := s.pool.QueryRow(ctx, `SELECT revision,markdown,seed,updated_at FROM qa_ops_weekly_drafts WHERE tenant_key=$1 AND week=$2`, s.tenant, week).Scan(&out.Revision, &out.Markdown, &out.Seed, &out.UpdatedAt) + if errors.Is(e, pgx.ErrNoRows) { + return out, nil + } + if e != nil { + return WeeklyDraft{}, dobotaiops.ErrUnavailable + } + return out, nil +} +func weeklyRequestHash(q WeeklyDraftRequest, operation string) string { + if operation == "prefill" { + q.Markdown = "" + } + raw, _ := json.Marshal(struct { + Request WeeklyDraftRequest + Operation string + }{q, operation}) + sum := sha256.Sum256(raw) + return hex.EncodeToString(sum[:]) +} +func (s *Store) SaveWeeklyDraft(ctx context.Context, week string, q WeeklyDraftRequest, actor string, now time.Time) (WeeklyDraftReceipt, error) { + return s.saveWeeklyDraft(ctx, week, q, actor, now, "edit", nil) +} +func (s *Store) saveWeeklyDraft(ctx context.Context, week string, q WeeklyDraftRequest, actor string, now time.Time, operation string, seed json.RawMessage) (WeeklyDraftReceipt, error) { + if !s.valid() { + return WeeklyDraftReceipt{}, dobotaiops.ErrUnavailable + } + if _, _, e := weeklyDraftBounds(week, now); e != nil { + return WeeklyDraftReceipt{}, e + } + if q.BaseRevision < 0 || !inlineNonce.MatchString(q.Nonce) || !validInlineID(actor) || !utf8.ValidString(q.Markdown) || len(q.Markdown) > maxWeeklyDraftBytes || strings.ContainsRune(q.Markdown, 0) || (operation != "edit" && operation != "prefill") { + return WeeklyDraftReceipt{}, dobotaiops.ErrInvalidQuery + } + ctx, cancel := context.WithTimeout(ctx, 8*time.Second) + defer cancel() + tx, e := s.pool.Begin(ctx) + if e != nil { + return WeeklyDraftReceipt{}, dobotaiops.ErrUnavailable + } + defer tx.Rollback(context.Background()) + if _, e = tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1||chr(31)||$2,259))`, s.tenant, week); e != nil { + return WeeklyDraftReceipt{}, dobotaiops.ErrUnavailable + } + requestHash := weeklyRequestHash(q, operation) + saved := WeeklyDraft{Week: week} + var savedActor, savedHash string + e = tx.QueryRow(ctx, `SELECT revision,markdown,seed,created_at,actor,request_hash FROM qa_ops_weekly_draft_revisions WHERE tenant_key=$1 AND week=$2 AND nonce=$3`, s.tenant, week, q.Nonce).Scan(&saved.Revision, &saved.Markdown, &saved.Seed, &saved.UpdatedAt, &savedActor, &savedHash) + if e == nil { + if savedActor != actor || savedHash != requestHash { + return WeeklyDraftReceipt{}, qaopsquality.ErrConflict + } + return WeeklyDraftReceipt{Draft: saved, Replayed: true}, nil + } + if !errors.Is(e, pgx.ErrNoRows) { + return WeeklyDraftReceipt{}, dobotaiops.ErrUnavailable + } + current := WeeklyDraft{Week: week, Seed: json.RawMessage(`{}`)} + e = tx.QueryRow(ctx, `SELECT revision,markdown,seed,updated_at FROM qa_ops_weekly_drafts WHERE tenant_key=$1 AND week=$2 FOR UPDATE`, s.tenant, week).Scan(¤t.Revision, ¤t.Markdown, ¤t.Seed, ¤t.UpdatedAt) + if e != nil && !errors.Is(e, pgx.ErrNoRows) { + return WeeklyDraftReceipt{}, dobotaiops.ErrUnavailable + } + if current.Revision != q.BaseRevision { + return WeeklyDraftReceipt{}, qaopsquality.ErrConflict + } + if seed == nil { + seed = current.Seed + } + if len(seed) == 0 || !json.Valid(seed) { + return WeeklyDraftReceipt{}, dobotaiops.ErrInvalidQuery + } + var created time.Time + next := current.Revision + 1 + e = tx.QueryRow(ctx, `INSERT INTO qa_ops_weekly_drafts(tenant_key,week,revision,markdown,seed,updated_by) VALUES($1,$2,$3,$4,$5,$6) ON CONFLICT(tenant_key,week) DO UPDATE SET revision=EXCLUDED.revision,markdown=EXCLUDED.markdown,seed=EXCLUDED.seed,updated_by=EXCLUDED.updated_by,updated_at=now() RETURNING updated_at`, s.tenant, week, next, q.Markdown, seed, actor).Scan(&created) + if e != nil { + return WeeklyDraftReceipt{}, dobotaiops.ErrUnavailable + } + if _, e = tx.Exec(ctx, `INSERT INTO qa_ops_weekly_draft_revisions(tenant_key,week,revision,operation,markdown,seed,actor,nonce,request_hash,created_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`, s.tenant, week, next, operation, q.Markdown, seed, actor, q.Nonce, requestHash, created); e != nil { + return WeeklyDraftReceipt{}, dobotaiops.ErrUnavailable + } + if e = tx.Commit(ctx); e != nil { + return WeeklyDraftReceipt{}, dobotaiops.ErrUnavailable + } + return WeeklyDraftReceipt{Draft: WeeklyDraft{Week: week, Revision: next, Markdown: q.Markdown, Seed: seed, UpdatedAt: &created}}, nil +} diff --git a/internal/qaopsmirror/weekly_draft_integration_test.go b/internal/qaopsmirror/weekly_draft_integration_test.go new file mode 100644 index 0000000..265dd22 --- /dev/null +++ b/internal/qaopsmirror/weekly_draft_integration_test.go @@ -0,0 +1,43 @@ +package qaopsmirror + +import ( + "context" + "testing" + "time" +) + +func TestWeeklyDraftDurablyRoundTripsOneDraftPerWeekWithoutReadSideEffects(t *testing.T) { + p := mirrorFixture(t) + s := NewStore(p, "fixture-weekly-editor") + ctx := context.Background() + now := time.Date(2026, 10, 19, 12, 0, 0, 0, time.UTC) + empty, e := s.ReadWeeklyDraft(ctx, "2026-W41", now) + if e != nil || empty.Revision != 0 || empty.Week != "2026-W41" || empty.Markdown != "" { + t.Fatalf("missing week must be a read-only empty draft, not unavailable/fabricated content: %+v %v", empty, e) + } + var rows int + if e = p.QueryRow(ctx, `SELECT count(*) FROM qa_ops_weekly_drafts WHERE tenant_key=$1`, s.tenant).Scan(&rows); e != nil || rows != 0 { + t.Fatal("viewing a week created a draft", e) + } + q := WeeklyDraftRequest{BaseRevision: 0, Nonce: "fixture_weekly_editor_0001", Markdown: "# Edited report\n\n中文正文,English text\n\n"} + receipt, e := s.SaveWeeklyDraft(ctx, "2026-W41", q, "fixture-admin", now) + if e != nil || receipt.Draft.Revision != 1 || receipt.Replayed { + t.Fatalf("autosave rejected: %+v %v", receipt, e) + } + fresh, e := NewStore(p, s.tenant).ReadWeeklyDraft(ctx, "2026-W41", now) + if e != nil || fresh.Markdown != q.Markdown || fresh.Revision != 1 || fresh.UpdatedAt == nil { + t.Fatalf("reopened editor lost exact content: %+v %v", fresh, e) + } + other, e := s.ReadWeeklyDraft(ctx, "2026-W40", now) + if e != nil || other.Revision != 0 || other.Markdown != "" { + t.Fatal("week contents contaminated", e) + } + tenant, e := NewStore(p, "fixture-other-tenant").ReadWeeklyDraft(ctx, "2026-W41", now) + if e != nil || tenant.Revision != 0 { + t.Fatal("tenant isolation failed", e) + } + replay, e := s.SaveWeeklyDraft(ctx, "2026-W41", q, "fixture-admin", now) + if e != nil || !replay.Replayed || replay.Draft.Revision != 1 { + t.Fatal("same autosave retry must recover its existing receipt", e) + } +} diff --git a/internal/qaopsmirror/weekly_prefill_integration_test.go b/internal/qaopsmirror/weekly_prefill_integration_test.go new file mode 100644 index 0000000..35ce77d --- /dev/null +++ b/internal/qaopsmirror/weekly_prefill_integration_test.go @@ -0,0 +1,69 @@ +package qaopsmirror + +import ( + "context" + "encoding/json" + "fmt" + "knowledge-governance/internal/dobotaiops" + pg "knowledge-governance/internal/platform/postgres" + "strings" + "testing" + "time" +) + +func TestWeeklyPrefillUsesRealMasterDashboardAndDoesNotOverwriteExistingDraft(t *testing.T) { + p := mirrorFixture(t) + ctx := context.Background() + s := NewStore(p, "fixture-weekly-prefill") + queue := pg.NewStore(p) + source := &fixtureSource{rows: []dobotaiops.MirrorRecord{fixtureEntry("prefill-event", "existing answer")}} + job := claimMirrorJob(t, s, queue, "prefill-source") + if e := s.HandleSync(ctx, *job, func(context.Context) (Source, error) { return source, nil }); e != nil { + t.Fatal(e) + } + if e := queue.Complete(ctx, job.ID, job.LeaseOwner, time.Now()); e != nil { + t.Fatal(e) + } + record, e := s.GetMasterRecord(ctx, s.tenant, "prefill-event") + if e != nil { + t.Fatal(e) + } + year, weekNumber := record.AskedAt.In(dashboardLocation).ISOWeek() + week := fmt.Sprintf("%04d-W%02d", year, weekNumber) + now := time.Now() + start, end, e := weeklyDraftBounds(week, now) + if e != nil { + t.Fatal(e) + } + data, e := s.ReadMasterDashboard(ctx, dobotaiops.RecordQuery{Window: dobotaiops.Window{TenantKey: s.tenant, Start: start, End: end}}) + if e != nil { + t.Fatal(e) + } + q := WeeklyDraftPrefillRequest{Nonce: "fixture_prefill_week_0001"} + receipt, e := s.PrefillWeeklyDraft(ctx, week, q, "fixture-admin", now) + if e != nil { + t.Fatalf("real-source prefill unavailable: %v", e) + } + if receipt.Draft.Revision != 1 || !strings.Contains(receipt.Draft.Markdown, fmt.Sprintf("问题总数:%d", data.TotalIssues)) || !strings.Contains(receipt.Draft.Markdown, fmt.Sprintf("New This Week: %d", data.NewThisWeek)) { + t.Fatal("prefill was not generated from matching master cutoff") + } + var seed map[string]any + if json.Unmarshal(receipt.Draft.Seed, &seed) != nil || seed["source"] != "governance-master" || seed["week"] != week { + t.Fatal("prefill source provenance missing") + } + edit := WeeklyDraftRequest{BaseRevision: 1, Nonce: "fixture_prefill_user_0002", Markdown: receipt.Draft.Markdown + "\n用户亲写内容,不许替换\n"} + if _, e = s.SaveWeeklyDraft(ctx, week, edit, "fixture-admin", now); e != nil { + t.Fatal(e) + } + if _, e = s.PrefillWeeklyDraft(ctx, week, WeeklyDraftPrefillRequest{BaseRevision: 1, Nonce: "fixture_prefill_stale_0003"}, "fixture-admin", now); e == nil { + t.Fatal("stale regeneration silently replaced edited content") + } + fresh, e := s.ReadWeeklyDraft(ctx, week, now) + if e != nil || fresh.Revision != 2 || fresh.Markdown != edit.Markdown { + t.Fatal("prefill or stale request overwrote user input", e) + } + replay, e := s.PrefillWeeklyDraft(ctx, week, q, "fixture-admin", now) + if e != nil || !replay.Replayed || replay.Draft.Revision != 1 { + t.Fatal("prefill retry must return its exact historical receipt", e) + } +} diff --git a/internal/qaopsmirror/weekly_template.go b/internal/qaopsmirror/weekly_template.go new file mode 100644 index 0000000..86f0d0f --- /dev/null +++ b/internal/qaopsmirror/weekly_template.go @@ -0,0 +1,175 @@ +package qaopsmirror + +import ( + "encoding/json" + "fmt" + "knowledge-governance/internal/dobotaiops" + "math" + "sort" + "strings" + "time" +) + +func weeklyMarkdownCell(value string) string { + return strings.NewReplacer("&", "&", "<", "<", ">", ">", "\\", "\\\\", "*", "\\*", "_", "\\_", "[", "\\[", "]", "\\]", "|", "\\|", "`", "\\`", "\r", " ", "\n", " ").Replace(value) +} +func weeklyPercent(value *float64) string { + if value == nil { + return "—" + } + return fmt.Sprintf("%.2f%%", *value) +} +func weeklyNumber(value *float64) string { + if value == nil { + return "—" + } + return fmt.Sprintf("%.2f", *value) +} +func validWeeklyValue(v *float64, percent bool) bool { + return v == nil || (!math.IsNaN(*v) && !math.IsInf(*v, 0) && *v >= 0 && (!percent || *v <= 100)) +} +func weeklyRegionRanking(regions []DashboardRegion, coverage, english bool) string { + rows := append([]DashboardRegion(nil), regions...) + metric := func(r DashboardRegion) *float64 { + if coverage { + return r.UsageCoverage.Percent + } + return r.Frequency + } + sort.SliceStable(rows, func(i, j int) bool { + a, b := metric(rows[i]), metric(rows[j]) + if a == nil { + return false + } + if b == nil { + return true + } + if *a == *b { + return rows[i].ID < rows[j].ID + } + return *a > *b + }) + values := make([]string, 0, len(rows)) + for i, r := range rows { + name := r.Name + if english { + if translated := map[string]string{"国内工业": "China Industrial", "America": "Americas", "APAC": "APAC", "EMEA": "EMEA"}[name]; translated != "" { + name = translated + } + } + value := weeklyNumber(metric(r)) + if coverage { + value = weeklyPercent(metric(r)) + } + values = append(values, fmt.Sprintf("%d. %s: %s", i+1, weeklyMarkdownCell(name), value)) + } + if len(values) == 0 { + return "—" + } + return strings.Join(values, "\n") +} +func renderWeeklyTemplate(week string, data Dashboard, now time.Time) (string, json.RawMessage, error) { + start, end, err := weeklyDraftBounds(week, now) + if err != nil || !data.Start.Equal(start) || !data.End.Equal(end) || data.TotalIssues < 0 || data.NewThisWeek < 0 || data.NewThisWeek > data.TotalIssues || data.MonthStart.IsZero() { + return "", nil, dobotaiops.ErrInvalidQuery + } + coverage, accuracy := data.Quality.Rates.Coverage.Percent, data.Quality.Rates.Correctness.Percent + qualitySource := "live-week" + qualitySourceWeekKey := "" + for _, f := range data.FrozenWeeks { + if f.WeekStart.Equal(start) && f.FrozenAt != nil { + coverage, accuracy = f.Coverage.Percent, f.Correctness.Percent + qualitySource = "frozen-week" + } + } + for _, p := range data.ReportedWeeks { + if p.WeekStart.Equal(start) && p.WeekEnd.Equal(start.AddDate(0, 0, 7)) && !p.WeekEnd.After(end) { + c, a := p.CoveragePercent, p.CorrectnessPercent + coverage, accuracy = &c, &a + qualitySource = "reported-history" + qualitySourceWeekKey = p.Week + } + } + if !validWeeklyValue(coverage, true) || !validWeeklyValue(accuracy, true) { + return "", nil, dobotaiops.ErrInvalidQuery + } + for _, r := range data.Regions { + if !validWeeklyValue(r.Frequency, false) || !validWeeklyValue(r.UsageCoverage.Percent, true) { + return "", nil, dobotaiops.ErrInvalidQuery + } + } + month := data.MonthStart.In(dashboardLocation) + replacements := []string{ + "{{YEAR}}", week[:4], "{{WEEK}}", week[6:], + "{{TOTAL_QUESTIONS}}", fmt.Sprint(data.TotalIssues), "{{NEW_THIS_WEEK}}", fmt.Sprint(data.NewThisWeek), + "{{COVERAGE_RATE}}", weeklyPercent(coverage), "{{ACCURACY_RATE}}", weeklyPercent(accuracy), + "{{MONTH_EN}}", month.Format("January 2006"), "{{MONTH_ZH}}", fmt.Sprintf("%d年%d月", month.Year(), month.Month()), + "{{AVERAGE_USAGE_RANKING_EN}}", weeklyRegionRanking(data.Regions, false, true), "{{AVERAGE_USAGE_RANKING_ZH}}", weeklyRegionRanking(data.Regions, false, false), + "{{USAGE_COVERAGE_RANKING_EN}}", weeklyRegionRanking(data.Regions, true, true), "{{USAGE_COVERAGE_RANKING_ZH}}", weeklyRegionRanking(data.Regions, true, false), + "{{WEEKLY_HIGHLIGHTS_EN}}", "[To be completed]", "{{NEXT_WEEK_PRIORITIES_EN}}", "[To be completed]", + "{{WEEKLY_HIGHLIGHTS_ZH}}", "[待填写]", "{{NEXT_WEEK_PRIORITIES_ZH}}", "[待填写]"} + markdown := strings.NewReplacer(replacements...).Replace(weeklySkillTemplate) + seed, _ := json.Marshal(map[string]any{"template_version": "dobot-weekly-six-sections-v1", "week": week, "start": start, "end": end, "generated_at": now, "source": "governance-master", "local_revision": data.LocalRevision, "source_observed_at": data.AsOf, "organization": data.Organization, "quality_source": qualitySource, "quality_source_week_key": qualitySourceWeekKey, "total_issues": data.TotalIssues, "new_this_week": data.NewThisWeek, "coverage_percent": coverage, "correctness_percent": accuracy, "month": month.Format("2006-01"), "regions": data.Regions}) + return markdown, seed, nil +} + +const weeklySkillTemplate = `# Knowledge Base Weekly Report - {{YEAR}} WK{{WEEK}} + +## 1. Weekly Summary + +Total Questions: {{TOTAL_QUESTIONS}} + +New This Week: {{NEW_THIS_WEEK}} + +## 2. Key Metrics + +Coverage Rate: {{COVERAGE_RATE}} + +Accuracy Rate: {{ACCURACY_RATE}} + +## 3. {{MONTH_EN}} Regional Average Usage Ranking + +{{AVERAGE_USAGE_RANKING_EN}} + +## 4. {{MONTH_EN}} Regional Usage Coverage Ranking + +{{USAGE_COVERAGE_RANKING_EN}} + +## 5. Weekly Highlights + +{{WEEKLY_HIGHLIGHTS_EN}} + +## 6. Next Week Priorities + +{{NEXT_WEEK_PRIORITIES_EN}} + +# 知识库周报 - {{YEAR}}年第{{WEEK}}周 + +## 一、本周概况 + +问题总数:{{TOTAL_QUESTIONS}} + +本周新增:{{NEW_THIS_WEEK}} + +## 二、核心指标 + +覆盖率:{{COVERAGE_RATE}} + +正确率:{{ACCURACY_RATE}} + +## 三、{{MONTH_ZH}}区域人均使用次数排名 + +{{AVERAGE_USAGE_RANKING_ZH}} + +## 四、{{MONTH_ZH}}区域使用覆盖率排名 + +{{USAGE_COVERAGE_RANKING_ZH}} + +## 五、本周重点 + +{{WEEKLY_HIGHLIGHTS_ZH}} + +## 六、下周重点 + +{{NEXT_WEEK_PRIORITIES_ZH}} +` diff --git a/internal/qaopsmirror/weekly_template_test.go b/internal/qaopsmirror/weekly_template_test.go new file mode 100644 index 0000000..f78e8d6 --- /dev/null +++ b/internal/qaopsmirror/weekly_template_test.go @@ -0,0 +1,61 @@ +package qaopsmirror + +import ( + "encoding/json" + "knowledge-governance/internal/qaopsquality" + "strings" + "testing" + "time" +) + +func TestWeeklyTemplateHistoricalRatesKeepSourceWeekKeyAndUseMatchingBoundaries(t *testing.T) { + now := time.Date(2026, 10, 19, 12, 0, 0, 0, time.UTC) + start, end, e := weeklyDraftBounds("2026-W40", now) + if e != nil { + t.Fatal(e) + } + data := Dashboard{Start: start, End: end, MonthStart: dashboardDay(end.Add(-time.Second)), ReportedWeeks: []DashboardReported{{Week: "2026WK40", WeekStart: start, WeekEnd: end, CoveragePercent: 57.23, CorrectnessPercent: 92.34, Source: "user_provided_history"}}} + md, seed, e := renderWeeklyTemplate("2026-W40", data, now) + if e != nil { + t.Fatal(e) + } + var meta map[string]any + if json.Unmarshal(seed, &meta) != nil { + t.Fatal("invalid seed") + } + if !strings.Contains(md, "Coverage Rate: 57.23%") || !strings.Contains(md, "Accuracy Rate: 92.34%") || meta["quality_source"] != "reported-history" || meta["quality_source_week_key"] != "2026WK40" { + t.Fatalf("historical source key must remain verbatim and match by real week boundaries: %s", md) + } + if data.ReportedWeeks[0].Week != "2026WK40" { + t.Fatal("rewrote source week identifier") + } +} + +func TestWeeklyTemplateUsesExactBilingualSixSectionsAndRealMetricValues(t *testing.T) { + now := time.Date(2026, 10, 19, 12, 0, 0, 0, time.UTC) + start, end, e := weeklyDraftBounds("2026-W41", now) + if e != nil { + t.Fatal(e) + } + p := func(v float64) *float64 { return &v } + data := Dashboard{Start: start, End: end, MonthStart: time.Date(2026, 10, 1, 0, 0, 0, 0, dashboardLocation), TotalIssues: 1234, NewThisWeek: 12, Quality: qaopsquality.Summary{Rates: qaopsquality.Rates{Coverage: qaopsquality.Rate{Percent: p(0)}, Correctness: qaopsquality.Rate{}}}, Regions: []DashboardRegion{{ID: "a", Name: "APAC", Frequency: p(0), UsageCoverage: qaopsquality.Rate{Percent: p(10)}}, {ID: "b", Name: "America", Frequency: p(2), UsageCoverage: qaopsquality.Rate{Percent: p(5)}}, {ID: "c", Name: "unassigned\n## fake", Frequency: nil}}} + md, seed, e := renderWeeklyTemplate("2026-W41", data, now) + if e != nil { + t.Fatal(e) + } + if strings.Count(md, "\n## ") != 12 || !strings.HasPrefix(md, "# Knowledge Base Weekly Report - 2026 WK41") || strings.Index(md, "## 6. Next Week Priorities") > strings.Index(md, "# 知识库周报") || !strings.Contains(md, "问题总数:1234") || !strings.Contains(md, "New This Week: 12") || !strings.Contains(md, "Coverage Rate: 0.00%") || !strings.Contains(md, "Accuracy Rate: —") || strings.Contains(md, "{{") || strings.Contains(md, "\n## fake") { + t.Fatalf("skill template, trusted values or escaping missing: %s", md) + } + if !strings.Contains(md, "[待填写]") || !strings.Contains(md, "[To be completed]") { + t.Fatal("invented authored highlights instead of leaving placeholders") + } + average := md[strings.Index(md, "Regional Average"):strings.Index(md, "## 4.")] + coverage := md[strings.Index(md, "Regional Usage Coverage"):strings.Index(md, "## 5.")] + if strings.Index(average, "Americas") > strings.Index(average, "APAC") || strings.Index(coverage, "APAC") > strings.Index(coverage, "Americas") { + t.Fatal("regional rankings not sorted independently") + } + var meta map[string]any + if json.Unmarshal(seed, &meta) != nil || meta["quality_source"] != "live-week" { + t.Fatal("prefill provenance missing") + } +} diff --git a/internal/qaopsquality/master.go b/internal/qaopsquality/master.go index c0dc85e..8d3ca0d 100644 --- a/internal/qaopsquality/master.go +++ b/internal/qaopsquality/master.go @@ -2,6 +2,33 @@ package qaopsquality import "knowledge-governance/internal/dobotaiops" +func ProjectMasterWithEdits(v View, stored map[string]string, derived dobotaiops.CoverageCheck, edits map[string]string) View { + v = ProjectMaster(v, stored, derived) + if value, ok := edits["coverage"]; ok { + v.Effective.Coverage = map[string]string{"是": "covered", "否": "uncovered", "否但不计入": "excluded"}[value] + v.Effective.ExclusionReason = "" + if value == "否但不计入" { + v.Effective.ExclusionReason = "详情字段选择:否但不计入" + } + } + if value, ok := edits["is_correct"]; ok { + v.Effective.Correctness = map[string]string{"是": "correct", "否": "incorrect", "待确认": "pending"}[value] + } + if value, ok := edits["product_category"]; ok { + v.Effective.ProductCategory = value + } + if value, ok := edits["problem_type"]; ok { + v.Effective.ProblemType = value + } + if value, ok := edits["root_cause"]; ok { + v.Effective.RootCause = value + } + if v.Effective.Correctness == "correct" { + v.Effective.RootCause = "" + } + return v +} + // Stored master labels remain their original provenance, not synthetic reviews. func ProjectMaster(v View, stored map[string]string, derived dobotaiops.CoverageCheck) View { v.EffectiveSource = "master-effective-fields-v1" diff --git a/internal/qaopsquality/master_inline_test.go b/internal/qaopsquality/master_inline_test.go new file mode 100644 index 0000000..a5b21b9 --- /dev/null +++ b/internal/qaopsquality/master_inline_test.go @@ -0,0 +1,31 @@ +package qaopsquality + +import ( + "knowledge-governance/internal/dobotaiops" + "testing" +) + +func TestMasterInlineEditsOverrideLegacyReviewOnlyForSelectedFields(t *testing.T) { + v := View{State: "reviewed", Revision: 7, Effective: Fields{Coverage: "covered", Correctness: "incorrect", ProductCategory: "legacy", ProblemType: "故障", RootCause: "知识过期"}, LatestReview: &Review{Revision: 7}} + edits := map[string]string{"coverage": "否但不计入", "is_correct": "是", "root_cause": "检索不到"} + got := ProjectMasterWithEdits(v, map[string]string{}, dobotaiops.CoverageCheck{}, edits) + if got.Effective.Coverage != "excluded" || got.Effective.ExclusionReason == "" || got.Effective.Correctness != "correct" || got.Effective.RootCause != "" || got.Effective.ProductCategory != "legacy" || got.Effective.ProblemType != "故障" || got.LatestReview.Revision != 7 { + t.Fatalf("inline override/reset dependency/legacy preservation failed: %+v", got) + } + reverted := ProjectMasterWithEdits(got, nil, dobotaiops.CoverageCheck{}, map[string]string{"coverage": "否", "is_correct": "待确认", "problem_type": "咨询", "root_cause": "知识缺失"}) + if reverted.Effective.Coverage != "uncovered" || reverted.Effective.ExclusionReason != "" || reverted.Effective.Correctness != "pending" || reverted.Effective.ProblemType != "咨询" || reverted.Effective.RootCause != "知识缺失" { + t.Fatalf("canonical labels/reset failed: %+v", reverted) + } +} + +func TestMasterInlineEditChangesOnlyChosenFieldWithoutFabricatingReview(t *testing.T) { + v := View{State: "unreviewed"} + stored := map[string]string{"coverage": "是", "product_category": "CRV"} + got := ProjectMasterWithEdits(v, stored, dobotaiops.CheckMasterCoverage("existing answer", true), map[string]string{"product_category": "MG400"}) + if got.Effective.ProductCategory != "MG400" || got.Effective.Correctness != "" || got.Effective.ProblemType != "" || got.State != "unreviewed" || got.LatestReview != nil { + t.Fatalf("single-field edit lost or implicitly created other conclusions: %+v", got) + } + if stored["product_category"] != "CRV" { + t.Fatal("mutated source labels") + } +} diff --git a/web/src/QADepartmentStatistics.tsx b/web/src/QADepartmentStatistics.tsx index 9c938cb..0b7589e 100644 --- a/web/src/QADepartmentStatistics.tsx +++ b/web/src/QADepartmentStatistics.tsx @@ -2,11 +2,12 @@ import { useEffect, useMemo, useRef, useState } from 'react' import type { DepartmentStatisticsSnapshot, DepartmentStatisticsQuery } from './qaDepartmentStatistics' import { fetchDepartmentStatistics } from './qaDepartmentStatistics' import { departmentTree, selectDepartmentBranch, subtreeIDs, defaultDepartmentCollapsed } from './qaRegionSelection' -import { currentQAWeek, qaWeek } from './qaWeeks' +import { currentQAWeekValue } from './qaWeeks' import { bindOrganizationDepartments } from './qaOrg' import type { RegionDefinition } from './qaOrg' import { QARegions } from './QARegions' import './qaDepartmentStatistics.css' +import { QAPeriodInput } from './QAPeriodInput' export interface DepartmentStatisticsViewProps { data: DepartmentStatisticsSnapshot selected?: string[] @@ -165,10 +166,7 @@ export function DepartmentRegionBinding({ ) } -const currentWeekValue = () => { - const w = currentQAWeek() - return `${w.year}-W${String(w.week).padStart(2, '0')}` -} +const currentWeekValue = currentQAWeekValue export function QARegionalStatistics({ onSaved }: { onSaved?: () => void } = {}) { const [period, setPeriod] = useState<'all' | 'month' | 'week'>('all'), [month, setMonth] = useState(currentMonth), @@ -325,39 +323,24 @@ export function QARegionalStatistics({ onSaved }: { onSaved?: () => void } = {}) {period === 'month' ? ( - + ) : null} {period === 'week' ? ( - + ) : null}