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} @@ -608,7 +595,7 @@ export function QAOperations() { }} /> ) : view === 'weekly' ? ( -