feat(qaops): 完成详情下拉与每周自动保存草稿
build / build (push) Successful in 1m7s

详情使用共享主表字典,稀疏修改所选字段并保留原始问答及旧复核审计。
月周统计提供文本和下拉双入口;每租户每周一份持久草稿,按技能六节双语模板真实预填。
加入幂等自动保存、冲突保留、未知响应重试、切周及离开保护和schema58非破坏降级保护。

验证:完整make verify、隔离PostgreSQL全mirror竞态回归、真实恢复库浏览器验收通过。
浏览器实测字段精确读回、月周双输入、模板预填、MD/CSV、自动保存、冲突、失败重试与API重启恢复。
未连接或改写DobotAI生产源库,未运行付费模型;提交时尚未部署。
This commit is contained in:
姚章浩
2026-10-20 05:41:57 +08:00
parent 154b0b62ad
commit 87f97a2f9b
47 changed files with 2587 additions and 180 deletions
+13
View File
@@ -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
+4
View File
@@ -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",
+1
View File
@@ -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"`
+56
View File
@@ -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})
}
+4 -1
View File
@@ -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
}
+82
View File
@@ -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})
}
+5
View File
@@ -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) })
@@ -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;
@@ -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)
);
@@ -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])
@@ -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)
}
}
}
+2 -6
View File
@@ -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] {
+5 -4
View File
@@ -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
}
+25
View File
@@ -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
}
@@ -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" {
+8 -4
View File
@@ -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)
}
}
}
+1 -1
View File
@@ -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)
+151
View File
@@ -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(&currentRevision, &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
}
@@ -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(&current); 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)
}
}
+5 -5
View File
@@ -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()
+179
View File
@@ -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(&current.Revision, &current.Markdown, &current.Seed, &current.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
}
@@ -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<script>not executable</script>\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)
}
}
@@ -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)
}
}
+175
View File
@@ -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("&", "&amp;", "<", "&lt;", ">", "&gt;", "\\", "\\\\", "*", "\\*", "_", "\\_", "[", "\\[", "]", "\\]", "|", "\\|", "`", "\\`", "\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}}
`
@@ -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")
}
}
+27
View File
@@ -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"
@@ -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")
}
}
+19 -36
View File
@@ -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({
</div>
)
}
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 } = {})
</select>
</label>
{period === 'month' ? (
<label>
统计月份
<input
aria-label="部门统计月份"
type="month"
min="2000-01"
max={currentMonth()}
value={month}
onChange={(e) => setMonth(e.target.value)}
/>
</label>
<QAPeriodInput
kind="month"
label="统计月份"
ariaLabel="部门统计月份"
value={month}
max={currentMonth()}
onChange={setMonth}
/>
) : null}
{period === 'week' ? (
<label>
统计周次(北京时间周一至周日)
<input
aria-label="部门统计周次"
type="week"
min="2000-W01"
max={currentWeekValue()}
value={week}
onChange={(e) => setWeek(e.target.value)}
/>
<small>
{(() => {
try {
return qaWeek(Number(week.slice(0, 4)), Number(week.slice(6))).label
} catch {
return '请选择有效自然周'
}
})()}
</small>
</label>
<QAPeriodInput
kind="week"
label="统计周次(北京时间周一至周日)"
ariaLabel="部门统计周次"
value={week}
max={currentWeekValue()}
onChange={setWeek}
/>
) : null}
<label>
部门
+144
View File
@@ -0,0 +1,144 @@
import { useEffect, useRef, useState } from 'react'
import './qaPeriodInput.css'
import type { QARecord } from './qaOps'
import { qaColumns } from './qaOps'
import { editableFieldKeys, editableFieldValue, fetchFieldOptions, saveInlineField } from './qaInlineFields'
import type { EditableField, FieldOptions } from './qaInlineFields'
import { newOperationID } from './operationKey'
import { qaFieldColors } from './qaFieldColors'
export function QAInlineFields({
record,
onSaved,
onReload,
}: {
record: QARecord
onSaved: (record: QARecord) => void
onReload: () => void
}) {
const [options, setOptions] = useState<FieldOptions | null>(null),
[error, setError] = useState(''),
[notice, setNotice] = useState(''),
[saving, setSaving] = useState(false),
[reloadOptions, setReloadOptions] = useState(0)
const [pending, setPending] = useState<{
record: QARecord
field: EditableField
value: string
nonce: string
} | null>(null)
const writing = useRef<AbortController | null>(null)
useEffect(() => {
const c = new AbortController()
setError('')
void fetchFieldOptions(c.signal)
.then(setOptions)
.catch((e) => {
if (!c.signal.aborted) setError(e instanceof Error ? e.message : '主表选项读取失败')
})
return () => c.abort()
}, [reloadOptions])
useEffect(() => () => writing.current?.abort(), [])
const save = async (p: NonNullable<typeof pending>) => {
if (writing.current) return
const c = new AbortController()
writing.current = c
setSaving(true)
setError('')
setNotice('')
try {
const fresh = await saveInlineField(p.record, p.field, p.value, p.nonce, c.signal)
if (!c.signal.aborted) {
setPending(null)
onSaved(fresh)
setNotice('已保存')
}
} catch (e) {
if (!c.signal.aborted) setError(e instanceof Error ? e.message : '字段保存失败,选择尚未确认')
} finally {
writing.current = null
if (!c.signal.aborted) setSaving(false)
}
}
return (
<div className="qa-inline-fields">
<div className="qa-detail-quality" aria-label="固定质量字段">
{editableFieldKeys.map((field) => {
const value = pending?.field === field ? pending.value : editableFieldValue(record, field)
const rootNotNeeded = field === 'root_cause' && editableFieldValue(record, 'is_correct') === '是'
return (
<div key={field} data-quality-field={field}>
<label>
<span>{qaColumns.find((c) => c.key === field)!.label}</span>
<select
aria-label={`修改${qaColumns.find((c) => c.key === field)!.label}`}
title="选择后自动保存"
value={rootNotNeeded ? '' : value}
disabled={
saving || !!pending || !options || !record.record_origin || !record.quality || rootNotNeeded
}
style={qaFieldColors(field, value || '—')}
onChange={(e) => {
if (e.target.value === editableFieldValue(record, field)) return
try {
const next = { record, field, value: e.target.value, nonce: newOperationID() }
setPending(next)
void save(next)
} catch (e) {
setError(e instanceof Error ? e.message : '无法生成保存标识')
}
}}
>
<option value="" disabled>
{rootNotNeeded ? '无需根因' : '未设置'}
</option>
{value && !options?.[field].includes(value) ? (
<option value={value} disabled>
{value}
</option>
) : null}
{options?.[field].map((v) => (
<option key={v} value={v}>
{v}
</option>
))}
</select>
</label>
</div>
)
})}
</div>
{saving || notice ? (
<p className="qa-inline-save" role="status" aria-live="polite">
{saving ? '保存中…' : notice}
</p>
) : null}
{error ? (
<p role="alert" className="error-banner">
{error}{' '}
{pending ? (
<>
<button type="button" disabled={saving} onClick={() => void save(pending)}>
重试保存
</button>
<button
type="button"
disabled={saving}
onClick={() => {
setPending(null)
setError('')
onReload()
}}
>
放弃并重读
</button>
</>
) : (
<button type="button" onClick={() => setReloadOptions((v) => v + 1)}>
重试读取选项
</button>
)}
</p>
) : null}
</div>
)
}
+9
View File
@@ -52,6 +52,15 @@ const props = {
}
describe('QA operations workbench', () => {
it('edits five fields directly in the first row without a separate review or feedback form', () => {
const html = renderToStaticMarkup(
<QAOperationsView {...props} detail={{ ...item, record_origin: 'historical_excel' }} />
)
const drawer = html.slice(html.indexOf('<aside'))
expect((drawer.match(/<select/g) ?? []).length).toBe(5)
for (const copy of ['人工复核与留档', '保存复核', '复核理由', '用户反馈', '质量留档/人工复核未配置'])
expect(drawer).not.toContain(copy)
})
it('shows each persisted AI task in the existing operation cell and explains positive master coverage', () => {
const withTasks = {
...item,
+22 -35
View File
@@ -8,7 +8,7 @@ import { ledgerWindow, ledgerCellPreview } from './qaLedgerRendering'
import { QARegionalStatistics } from './QADepartmentStatistics'
import { QADashboard } from './QADashboard'
import { QAWeekly } from './QAWeekly'
import { QAQualityEditor } from './QAQuality'
import { QAInlineFields } from './QAInlineFields'
import { qaFieldColors, qaTagColumns } from './qaFieldColors'
import { qualityLabels } from './qaQuality'
import { QAWeekPicker } from './QAWeekPicker'
@@ -272,16 +272,12 @@ export function QAOperationsView(props: ViewProps) {
</button>
</header>
{detail ? (
<div className="qa-detail-quality" aria-label="固定质量字段">
{(['coverage', 'is_correct', 'product_category', 'problem_type', 'root_cause'] as const).map((key) => (
<div key={key} data-quality-field={key}>
<span>{qaColumns.find((column) => column.key === key)!.label}</span>
<strong className="qa-field-tag" style={qaFieldColors(key, qaCellValue(detail, key) ?? '—')}>
{qaCellValue(detail, key) ?? '—'}
</strong>
</div>
))}
</div>
<QAInlineFields
key={detail.id}
record={detail}
onSaved={props.onQualitySaved ?? (() => {})}
onReload={props.onReloadDetail ?? (() => {})}
/>
) : null}
{props.detailLoading ? <p role="status">正在读取完整问答…</p> : null}
{props.detailError ? (
@@ -334,28 +330,6 @@ export function QAOperationsView(props: ViewProps) {
?.map((task) => task.updated_at)
.join(':')}
/>
{detail.quality ? (
<QAQualityEditor
key={detail.id}
recordId={detail.id}
dataset={detail.record_origin ? 'master' : undefined}
quality={detail.quality}
onSaved={props.onQualitySaved ?? (() => {})}
onReload={props.onReloadDetail ?? (() => {})}
/>
) : (
<p className="qa-ops-muted">质量留档/人工复核未配置。</p>
)}
<h4>用户反馈</h4>
<p>
提问人:
{detail.feedback.asker_value === 'up'
? '点赞'
: detail.feedback.asker_value === 'down'
? '点踩'
: '无反馈'}
;全部反馈:赞 {detail.feedback.up} / 踩 {detail.feedback.down}
</p>
<h4>引用知识</h4>
<pre>{JSON.stringify(detail.references, null, 2)}</pre>
<details>
@@ -379,6 +353,10 @@ function initialDraft() {
export function QAOperations() {
const [view, setView] = useState<QAOpsView>('records')
const weeklyBeforeLeave = useRef<(() => Promise<boolean>) | null>(null)
const registerWeeklyLeave = useCallback((guard: (() => Promise<boolean>) | null) => {
weeklyBeforeLeave.current = guard
}, [])
const auth = useOptionalAuth()
const canRead = auth?.identity?.role === 'admin'
const [draft, setDraft] = useState(initialDraft)
@@ -528,7 +506,16 @@ export function QAOperations() {
</p>
) : (
<>
<QAOperationsTabs view={view} onChange={setView} />
<QAOperationsTabs
view={view}
onChange={(next) => {
if (view === 'weekly' && next !== 'weekly' && weeklyBeforeLeave.current)
void weeklyBeforeLeave.current().then((ok) => {
if (ok) setView(next)
})
else setView(next)
}}
/>
{queryView ? (
<>
{mirrorChanged ? <p className="qa-ops-warning">数据已更新,关闭详情后刷新。</p> : null}
@@ -608,7 +595,7 @@ export function QAOperations() {
}}
/>
) : view === 'weekly' ? (
<QAWeekly query={query} />
<QAWeekly onBeforeLeave={registerWeeklyLeave} />
) : view === 'dashboard' ? (
<QADashboard
query={query}
+17
View File
@@ -0,0 +1,17 @@
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, it, expect, vi } from 'vitest'
import { QAPeriodInput } from './QAPeriodInput'
describe('one period value with two input methods', () => {
it('offers a text input and dropdown for both months and ISO weeks', () => {
for (const kind of ['month', 'week'] as const) {
const value = kind === 'month' ? '2026-10' : '2026-W43'
const html = renderToStaticMarkup(
<QAPeriodInput kind={kind} label="统计周期" ariaLabel="测试周期" value={value} max={value} onChange={vi.fn()} />
)
expect(html).toContain('type="text"')
expect(html).toContain('<select')
expect(html).toContain(`value="${value}"`)
expect(html).toContain('测试周期下拉选择')
}
})
})
+102
View File
@@ -0,0 +1,102 @@
import { useEffect, useId, useMemo, useState } from 'react'
import type { QAPeriodKind } from './qaPeriodInput'
import { periodInputOptions, validPeriodInput } from './qaPeriodInput'
import { qaWeek } from './qaWeeks'
import './qaPeriodInput.css'
export interface QAPeriodInputProps {
kind: QAPeriodKind
label: string
ariaLabel: string
value: string
max: string
onChange: (value: string) => void
disabled?: boolean
}
export function QAPeriodInput(p: QAPeriodInputProps) {
const id = useId(),
[text, setText] = useState(p.value),
[error, setError] = useState('')
useEffect(() => {
setText(p.value)
setError('')
}, [p.value, p.kind])
const options = useMemo(() => {
const values = periodInputOptions(p.kind, p.max)
if (validPeriodInput(p.kind, p.value, p.max) && !values.includes(p.value)) values.push(p.value)
return values
}, [p.kind, p.max, p.value])
const apply = () => {
if (text === p.value) return
if (!validPeriodInput(p.kind, text, p.max)) {
setError(
`请输入有效的${p.kind === 'month' ? 'YYYY-MM月份' : 'YYYY-Www自然周'},最早2000年、不晚于${p.max};仍显示${p.value}的结果`
)
return
}
setError('')
p.onChange(text)
}
return (
<div className="qa-period-input">
<label htmlFor={id}>{p.label}</label>
<div className="qa-period-combo">
<input
id={id}
type="text"
aria-label={p.ariaLabel}
aria-describedby={`${id}-hint`}
aria-invalid={!!error}
autoComplete="off"
spellCheck={false}
value={text}
disabled={p.disabled}
placeholder={p.kind === 'month' ? 'YYYY-MM' : 'YYYY-Www'}
onChange={(e) => {
setText(e.target.value)
setError('')
}}
onBlur={apply}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
apply()
}
if (e.key === 'Escape') {
setText(p.value)
setError('')
}
}}
/>
<select
aria-label={`${p.ariaLabel}下拉选择`}
value=""
disabled={p.disabled}
onChange={(e) => {
const value = e.target.value
if (!value) return
setText(value)
setError('')
p.onChange(value)
}}
>
<option value="" disabled>
选择
</option>
{options.map((v) => (
<option key={v} value={v}>
{p.kind === 'week' ? `${v} ${qaWeek(Number(v.slice(0, 4)), Number(v.slice(6))).label}` : v}
</option>
))}
</select>
</div>
<small id={`${id}-hint`} role={error ? 'alert' : undefined}>
{error ||
(text !== p.value
? '尚未应用,按回车或移开输入框确认'
: p.kind === 'week'
? qaWeek(Number(p.value.slice(0, 4)), Number(p.value.slice(6))).label
: '可下拉选择,也可直接输入')}
</small>
</div>
)
}
+255 -79
View File
@@ -1,97 +1,273 @@
import { useEffect, useRef, useState } from 'react'
import { useDeferredValue, useEffect, useState, useSyncExternalStore } from 'react'
import type { QAQuery } from './qaOps'
import { fetchAllQARecords } from './qaOps'
import { fetchQADashboardQuality } from './qaDashboard'
import { fetchQAMirrorStatus } from './qaMirror'
import { buildQAWeeklyDraft, downloadQAWeekly } from './qaWeekly'
import type { QAWeeklyDraft } from './qaWeekly'
import { currentQAWeekValue, qaDateTimeToISO, qaWeekQueryRange } from './qaWeeks'
import { WeeklyDraftEditorModel } from './qaWeeklyDrafts'
import { weeklyTransport } from './qaWeeklyDraftAPI'
import { buildQAWeeklyCSV, downloadQAWeekly } from './qaWeekly'
import { QAAnswerMarkdown } from './QAAnswerMarkdown'
export function QAWeekly({ query }: { query: QAQuery | null }) {
const [draft, setDraft] = useState<QAWeeklyDraft | null>(null),
[loading, setLoading] = useState(false),
[error, setError] = useState('')
const request = useRef<AbortController | null>(null)
import { QAPeriodInput } from './QAPeriodInput'
import './qaWeeklyEditor.css'
type LeaveGuard = (() => Promise<boolean>) | null
interface Props {
query?: QAQuery | null
onBeforeLeave?: (guard: LeaveGuard) => void
}
export function QAWeekly({ onBeforeLeave }: Props) {
const [model] = useState(() => new WeeklyDraftEditorModel(currentQAWeekValue(), weeklyTransport))
const state = useSyncExternalStore(model.subscribe, model.getSnapshot, model.getSnapshot)
const [mode, setMode] = useState<'edit' | 'preview' | 'both'>('both'),
[replaceConfirm, setReplaceConfirm] = useState(false),
[reloadConfirm, setReloadConfirm] = useState(false),
[periodReset, setPeriodReset] = useState(0),
[exporting, setExporting] = useState(false),
[exportError, setExportError] = useState('')
const preview = useDeferredValue(state.text)
useEffect(() => {
request.current?.abort()
setDraft(null)
setError('')
setLoading(false)
return () => request.current?.abort()
}, [query])
const generate = async () => {
if (!query || loading) return
request.current?.abort()
const controller = new AbortController()
request.current = controller
setLoading(true)
setDraft(null)
setError('')
void model.initialize()
const unload = (e: BeforeUnloadEvent) => {
if (model.dirty || model.uncertainPrefill) {
e.preventDefault()
e.returnValue = ''
}
}
window.addEventListener('beforeunload', unload)
return () => {
window.removeEventListener('beforeunload', unload)
if (model.dirty && !model.uncertainPrefill) void model.beforeLeave().finally(() => model.dispose())
else model.dispose()
}
}, [model])
useEffect(() => {
onBeforeLeave?.(() => model.beforeLeave())
return () => onBeforeLeave?.(null)
}, [model, onBeforeLeave])
const blocked = state.busy || state.loading || !state.document || model.uncertainPrefill
const status = state.loading
? 'loading'
: state.saving
? 'saving'
: state.busy
? 'working'
: state.error
? 'error'
: model.dirty
? 'dirty'
: state.document?.revision
? 'saved'
: 'empty'
const source = state.document?.seed
const generate = () => {
setReplaceConfirm(false)
setReloadConfirm(false)
void model.prefill()
}
const csv = async () => {
if (exporting) return
setExporting(true)
setExportError('')
try {
const before = await fetchQAMirrorStatus(controller.signal)
if (!before.ready || before.blocked) throw Error('本地同步未就绪,暂不能生成周报')
const [page, quality] = await Promise.all([
fetchAllQARecords(query, controller.signal),
fetchQADashboardQuality(query, controller.signal),
])
const after = await fetchQAMirrorStatus(controller.signal)
if (!after.ready || after.blocked || before.revision !== after.revision || page.local_revision !== after.revision)
throw Error('本地副本在生成期间已更新,请重新生成')
const result = buildQAWeeklyDraft(page, query, quality, new Date().toISOString())
if (!controller.signal.aborted) setDraft(result)
const range = qaWeekQueryRange(Number(state.week.slice(0, 4)), Number(state.week.slice(6)))
const start = typeof source?.start === 'string' ? source.start : qaDateTimeToISO(range.start),
end = typeof source?.end === 'string' ? source.end : qaDateTimeToISO(range.end)
const page = await fetchAllQARecords({ dataset: 'master', start, end, keyword: '', cursor: '' })
downloadQAWeekly(buildQAWeeklyCSV(page), `DobotAI-Questions-${state.week}.csv`, 'text/csv;charset=utf-8')
} catch (e) {
if (!controller.signal.aborted) setError(e instanceof Error ? e.message : '周报生成失败')
setExportError(e instanceof Error ? e.message : '明细导出失败')
} finally {
if (!controller.signal.aborted) setLoading(false)
setExporting(false)
}
}
const suffix = query ? new Date(Date.parse(query.start) + 8 * 60 * 60 * 1000).toISOString().slice(0, 10) : 'draft'
return (
<section className="qa-weekly" aria-label="周报生成">
<header className="qa-weekly-toolbar">
<div>
<h3>周报生成</h3>
{query ? (
<span>
{new Date(query.start).toLocaleDateString('zh-CN', { timeZone: 'Asia/Shanghai' })} 至{' '}
{new Date(Date.parse(query.end) - 1).toLocaleDateString('zh-CN', { timeZone: 'Asia/Shanghai' })}
</span>
) : null}
<section className="qa-weekly qa-weekly-editor" aria-label="周报生成" data-weekly-week={state.week}>
<header className="qa-weekly-editor-header">
<h3>周报草稿</h3>
<QAPeriodInput
key={`${state.week}:${periodReset}`}
kind="week"
label="周报周次"
ariaLabel="周报周次"
value={state.week}
max={currentQAWeekValue()}
disabled={state.busy || state.loading}
onChange={(week) => {
setReplaceConfirm(false)
setReloadConfirm(false)
setExportError('')
void model.switchWeek(week).then((ok) => {
if (!ok) setPeriodReset((v) => v + 1)
})
}}
/>
<div className="qa-weekly-editor-actions">
{replaceConfirm ? (
<>
<button type="button" className="qa-weekly-prefill" onClick={() => setReplaceConfirm(false)}>
取消
</button>
<button type="button" onClick={generate}>
确认替换预填
</button>
</>
) : (
<button
type="button"
className="qa-weekly-prefill"
disabled={blocked || state.errorKind === 'conflict'}
onClick={() => {
if (state.document?.revision || state.text) setReplaceConfirm(true)
else generate()
}}
>
{state.document?.revision ? '重新预填数据' : '生成并预填草稿'}
</button>
)}
<button
type="button"
disabled={blocked || !model.dirty || state.saving || state.errorKind === 'conflict'}
onClick={() => void model.beforeLeave()}
>
立即保存
</button>
<button
type="button"
disabled={!state.document}
onClick={() =>
downloadQAWeekly(state.text, `DobotAI_KB_Weekly_Report_${state.week}.md`, 'text/markdown;charset=utf-8')
}
>
下载 MD
</button>
<button type="button" disabled={!state.document || exporting || state.busy} onClick={() => void csv()}>
{exporting ? '导出中…' : '下载明细 CSV'}
</button>
</div>
<button type="button" onClick={() => void generate()} disabled={loading || !query}>
{loading ? '正在生成…' : '生成周报草稿'}
</button>
{draft ? (
<>
<span className="qa-weekly-badge">草稿 · {draft.questions} 条</span>
<button
type="button"
onClick={() =>
downloadQAWeekly(draft.markdown, `DobotAI-问答周报-${suffix}.md`, 'text/markdown;charset=utf-8')
}
>
下载 MD
</button>
<button
type="button"
onClick={() => downloadQAWeekly(draft.csv, `DobotAI-问题明细-${suffix}.csv`, 'text/csv;charset=utf-8')}
>
下载明细 CSV
</button>
</>
) : null}
</header>
{error ? (
<div className="qa-weekly-editor-toolbar">
<div className="qa-weekly-mode" aria-label="编辑与预览">
{(['edit', 'preview', 'both'] as const).map((value) => (
<button type="button" key={value} aria-pressed={mode === value} onClick={() => setMode(value)}>
{{ edit: '编辑', preview: '预览', both: '对照' }[value]}
</button>
))}
</div>
<span role="status" aria-live="polite" className="qa-weekly-editor-status" data-weekly-save-state={status}>
自动保存 ·{' '}
{
{
loading: '正在读取草稿…',
saving: '保存中…',
working: '正在处理…',
error: '未保存或尚未确认',
dirty: '待保存',
saved: '已保存',
empty: '尚未创建',
}[status]
}
{status === 'saved' && state.document?.updated_at
? ` · ${new Date(state.document.updated_at).toLocaleTimeString('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false })}`
: ''}
</span>
</div>
{replaceConfirm ? (
<p className="qa-ops-warning">将先保存当前文字,再用最新统计替换正文;旧保存版本保留。请确认后继续。</p>
) : null}
{state.error ? (
<div role="alert" className="error-banner">
{state.error}
{state.errorKind === 'load' ? (
<button type="button" disabled={state.busy} onClick={() => void model.reload()}>
重试读取
</button>
) : state.errorKind === 'save' ? (
<button type="button" disabled={state.saving || state.busy} onClick={() => void model.beforeLeave()}>
重试保存
</button>
) : state.errorKind === 'prefill' ? (
<button type="button" disabled={state.busy} onClick={generate}>
重试确认预填
</button>
) : null}
{reloadConfirm ? (
<>
<button type="button" className="qa-weekly-reload" onClick={() => setReloadConfirm(false)}>
取消
</button>
<button
type="button"
disabled={state.saving || state.busy}
onClick={() => {
setReloadConfirm(false)
void model.reload()
}}
>
放弃本地修改并读取
</button>
</>
) : (
<button
type="button"
className="qa-weekly-reload"
disabled={state.busy || state.saving}
onClick={() => setReloadConfirm(true)}
>
重新读取服务器版本
</button>
)}
</div>
) : null}
{exportError ? (
<p role="alert" className="error-banner">
{error}
{exportError}
</p>
) : null}
{draft ? (
<article className="qa-weekly-preview">
<QAAnswerMarkdown answer={draft.markdown} />
</article>
) : (
<div className="qa-weekly-empty">{loading ? '正在汇总本地问答…' : '尚未生成周报。'}</div>
)}
<div className="qa-weekly-editor-layout" data-mode={mode}>
{mode !== 'preview' ? (
<div className="qa-weekly-writing">
<label htmlFor="qa-weekly-body">
周报草稿正文 <small>Markdown</small>
</label>
<textarea
id="qa-weekly-body"
aria-label="周报草稿正文"
value={state.text}
disabled={blocked}
spellCheck={false}
placeholder="生成并预填草稿,或直接输入。英文完整部分在上,中文在下;输入后自动保存。"
onChange={(e) => model.setText(e.target.value)}
onKeyDown={(e) => {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 's') {
e.preventDefault()
void model.beforeLeave()
}
}}
/>
</div>
) : null}
{mode !== 'edit' ? (
<article className="qa-weekly-preview" aria-label="周报预览">
{preview ? <QAAnswerMarkdown answer={preview} /> : <p className="qa-ops-muted">尚无草稿正文。</p>}
</article>
) : null}
</div>
{source && source.source === 'governance-master' ? (
<details className="qa-weekly-source">
<summary>预填来源</summary>
<p>
治理正式总表 · {String(source.week)} · 截至{' '}
{typeof source.end === 'string'
? new Date(source.end).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false })
: '—'}{' '}
·{' '}
{{ 'live-week': '当周实时质量指标', 'frozen-week': '已冻结周指标', 'reported-history': '历史上报指标' }[
String(source.quality_source)
] ?? '指标来源未确认'}
</p>
<p>
问题总数与新增是可编辑的系统统计建议;区域采用已保存配置及当前通讯录。未做新的FAQ调整,重点与英文文字请自行填写。草稿不会自动发送。
</p>
</details>
) : null}
</section>
)
}
+15
View File
@@ -0,0 +1,15 @@
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, it, expect } from 'vitest'
import { QAWeekly } from './QAWeekly'
describe('weekly report editor surface', () => {
it('provides a weekly document editor, automatic-save status and a preview without an external-send action', () => {
const html = renderToStaticMarkup(<QAWeekly query={null} />)
expect(html).toContain('<textarea')
expect(html).toContain('周报草稿正文')
expect(html).toContain('自动保存')
expect(html).toContain('周报周次下拉选择')
expect(html).toContain('预览')
expect(html).not.toContain('发送周报')
expect(html).not.toContain('发布周报')
})
})
+73
View File
@@ -0,0 +1,73 @@
import { fetchQARecord, getPayload, qaCellValue } from './qaOps'
import type { QARecord } from './qaOps'
export const editableFieldKeys = ['coverage', 'is_correct', 'product_category', 'problem_type', 'root_cause'] as const
export type EditableField = (typeof editableFieldKeys)[number]
export type FieldOptions = Record<EditableField, string[]>
export function parseFieldOptions(raw: unknown): FieldOptions {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw Error('主表选项读取失败')
const v = raw as Record<string, unknown>
if (v.version !== 'qa-master-options-v1' || !v.fields || typeof v.fields !== 'object' || Array.isArray(v.fields))
throw Error('主表选项版本无效')
const fields = v.fields as Record<string, unknown>
if (Object.keys(fields).length !== editableFieldKeys.length) throw Error('主表选项不完整')
const result = {} as FieldOptions
for (const key of editableFieldKeys) {
const values = fields[key]
if (
!Array.isArray(values) ||
!values.length ||
values.some((v) => typeof v !== 'string' || !v.trim()) ||
new Set(values).size !== values.length
)
throw Error('主表选项不完整')
result[key] = values as string[]
}
return result
}
export async function fetchFieldOptions(signal?: AbortSignal) {
return parseFieldOptions(await getPayload('/api/v1/qa-ops/master/field-options', signal))
}
export function editableFieldValue(record: QARecord, field: EditableField): string {
if (field === 'root_cause' && qaCellValue(record, 'is_correct') === '是') return ''
const value = qaCellValue(record, field) ?? ''
return field === 'coverage' && value === '不计入' ? '否但不计入' : value
}
export async function saveInlineField(
record: QARecord,
field: EditableField,
value: string,
nonce: string,
signal?: AbortSignal
): Promise<QARecord> {
if (!record.record_origin || !record.quality) throw Error('此记录暂不可修改,请重新读取')
const q = record.quality
const raw = await getPayload(`/api/v1/qa-ops/master/records/${encodeURIComponent(record.id)}/fields`, signal, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Governance-Action': 'qa-master-fields' },
body: JSON.stringify({
input_hash: q.input_hash,
input_epoch: q.input_epoch,
base_revision: record.field_edit_revision ?? 0,
base_review_revision: q.revision,
nonce,
fields: { [field]: value },
}),
})
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw Error('字段保存回执无效,请重试读取')
const receipt = raw as Record<string, unknown>
if (
!Number.isSafeInteger(receipt.saved_revision) ||
Number(receipt.saved_revision) < 1 ||
typeof receipt.replayed !== 'boolean'
)
throw Error('字段保存回执无效,请重试读取')
const fresh = await fetchQARecord(record.id, signal, 'master')
if (
fresh.field_edit_revision !== receipt.saved_revision ||
fresh.quality?.input_hash !== q.input_hash ||
fresh.quality.revision !== q.revision ||
editableFieldValue(fresh, field) !== value
)
throw Error('保存后字段再次变化,未将旧结果标为成功,请重新读取核对')
return fresh
}
+18 -1
View File
@@ -84,6 +84,7 @@ export function mergeQAPages(current: QARecordPage, next: QARecordPage): QARecor
}
}
export interface QARecord {
field_edit_revision?: number
background_tasks?: AIWorkState[]
record_origin?: 'dobotai_2_0' | 'historical_excel'
master_fields?: Partial<Record<QAColumn, string>>
@@ -215,6 +216,7 @@ export function parseQARecord(value: unknown): QARecord {
if (typeof row.is_correct !== 'boolean' && row.is_correct !== null) return invalid()
item.is_correct = row.is_correct
}
if (row.field_edit_revision !== undefined) item.field_edit_revision = count(row.field_edit_revision)
if (row.record_origin !== undefined) {
if (row.record_origin !== 'dobotai_2_0' && row.record_origin !== 'historical_excel') return invalid()
item.record_origin = row.record_origin
@@ -291,6 +293,16 @@ export function parseQARecordPage(value: unknown): QARecordPage {
}
}
export class QARequestError extends Error {
constructor(
message: string,
public readonly status: number
) {
super(message)
this.name = 'QARequestError'
}
}
export async function getPayload(
path: string,
signal?: AbortSignal,
@@ -300,7 +312,10 @@ export async function getPayload(
const raw: unknown = await response.json().catch(() => null)
if (!response.ok) {
const payload = raw && typeof raw === 'object' ? (raw as Record<string, unknown>) : null
throw new Error(typeof payload?.error === 'string' ? payload.error : `问答数据读取失败(HTTP ${response.status})`)
throw new QARequestError(
typeof payload?.error === 'string' ? payload.error : `问答数据读取失败(HTTP ${response.status})`,
response.status
)
}
return object(raw).data
}
@@ -399,6 +414,8 @@ const qaAskedAtFormatter = new Intl.DateTimeFormat('zh-CN', {
})
export function qaCellValue(item: QARecord, column: QAColumn): string | null {
if (column === 'root_cause' && item.field_edit_revision && item.quality?.effective.correctness === 'correct')
return null
if (column === 'trigger_person') return triggerPersonName(item.master_fields?.trigger_person ?? item.trigger_person)
const reviewedQuality =
+89
View File
@@ -0,0 +1,89 @@
.qa-period-input {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
max-width: 100%;
font-size: 13px;
}
.qa-period-combo {
display: flex;
align-items: stretch;
width: 240px;
max-width: 100%;
border: 1px solid #d7e0eb;
border-radius: 8px;
background: #fff;
overflow: hidden;
}
.qa-period-combo input {
min-width: 0;
width: 100%;
flex: 1;
border: 0 !important;
border-radius: 0 !important;
background: transparent;
padding: 8px 10px;
font: inherit;
}
.qa-period-combo select {
width: 74px;
min-width: 74px;
border: 0 !important;
border-left: 1px solid #e3e9f0 !important;
border-radius: 0 !important;
background: #f5f8fc;
padding: 7px 5px;
font: inherit;
cursor: pointer;
}
.qa-period-combo:focus-within {
outline: 2px solid #83b6f4;
outline-offset: 1px;
}
.qa-period-input small {
font-size: 12px;
color: #64748b;
max-width: 360px;
white-space: normal;
}
.qa-period-input small[role='alert'] {
color: #b42318;
}
.qa-period-combo input[aria-invalid='true'] {
background: #fff4f2;
}
.qa-inline-fields {
flex: none;
min-width: 0;
}
.qa-inline-fields .qa-detail-quality label {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.qa-inline-fields .qa-detail-quality select {
min-width: 0;
width: 100%;
max-width: 100%;
border: 1px solid #d7e0eb;
border-radius: 6px;
padding: 6px 4px;
font: inherit;
font-weight: 600;
cursor: pointer;
}
.qa-inline-fields .qa-detail-quality select:disabled {
cursor: default;
opacity: 0.75;
}
.qa-inline-save {
margin: 2px 18px 6px;
font-size: 12px;
color: #35634c;
}
.qa-inline-fields > .error-banner {
margin: 4px 18px 8px;
font-size: 12px;
}
+17
View File
@@ -0,0 +1,17 @@
import { describe, it, expect } from 'vitest'
import { validPeriodInput, periodInputOptions } from './qaPeriodInput'
describe('typed or dropdown natural periods', () => {
it('accepts canonical months and real cross-year weeks and rejects incomplete, future or impossible inputs', () => {
expect(validPeriodInput('month', '2026-02', '2026-10')).toBe(true)
expect(validPeriodInput('week', '2020-W53', '2021-W01')).toBe(true)
for (const value of ['', '2026-1', '2026-00', '2026-13', '2027-01', ' 2026-02', '1999-12'])
expect(validPeriodInput('month', value, '2026-10')).toBe(false)
for (const value of ['', '2021-W53', '2021-W1', '2021-W00', '2026-W44', '2020-W+1', '2020-W53 '])
expect(validPeriodInput('week', value, '2026-W43')).toBe(false)
})
it('offers selectable periods across year boundaries without replacing the typed value with an invalid date', () => {
expect(periodInputOptions('week', '2021-W01').slice(0, 2)).toEqual(['2021-W01', '2020-W53'])
expect(periodInputOptions('month', '2026-01').slice(0, 2)).toEqual(['2026-01', '2025-12'])
expect(periodInputOptions('week', '2021-W53')).toEqual([])
})
})
+44
View File
@@ -0,0 +1,44 @@
import { qaWeek, currentQAWeek, qaWeekValue } from './qaWeeks'
export type QAPeriodKind = 'month' | 'week'
export function validPeriodInput(kind: QAPeriodKind, value: string, max?: string): boolean {
const pattern = kind === 'month' ? /^[0-9]{4}-[0-9]{2}$/ : /^[0-9]{4}-W[0-9]{2}$/
if (!pattern.test(value)) return false
const year = Number(value.slice(0, 4))
if (year < 2000 || year > 9998) return false
if (kind === 'month') {
const month = Number(value.slice(5))
if (month < 1 || month > 12) return false
} else {
try {
qaWeek(year, Number(value.slice(6)))
} catch {
return false
}
}
return !max || (validPeriodInput(kind, max) && value <= max)
}
export function periodInputOptions(kind: QAPeriodKind, max: string): string[] {
if (!validPeriodInput(kind, max)) return []
const result: string[] = []
if (kind === 'month') {
let year = Number(max.slice(0, 4)),
month = Number(max.slice(5))
for (let i = 0; i < 36 && year >= 2000; i++) {
result.push(`${year}-${String(month).padStart(2, '0')}`)
month--
if (month === 0) {
year--
month = 12
}
}
} else {
const start = qaWeek(Number(max.slice(0, 4)), Number(max.slice(6))).start
const monday = Date.parse(`${start}:00Z`)
for (let i = 0; i < 156; i++) {
const p = currentQAWeek(new Date(monday - i * 7 * 86400000))
if (p.year < 2000) break
result.push(qaWeekValue(p))
}
}
return result
}
+13 -6
View File
@@ -24,6 +24,18 @@ function csvCell(value: unknown) {
const safe = /^[\t\r\n]|^\s*[=+\-@]/.test(v) ? "'" + v : v
return '"' + safe.replace(/"/g, '""') + '"'
}
export function buildQAWeeklyCSV(page: QARecordPage): string {
if (page.has_more || page.total !== page.items.length || new Set(page.items.map((r) => r.id)).size !== page.total)
throw Error('明细未完整读取,未导出部分数据')
const csv =
'\ufeff' +
[
qaColumns.map((c) => csvCell(c.label)).join(','),
...page.items.map((row) => qaColumns.map((c) => csvCell(qaCellValue(row, c.key))).join(',')),
].join('\r\n')
return csv
}
export function buildQAWeeklyDraft(
page: QARecordPage,
query: QAQuery,
@@ -106,12 +118,7 @@ export function buildQAWeeklyDraft(
]
.filter(Boolean)
.join('\n\n') + '\n'
const csv =
'\ufeff' +
[
qaColumns.map((c) => csvCell(c.label)).join(','),
...page.items.map((row) => qaColumns.map((c) => csvCell(qaCellValue(row, c.key))).join(',')),
].join('\r\n')
const csv = buildQAWeeklyCSV(page)
return {
markdown: md.replace(/\|\n\n\|/g, '|\n|'),
csv,
+54
View File
@@ -0,0 +1,54 @@
import { describe, it, expect, vi, afterEach } from 'vitest'
import { parseWeeklyDocument, weeklyTransport } from './qaWeeklyDraftAPI'
afterEach(() => vi.unstubAllGlobals())
const empty = { week: '2026-W41', revision: 0, markdown: '', seed: {}, updated_at: null }
describe('weekly document protocol', () => {
it('verifies an exact GET after POST and refuses a newer conflicting head', async () => {
const document = { ...empty, revision: 1, markdown: '用户正文', updated_at: '2026-10-19T10:00:00Z' }
const fetch = vi
.fn()
.mockResolvedValueOnce(
new Response(JSON.stringify({ data: { draft: document, replayed: false } }), { status: 200 })
)
.mockResolvedValueOnce(new Response(JSON.stringify({ data: document }), { status: 200 }))
vi.stubGlobal('fetch', fetch)
expect(
await weeklyTransport.write(empty.week, {
base_revision: 0,
nonce: 'fixture_client_weekly_0001',
markdown: document.markdown,
})
).toEqual(document)
expect(fetch.mock.calls).toHaveLength(2)
expect(fetch.mock.calls[0][1].method).toBe('POST')
expect(fetch.mock.calls[1][1].method ?? 'GET').toBe('GET')
fetch
.mockResolvedValueOnce(
new Response(JSON.stringify({ data: { draft: document, replayed: true } }), { status: 200 })
)
.mockResolvedValueOnce(
new Response(JSON.stringify({ data: { ...document, revision: 2, markdown: 'another editor' } }), {
status: 200,
})
)
await expect(
weeklyTransport.write(empty.week, {
base_revision: 0,
nonce: 'fixture_client_weekly_0001',
markdown: document.markdown,
})
).rejects.toMatchObject({ status: 409 })
})
it('refuses foreign weeks, fabricated saved content and invalid revisions rather than showing false success', () => {
expect(parseWeeklyDocument(empty, empty.week)).toEqual(empty)
for (const value of [
{ ...empty, week: '2026-W40' },
{ ...empty, revision: -1 },
{ ...empty, revision: 0, markdown: 'invented persisted content' },
{ ...empty, revision: 1, updated_at: null },
{ ...empty, seed: [] },
{ ...empty, markdown: 9 },
])
expect(() => parseWeeklyDocument(value, empty.week)).toThrow()
})
})
+63
View File
@@ -0,0 +1,63 @@
import type { WeeklyDocument, WeeklyTransport } from './qaWeeklyDrafts'
import { validPeriodInput } from './qaPeriodInput'
import { getPayload, QARequestError } from './qaOps'
import type { WeeklyWriteRequest, WeeklyPrefillRequest } from './qaWeeklyDrafts'
const invalid = (): never => {
throw Error('草稿响应无效,未展示或标记为已保存')
}
export function parseWeeklyDocument(value: unknown, week: string): WeeklyDocument {
if (!value || typeof value !== 'object' || Array.isArray(value)) return invalid()
const v = value as Record<string, unknown>
if (
v.week !== week ||
!validPeriodInput('week', week) ||
typeof v.revision !== 'number' ||
!Number.isSafeInteger(v.revision) ||
v.revision < 0 ||
typeof v.markdown !== 'string' ||
new TextEncoder().encode(v.markdown).length > 256 * 1024 ||
!v.seed ||
typeof v.seed !== 'object' ||
Array.isArray(v.seed)
)
return invalid()
if (v.revision === 0) {
if (v.markdown !== '' || v.updated_at !== null || Object.keys(v.seed).length) return invalid()
} else if (typeof v.updated_at !== 'string' || !Number.isFinite(Date.parse(v.updated_at))) return invalid()
return {
week,
revision: v.revision,
markdown: v.markdown,
seed: v.seed as Record<string, unknown>,
updated_at: v.updated_at as string | null,
}
}
const endpoint = (week: string) => `/api/v1/qa-ops/master/weekly-drafts/${encodeURIComponent(week)}`
async function read(week: string) {
return parseWeeklyDocument(await getPayload(endpoint(week)), week)
}
async function save(week: string, q: WeeklyWriteRequest | WeeklyPrefillRequest, prefill: boolean) {
const raw = await getPayload(endpoint(week) + (prefill ? '/prefill' : ''), undefined, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Governance-Action': 'qa-weekly-draft' },
body: JSON.stringify(q),
})
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return invalid()
const receipt = raw as Record<string, unknown>,
draft = parseWeeklyDocument(receipt.draft, week)
if (
typeof receipt.replayed !== 'boolean' ||
draft.revision !== q.base_revision + 1 ||
(!prefill && draft.markdown !== (q as WeeklyWriteRequest).markdown)
)
return invalid()
const current = await read(week)
if (current.revision !== draft.revision || current.markdown !== draft.markdown)
throw new QARequestError('本周草稿在保存后又发生变化;你的文字仍保留,请核对服务器版本', 409)
return current
}
export const weeklyTransport: WeeklyTransport = {
read,
write: (week, q) => save(week, q, false),
prefill: (week, q) => save(week, q, true),
}
+110
View File
@@ -0,0 +1,110 @@
import { describe, it, expect, vi, afterEach } from 'vitest'
import { WeeklyDraftEditorModel } from './qaWeeklyDrafts'
import type { WeeklyDocument, WeeklyWriteRequest } from './qaWeeklyDrafts'
const empty: WeeklyDocument = { week: '2026-W41', revision: 0, markdown: '', seed: {}, updated_at: null }
const saved = (q: WeeklyWriteRequest): WeeklyDocument => ({
...empty,
revision: q.base_revision + 1,
markdown: q.markdown,
updated_at: '2026-10-19T10:00:00Z',
})
afterEach(() => {
vi.useRealTimers()
vi.unstubAllGlobals()
})
describe('weekly draft autosave model', () => {
it('retains input and reports a safe failure if an operation identifier cannot be created', async () => {
const write = vi.fn()
const model = new WeeklyDraftEditorModel(empty.week, { read: async () => empty, write, prefill: vi.fn() })
await model.initialize()
model.setText('不可丢失的输入')
vi.stubGlobal('crypto', undefined)
expect(await model.flush()).toBe(false)
expect(model.state.text).toBe('不可丢失的输入')
expect(model.state.errorKind).toBe('save')
expect(write).not.toHaveBeenCalled()
model.dispose()
})
it('does not regenerate an existing draft on opening and saves edited text before explicit replacement', async () => {
const initial = { ...empty, revision: 1, markdown: '原草稿', updated_at: '2026-10-19T10:00:00Z' }
const write = vi.fn(async (_week: string, q: WeeklyWriteRequest) => saved(q)),
prefill = vi.fn(async (week: string, q: { base_revision: number; nonce: string }) => ({
...initial,
week,
revision: q.base_revision + 1,
markdown: '新预填模板',
}))
const model = new WeeklyDraftEditorModel(empty.week, { read: async () => initial, write, prefill })
await model.initialize()
expect(prefill).not.toHaveBeenCalled()
model.setText('用户原稿的最后输入')
expect(await model.prefill()).toBe(true)
expect(write.mock.calls[0][1].markdown).toBe('用户原稿的最后输入')
expect(prefill.mock.calls[0][1].base_revision).toBe(2)
expect(model.state.text).toBe('新预填模板')
expect(model.state.document?.revision).toBe(3)
model.dispose()
})
it('saves outstanding text before switching weeks and restores each week independently', async () => {
const documents = new Map<string, WeeklyDocument>([
[empty.week, empty],
[
'2026-W40',
{ ...empty, week: '2026-W40', revision: 4, markdown: '上周原稿', updated_at: '2026-10-19T10:00:00Z' },
],
])
const read = vi.fn(async (week: string) => documents.get(week)!),
write = vi.fn(async (week: string, q: WeeklyWriteRequest) => {
const value = { ...saved(q), week }
documents.set(week, value)
return value
})
const model = new WeeklyDraftEditorModel(empty.week, { read, write, prefill: vi.fn() })
await model.initialize()
model.setText('本周未保存输入')
expect(await model.switchWeek('2026-W40')).toBe(true)
expect(write.mock.calls[0][1].markdown).toBe('本周未保存输入')
expect(model.state.text).toBe('上周原稿')
expect(await model.switchWeek(empty.week)).toBe(true)
expect(model.state.text).toBe('本周未保存输入')
model.dispose()
})
it('retries the identical uncertain request before sending newer keystrokes and blocks week changes on failure', async () => {
const write = vi
.fn<(_: string, q: WeeklyWriteRequest) => Promise<WeeklyDocument>>()
.mockRejectedValueOnce(Error('网络断开'))
.mockImplementation(async (_week, q) => saved(q))
const model = new WeeklyDraftEditorModel(empty.week, { read: async () => empty, write, prefill: vi.fn() })
await model.initialize()
model.setText('original')
expect(await model.flush()).toBe(false)
const request = write.mock.calls[0][1]
expect(model.state.errorKind).toBe('save')
model.setText('newer input after failure')
expect(await model.flush()).toBe(true)
expect(write.mock.calls[1][1]).toEqual(request)
expect(write.mock.calls[2][1].base_revision).toBe(1)
expect(write.mock.calls[2][1].markdown).toBe('newer input after failure')
expect(write.mock.calls[2][1].nonce).not.toBe(request.nonce)
expect(model.state.document?.revision).toBe(2)
model.dispose()
})
it('debounces continuous typing and persists the latest exact text, not the initial keystroke', async () => {
vi.useFakeTimers()
const write = vi.fn(async (_week: string, q: WeeklyWriteRequest) => saved(q))
const model = new WeeklyDraftEditorModel(empty.week, { read: async () => empty, write, prefill: vi.fn() })
await model.initialize()
model.setText('first')
await vi.advanceTimersByTimeAsync(500)
model.setText('最后输入 / last edit')
await vi.advanceTimersByTimeAsync(799)
expect(write).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(1)
expect(write).toHaveBeenCalledTimes(1)
expect(write.mock.calls[0][1].markdown).toBe('最后输入 / last edit')
expect(model.state.document?.markdown).toBe('最后输入 / last edit')
expect(model.state.error).toBe('')
model.dispose()
})
})
+229
View File
@@ -0,0 +1,229 @@
import { newOperationID } from './operationKey'
export interface WeeklyDocument {
week: string
revision: number
markdown: string
seed: Record<string, unknown>
updated_at: string | null
}
export interface WeeklyWriteRequest {
base_revision: number
nonce: string
markdown: string
}
export interface WeeklyPrefillRequest {
base_revision: number
nonce: string
}
export interface WeeklyTransport {
read: (week: string) => Promise<WeeklyDocument>
write: (week: string, q: WeeklyWriteRequest) => Promise<WeeklyDocument>
prefill: (week: string, q: WeeklyPrefillRequest) => Promise<WeeklyDocument>
}
export interface WeeklyEditorState {
week: string
document: WeeklyDocument | null
text: string
loading: boolean
busy: boolean
saving: boolean
error: string
errorKind: '' | 'load' | 'save' | 'conflict' | 'prefill'
}
const errorText = (e: unknown) => (e instanceof Error ? e.message : '草稿操作失败,请保留文字并重试')
const conflict = (e: unknown) => !!e && typeof e === 'object' && 'status' in e && e.status === 409
export class WeeklyDraftEditorModel {
private snapshot: WeeklyEditorState
private listeners = new Set<() => void>()
private timer: ReturnType<typeof setTimeout> | null = null
private pending: WeeklyWriteRequest | null = null
private prefillPending: WeeklyPrefillRequest | null = null
get uncertainPrefill() {
return !!this.prefillPending
}
private flight: Promise<boolean> | null = null
private generation = 0
private disposed = false
constructor(
week: string,
private transport: WeeklyTransport
) {
this.snapshot = {
week,
document: null,
text: '',
loading: false,
busy: false,
saving: false,
error: '',
errorKind: '',
}
}
get state() {
return this.snapshot
}
get dirty() {
return !!this.state.document && (this.state.text !== this.state.document.markdown || !!this.pending)
}
getSnapshot = () => this.snapshot
subscribe = (listener: () => void) => {
this.listeners.add(listener)
return () => {
this.listeners.delete(listener)
}
}
private update(change: Partial<WeeklyEditorState>) {
this.snapshot = { ...this.snapshot, ...change }
for (const listener of this.listeners) listener()
}
private stopTimer() {
if (this.timer !== null) {
clearTimeout(this.timer)
this.timer = null
}
}
private schedule() {
this.stopTimer()
if (this.disposed || !this.dirty || this.state.error || this.state.loading || this.state.busy || this.state.saving)
return
this.timer = setTimeout(() => {
this.timer = null
void this.saveOne()
}, 800)
}
async initialize() {
const generation = ++this.generation
this.disposed = false
this.update({ loading: true, error: '', errorKind: '' })
try {
const document = await this.transport.read(this.state.week)
if (generation !== this.generation) return
this.pending = null
this.update({ document, text: document.markdown })
} catch (e) {
if (generation === this.generation) this.update({ error: errorText(e), errorKind: 'load' })
} finally {
if (generation === this.generation) this.update({ loading: false })
}
}
setText(text: string) {
if (this.state.busy || this.state.loading || this.prefillPending || !this.state.document) return
this.update({ text })
this.schedule()
}
private async saveOne(): Promise<boolean> {
if (this.flight) return this.flight
if (this.state.errorKind === 'conflict') return false
const document = this.state.document
if (!document || !this.dirty) return true
if (!this.pending) {
try {
this.pending = { base_revision: document.revision, nonce: newOperationID(), markdown: this.state.text }
} catch (e) {
this.update({ error: errorText(e), errorKind: 'save' })
return false
}
}
const request = this.pending,
week = this.state.week
this.flight = Promise.resolve().then(async () => {
this.update({ saving: true, error: '', errorKind: '' })
try {
const fresh = await this.transport.write(week, request)
if (fresh.week !== week || fresh.markdown !== request.markdown || fresh.revision !== request.base_revision + 1)
throw Error('保存版本或正文不匹配,请重新读取核对')
this.pending = null
this.update({ document: fresh })
return true
} catch (e) {
this.update({ error: errorText(e), errorKind: conflict(e) ? 'conflict' : 'save' })
return false
} finally {
this.update({ saving: false })
this.flight = null
this.schedule()
}
})
return this.flight
}
async prefill() {
if (this.state.busy || this.state.loading || !this.state.document) return false
this.update({ busy: true })
this.stopTimer()
try {
if (!this.prefillPending) {
if (!(await this.flush())) return false
this.prefillPending = { base_revision: this.state.document!.revision, nonce: newOperationID() }
}
const request = this.prefillPending,
week = this.state.week
this.update({ error: '', errorKind: '' })
const document = await this.transport.prefill(week, request)
if (document.week !== week || document.revision !== request.base_revision + 1)
throw Error('预填版本无效,尚未确认保存')
this.prefillPending = null
this.pending = null
this.update({ document, text: document.markdown })
return true
} catch (e) {
if (conflict(e)) this.prefillPending = null
this.update({ error: errorText(e), errorKind: conflict(e) ? 'conflict' : 'prefill' })
return false
} finally {
this.update({ busy: false })
}
}
async beforeLeave() {
if (this.state.busy) return false
this.update({ busy: true })
try {
return await this.flush()
} finally {
this.update({ busy: false })
}
}
async switchWeek(week: string) {
if (week === this.state.week) return true
if (this.state.busy) return false
this.update({ busy: true })
try {
if (!(await this.flush())) return false
this.stopTimer()
this.pending = null
this.prefillPending = null
this.update({ week, document: null, text: '' })
await this.initialize()
return !this.state.error
} finally {
this.update({ busy: false })
}
}
async reload() {
if (this.state.busy) return false
this.update({ busy: true })
try {
this.stopTimer()
if (this.flight) await this.flight
this.pending = null
this.prefillPending = null
await this.initialize()
return !this.state.error
} finally {
this.update({ busy: false })
}
}
async flush() {
this.stopTimer()
if (this.prefillPending || this.state.errorKind === 'conflict') return false
do {
if (!(await this.saveOne())) return false
} while (this.dirty)
return true
}
dispose() {
this.disposed = true
this.generation++
this.stopTimer()
this.listeners.clear()
}
}
+176
View File
@@ -0,0 +1,176 @@
.qa-weekly.qa-weekly-editor {
display: flex;
flex-direction: column;
gap: 12px;
min-height: 0;
overflow: hidden;
}
.qa-weekly-editor-header {
display: flex;
align-items: center;
gap: 18px;
flex-wrap: wrap;
}
.qa-weekly-editor-header h3 {
margin: 0;
font-size: 18px;
color: #17324d;
}
.qa-weekly-editor-actions {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.qa-weekly-reload {
min-width: 168px;
}
.qa-weekly-prefill {
min-width: 144px;
}
.qa-weekly-editor-toolbar {
display: flex;
align-items: center;
gap: 16px;
justify-content: space-between;
flex-wrap: wrap;
}
.qa-weekly-mode {
display: flex;
gap: 4px;
}
.qa-weekly-mode button[aria-pressed='true'] {
background: #eaf3ff;
color: #155cb5;
border-color: #a3c7f3;
}
.qa-weekly-editor-status {
font-size: 13px;
color: #64748b;
}
.qa-weekly-editor-status[data-weekly-save-state='saved'] {
color: #2d704d;
}
.qa-weekly-editor-status[data-weekly-save-state='error'] {
color: #b42318;
}
.qa-weekly-editor-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
flex: 1;
min-height: 320px;
overflow: hidden;
border: 1px solid #dce4ed;
border-radius: 10px;
background: #fff;
}
.qa-weekly-editor-layout[data-mode='edit'],
.qa-weekly-editor-layout[data-mode='preview'] {
grid-template-columns: minmax(0, 1fr);
}
.qa-weekly-writing {
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
}
.qa-weekly-writing > label {
padding: 10px 16px;
background: #f5f8fc;
border-bottom: 1px solid #e3e9f0;
font-size: 13px;
font-weight: 600;
color: #344b66;
}
.qa-weekly-writing > label small {
font-weight: 400;
color: #728298;
margin-left: 8px;
}
.qa-weekly-writing textarea {
flex: 1;
min-height: 260px;
width: 100%;
min-width: 0;
border: 0;
outline-offset: -3px;
border-radius: 0;
resize: none;
box-sizing: border-box;
padding: 16px;
font:
13px/1.8 ui-monospace,
SFMono-Regular,
Consolas,
monospace;
color: #263c52;
background: #fff;
tab-size: 2;
}
.qa-weekly-writing textarea:disabled {
background: #f6f8fb;
color: #6c7a8a;
}
.qa-weekly-editor-layout .qa-weekly-preview {
margin: 0;
border: 0;
border-left: 1px solid #e3e9f0;
border-radius: 0;
padding: 16px;
overflow: auto;
min-width: 0;
min-height: 0;
max-height: none;
}
.qa-weekly-editor-layout[data-mode='preview'] .qa-weekly-preview {
border-left: 0;
}
.qa-weekly-source {
font-size: 12px;
color: #66778c;
}
.qa-weekly-source p {
margin: 7px 0;
overflow-wrap: anywhere;
}
.qa-weekly-source summary {
cursor: pointer;
}
.qa-weekly-editor > .error-banner {
margin: 0;
}
.qa-weekly-editor > .error-banner button {
margin-left: 8px;
}
.qa-weekly-editor .qa-period-input {
min-width: 210px;
}
@media (max-width: 900px) {
.qa-weekly-editor-header {
gap: 10px;
}
.qa-weekly-editor-actions {
gap: 6px;
}
.qa-weekly-editor-layout[data-mode='both'] {
display: flex;
flex-direction: column;
overflow: auto;
}
.qa-weekly-editor-layout[data-mode='both'] .qa-weekly-writing {
min-height: 360px;
flex: none;
}
.qa-weekly-editor-layout[data-mode='both'] .qa-weekly-preview {
min-height: 260px;
overflow: visible;
border-left: 0;
border-top: 1px solid #e3e9f0;
}
.qa-weekly-editor-header .qa-period-input {
width: 100%;
}
.qa-weekly-editor-actions {
width: 100%;
}
}
+7
View File
@@ -9,6 +9,13 @@ export interface QAWeek {
const day = 86_400_000
const dateOnly = (value: number) => new Date(value).toISOString().slice(0, 10)
export function qaWeekValue(value: Pick<QAWeek, 'year' | 'week'>): string {
return `${value.year}-W${String(value.week).padStart(2, '0')}`
}
export function currentQAWeekValue(now = new Date()): string {
return qaWeekValue(currentQAWeek(now))
}
export function currentQAWeek(now = new Date()): QAWeek {
const beijing = new Date(now.getTime() + 8 * 3_600_000)
beijing.setUTCHours(0, 0, 0, 0)