From 3e7fcce0b1b077dd6c44f2a4d535813232b941fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A7=9A=E7=AB=A0=E6=B5=A9?= Date: Sun, 18 Oct 2026 19:10:47 +0800 Subject: [PATCH] =?UTF-8?q?feat(qa-ops):=20=E6=8C=81=E4=B9=85=E6=9C=AC?= =?UTF-8?q?=E5=9C=B0=E9=97=AE=E7=AD=94=E5=89=AF=E6=9C=AC=E4=B8=8E=E5=AE=89?= =?UTF-8?q?=E5=85=A8=E7=AD=94=E6=A1=88=E9=A2=84=E8=A7=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增迁移50与每5分钟/手动持久同步,API只读本地;同步失败保留、权限阻断、租约与质量失效原子激活。 保留全量稳定分页/固定列,已存质量批量读取,元数据不反复解压长答案;详情安全Markdown与原文。 已运行整仓 make verify、源与副本隔离PG -race、真实只读源及隔离浏览器。 实际同步2525条;固定窗口640条本地中位1.46秒,对照直接源33.10秒。 停worker、缺源凭据、重启API后仍可查询;未更新8088,未修改生产原问答。 --- CHANGELOG.md | 17 + api/openapi.yaml | 87 + cmd/api/main.go | 2 +- cmd/api/qa_ops.go | 19 +- cmd/api/qa_ops_test.go | 8 +- cmd/worker/main.go | 7 + cmd/worker/qa_mirror.go | 52 + cmd/worker/qa_mirror_test.go | 23 + internal/dobotaiops/mirror.go | 101 ++ .../dobotaiops/mirror_integration_test.go | 53 + internal/dobotaiops/organization_records.go | 8 +- internal/dobotaiops/quality_records.go | 12 +- internal/dobotaiops/record_repository.go | 59 + internal/dobotaiops/records.go | 41 +- internal/httpapi/qa_mirror_quality_test.go | 45 + internal/httpapi/qa_mirror_sync.go | 79 + internal/httpapi/qa_mirror_sync_test.go | 86 + internal/httpapi/qa_ops.go | 13 +- internal/httpapi/qa_quality.go | 26 +- internal/httpapi/server.go | 5 +- .../000050_qa_ops_local_mirror.down.sql | 2 + .../000050_qa_ops_local_mirror.up.sql | 14 + internal/platform/postgres/migrations_test.go | 4 +- .../qaopsmirror/failure_integration_test.go | 118 ++ internal/qaopsmirror/metadata.go | 10 + .../scheduler_recovery_integration_test.go | 39 + internal/qaopsmirror/store.go | 163 ++ .../qaopsmirror/store_integration_test.go | 76 + internal/qaopsmirror/stored_quality.go | 13 + .../stored_quality_integration_test.go | 83 + internal/qaopsmirror/sync.go | 120 ++ internal/qaopsmirror/sync_integration_test.go | 71 + internal/qaopsmirror/sync_run.go | 283 ++++ .../qaopsmirror/sync_run_integration_test.go | 143 ++ internal/qaopsquality/read_views.go | 62 + internal/qaopsquality/store.go | 28 + scripts/test-scope.json | 44 +- web/package-lock.json | 1499 ++++++++++++++++- web/package.json | 4 +- web/src/QAAnswerMarkdown.test.tsx | 30 + web/src/QAAnswerMarkdown.tsx | 67 + web/src/QAMirrorSync.test.tsx | 55 + web/src/QAMirrorSync.tsx | 124 ++ web/src/QAOperations.tsx | 28 +- web/src/qaAnswer.css | 92 + web/src/qaMirror.css | 29 + web/src/qaMirror.test.ts | 63 + web/src/qaMirror.ts | 86 + web/src/qaOps.ts | 4 + 49 files changed, 4038 insertions(+), 59 deletions(-) create mode 100644 cmd/worker/qa_mirror.go create mode 100644 cmd/worker/qa_mirror_test.go create mode 100644 internal/dobotaiops/mirror.go create mode 100644 internal/dobotaiops/mirror_integration_test.go create mode 100644 internal/dobotaiops/record_repository.go create mode 100644 internal/httpapi/qa_mirror_quality_test.go create mode 100644 internal/httpapi/qa_mirror_sync.go create mode 100644 internal/httpapi/qa_mirror_sync_test.go create mode 100644 internal/platform/postgres/migrations/000050_qa_ops_local_mirror.down.sql create mode 100644 internal/platform/postgres/migrations/000050_qa_ops_local_mirror.up.sql create mode 100644 internal/qaopsmirror/failure_integration_test.go create mode 100644 internal/qaopsmirror/metadata.go create mode 100644 internal/qaopsmirror/scheduler_recovery_integration_test.go create mode 100644 internal/qaopsmirror/store.go create mode 100644 internal/qaopsmirror/store_integration_test.go create mode 100644 internal/qaopsmirror/stored_quality.go create mode 100644 internal/qaopsmirror/stored_quality_integration_test.go create mode 100644 internal/qaopsmirror/sync.go create mode 100644 internal/qaopsmirror/sync_integration_test.go create mode 100644 internal/qaopsmirror/sync_run.go create mode 100644 internal/qaopsmirror/sync_run_integration_test.go create mode 100644 internal/qaopsquality/read_views.go create mode 100644 web/src/QAAnswerMarkdown.test.tsx create mode 100644 web/src/QAAnswerMarkdown.tsx create mode 100644 web/src/QAMirrorSync.test.tsx create mode 100644 web/src/QAMirrorSync.tsx create mode 100644 web/src/qaAnswer.css create mode 100644 web/src/qaMirror.css create mode 100644 web/src/qaMirror.test.ts create mode 100644 web/src/qaMirror.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b2c21a..2fff4e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -76,6 +76,23 @@ > `0.1.12`),由 `scripts/version.py` 从 git **算出来**;开发交付的独立说明写入本文件“开发快照”章节,并打 `snapshot/vX.Y.Z` 注释标签 ⇒ 每次开发交付均可追溯, > 但"发布版本"仍然只能由人定(CI 不会自动升版本、不会打 tag)。 +## 开发快照 snapshot/v0.1.28 - 2026-10-18 + +### Added + +- 问答运营新增持久本地副本,每 5 分钟后台同步并支持立即同步;列表、详情、检索、质量范围和使用统计改为读取本地数据库。 +- AI 答案详情提供安全 Markdown 预览与原文查看,支持表格、代码块和链接;不执行原始 HTML,外部图片默认不请求。 + +### Changed + +- 增加治理侧问答副本表与同步状态;同步由普通 worker 执行,使用独立只读源连接,源失败保留最后成功副本,权限校验失败阻断读取。 +- 副本激活与质量输入变化的复核失效同事务提交;同步持久去重、租约校验及重启恢复不改变源问答或现有生产写入。 + +### Fixed + +- 本地读取不再逐条重复写质量留档或解压完整范围长答案来统计字段,保留完整列、输入版本一致性和真实缺失值。 +- 初次观察即已同步完成时自动重查列表,避免较早的首次未同步错误一直停留。 + ## 开发快照 snapshot/v0.1.27 - 2026-10-18 ### Fixed diff --git a/api/openapi.yaml b/api/openapi.yaml index 7877a12..e080bf8 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -105,6 +105,93 @@ "/api/v1/qa-ops/records/{id}/reviews": {"post": {"operationId": "saveQAQualityReview", "summary": "治理库追加人工复核,登录身份审计,源端只读,输入/复核乐观锁和nonce幂等;正确时根因为空,不计入须说明原因", "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string"}}, {"name": "X-Governance-Action", "in": "header", "required": true, "schema": {"type": "string", "enum": ["qa-quality"]}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"type": "object", "additionalProperties": false, "properties": {"input_hash": {"type": "string", "pattern": "^[a-f0-9]{64}$"}, "input_epoch": {"type": "integer", "minimum": 1}, "base_revision": {"type": "integer", "minimum": 0}, "nonce": {"type": "string", "pattern": "^[A-Za-z0-9_-]{16,128}$"}, "fields": {"type": "object", "additionalProperties": false, "properties": {"coverage": {"type": "string", "enum": ["covered", "uncovered", "partial", "pending", "excluded"]}, "correctness": {"type": "string", "enum": ["correct", "incorrect", "pending"]}, "product_category": {"type": "string", "maxLength": 128}, "problem_type": {"type": "string", "maxLength": 128}, "root_cause": {"type": "string", "maxLength": 2000}, "exclusion_reason": {"type": "string", "maxLength": 2000}}, "required": ["coverage", "correctness"]}, "reason": {"type": "string", "minLength": 1, "maxLength": 4000}}, "required": ["input_hash", "input_epoch", "base_revision", "nonce", "fields", "reason"]}}}}, "tags": ["QA operations"], "responses": {"200": {"$ref": "#/components/responses/Envelope"}, "400": {"$ref": "#/components/responses/BadRequest"}, "401": {"description": "需要登录"}, "403": {"description": "仅管理员访问"}, "503": {"description": "独立只读源或治理组织存储不可用,不返回伪造的零值"}, "500": {"$ref": "#/components/responses/InternalError"}, "409": {"description": "输入或复核版本变化/重复操作载荷不一致,未覆盖原结论"}, "404": {"description": "当前授权范围内未找到记录/历史输入"}}}}, "/api/v1/qa-ops/records/{id}/quality/history": {"get": {"operationId": "listQAQualityHistory", "summary": "按当前固定租户查询不可变复检/人工历史,分别倒序分页,各20条", "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string"}}, {"name": "review_before", "in": "query", "schema": {"type": "integer", "minimum": 1}}, {"name": "check_before", "in": "query", "schema": {"type": "integer", "minimum": 1}}], "tags": ["QA operations"], "responses": {"200": {"$ref": "#/components/responses/Envelope"}, "400": {"$ref": "#/components/responses/BadRequest"}, "401": {"description": "需要登录"}, "403": {"description": "仅管理员访问"}, "503": {"description": "独立只读源或治理组织存储不可用,不返回伪造的零值"}, "500": {"$ref": "#/components/responses/InternalError"}, "409": {"description": "输入或复核版本变化/重复操作载荷不一致,未覆盖原结论"}, "404": {"description": "当前授权范围内未找到记录/历史输入"}}}}, "/api/v1/qa-ops/records/{id}/quality/inputs/{epoch}": {"get": {"operationId": "getQAQualityInputSnapshot", "summary": "管理员读取已采集的历史质量输入快照,不另存人员关联/反馈字段;元数据留档但未采集原文返回409,不编造空快照", "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string"}}, {"name": "epoch", "in": "path", "required": true, "schema": {"type": "integer", "minimum": 1}}], "tags": ["QA operations"], "responses": {"200": {"$ref": "#/components/responses/Envelope"}, "400": {"$ref": "#/components/responses/BadRequest"}, "401": {"description": "需要登录"}, "403": {"description": "仅管理员访问"}, "503": {"description": "独立只读源或治理组织存储不可用,不返回伪造的零值"}, "500": {"$ref": "#/components/responses/InternalError"}, "409": {"description": "该输入版本仅有指纹/规则元数据、原文未采集"}, "404": {"description": "当前授权范围内未找到记录/历史输入"}}}}, + "/api/v1/qa-ops/sync": { + "get": { + "operationId": "getQALocalMirrorSync", + "summary": "读取本地问答副本与后台同步状态(只读本地,不探测生产)", + "responses": { + "200": { + "description": "本地副本状态、最近成功时间、5分钟计划与安全作业进度;无问答原文/人员标识" + }, + "400": { + "description": "不接受查询参数" + }, + "401": { + "description": "未登录" + }, + "403": { + "description": "仅管理员" + }, + "500": { + "description": "治理库不可用" + }, + "503": { + "description": "本地同步未接线" + } + } + }, + "post": { + "operationId": "enqueueQALocalMirrorSync", + "summary": "同源管理员请求后台问答同步,持久去重,不同步阻塞查询", + "parameters": [ + { + "in": "header", + "name": "X-Governance-Action", + "required": true, + "schema": { + "type": "string", + "enum": [ + "qa-records-sync" + ] + } + }, + { + "in": "header", + "name": "Idempotency-Key", + "required": true, + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128 + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false + } + } + } + }, + "responses": { + "202": { + "description": "已入队/重放/合并;须轮询状态,不代表同步成功" + }, + "400": { + "description": "非法参数、null、未知字段或缺nonce" + }, + "401": { + "description": "未登录" + }, + "403": { + "description": "非管理员/跨站/缺操作意图" + }, + "409": { + "description": "相同nonce的操作人/载荷冲突" + }, + "500": { + "description": "治理库不可用" + }, + "503": { + "description": "本地模式或执行器未接线;未创建任务" + } + } + } + }, "/api/v1/qa-ops/status": { "get": { "tags": ["QA operations"], diff --git a/cmd/api/main.go b/cmd/api/main.go index a25a2f7..d86b83b 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -137,7 +137,7 @@ func run(ctx context.Context, getenv func(string) string) error { } } } - qaReader, qaSettings := openQAOperations(ctx, getenv, config.DatabaseURL) + qaReader, qaSettings := openQAOperations(ctx, getenv, config.DatabaseURL, pool) if closer, ok := qaReader.(interface{ Close() }); ok { defer closer.Close() } diff --git a/cmd/api/qa_ops.go b/cmd/api/qa_ops.go index 58becfc..066310d 100644 --- a/cmd/api/qa_ops.go +++ b/cmd/api/qa_ops.go @@ -2,20 +2,25 @@ package main import ( "context" + "github.com/jackc/pgx/v5/pgxpool" "knowledge-governance/internal/dobotaiops" "knowledge-governance/internal/httpapi" + "knowledge-governance/internal/qaopsmirror" + "strings" ) -// A failed optional source must not replace or take down the main database. -func openQAOperations(ctx context.Context, getenv func(string) string, governanceDSN string) (httpapi.QAOpsReader, dobotaiops.Settings) { +// API serving never constructs or probes a production-source client. The worker +// alone synchronizes the explicitly authorized local copy; this is not a fallback. +func openQAOperations(_ context.Context, getenv func(string) string, governanceDSN string, pool *pgxpool.Pool) (httpapi.QAOpsReader, dobotaiops.Settings) { settings := dobotaiops.LoadSettings(getenv, governanceDSN) - if !settings.Enabled || settings.Problem != "" { + if !settings.Enabled { return nil, settings } - client, err := dobotaiops.Build(ctx, settings.Connection) - if err != nil { - settings.Problem = "source_connection_or_permissions_failed" + if settings.TenantKey == "" || len(settings.TenantKey) > 128 || strings.ContainsRune(settings.TenantKey, 0) { + settings.Problem = "local_mirror_tenant_invalid" return nil, settings } - return client, settings + // Missing/offline source credentials stop synchronization, not last-good local reads. + settings.Problem = "" + return qaopsmirror.NewStore(pool, settings.TenantKey), settings } diff --git a/cmd/api/qa_ops_test.go b/cmd/api/qa_ops_test.go index c30d30a..4a63362 100644 --- a/cmd/api/qa_ops_test.go +++ b/cmd/api/qa_ops_test.go @@ -6,13 +6,13 @@ import ( ) func TestQAOpsConnectionFailureIsContainedOutsideMainDatabase(t *testing.T) { - reader, settings := openQAOperations(context.Background(), func(string) string { return "" }, "postgres://main@host/governance") + reader, settings := openQAOperations(context.Background(), func(string) string { return "" }, "postgres://main@host/governance", nil) if reader != nil || settings.Enabled || settings.Problem != "" { t.Fatal("QA operations must be disabled by default") } env := map[string]string{"DOBOTAI_OPS_READ_ENABLED": "true", "DOBOTAI_OPS_TENANT_KEY": "approved-tenant", "DOBOTAI_OPS_READ_DSN_FILE": "/nonexistent-qaops-fixture-secret"} - reader, settings = openQAOperations(context.Background(), func(k string) string { return env[k] }, "postgres://main@host/governance") - if reader != nil || !settings.Enabled || settings.Problem == "" { - t.Fatal("failed external connection must be a contained module error") + reader, settings = openQAOperations(context.Background(), func(k string) string { return env[k] }, "postgres://main@host/governance", nil) + if reader == nil || !settings.Enabled || settings.Problem != "" { + t.Fatal("API serving still constructs a source connection instead of a local reader") } } diff --git a/cmd/worker/main.go b/cmd/worker/main.go index 1de7bc7..c1ba9ca 100644 --- a/cmd/worker/main.go +++ b/cmd/worker/main.go @@ -56,6 +56,10 @@ func run(ctx context.Context, getenv func(string) string) error { if syncErr := configureDirectorySync(pool, getenv, handlers); syncErr != nil { log.Print("QA directory synchronization unavailable; handler was not registered") } + mirrorStore, mirrorErr := configureRecordsSync(pool, getenv, config.DatabaseURL, handlers) + if mirrorErr != nil { + log.Print("QA local mirror configuration invalid; handler was not registered") + } worker, err := jobs.NewWorker(jobs.WorkerConfig{ Queue: store, WorkerID: config.WorkerID, @@ -67,6 +71,9 @@ func run(ctx context.Context, getenv func(string) string) error { if err != nil { return err } + if mirrorStore != nil { + go runRecordsSyncScheduler(ctx, mirrorStore, func(error) { log.Print("QA local mirror scheduler unavailable; last committed data retained") }) + } go runSourceRefreshScheduler(ctx, store, config.SourceRefreshSchedulerInterval, func(err error) { log.Printf("worker %s source refresh scheduler error: %v", config.WorkerID, err) }) diff --git a/cmd/worker/qa_mirror.go b/cmd/worker/qa_mirror.go new file mode 100644 index 0000000..58e30a4 --- /dev/null +++ b/cmd/worker/qa_mirror.go @@ -0,0 +1,52 @@ +package main + +import ( + "context" + "github.com/jackc/pgx/v5/pgxpool" + "knowledge-governance/internal/dobotaiops" + "knowledge-governance/internal/jobs" + "knowledge-governance/internal/qaopsmirror" + "strings" + "time" +) + +func configureRecordsSync(pool *pgxpool.Pool, getenv func(string) string, governanceDSN string, handlers map[string]jobs.Handler) (*qaopsmirror.Store, error) { + settings := dobotaiops.LoadSettings(getenv, governanceDSN) + if !settings.Enabled { + return nil, nil + } + if settings.TenantKey == "" || len(settings.TenantKey) > 128 || strings.ContainsRune(settings.TenantKey, 0) { + return nil, qaopsmirror.ErrInvalid + } + store := qaopsmirror.NewStore(pool, settings.TenantKey) + handlers[store.SyncJobType()] = func(ctx context.Context, job jobs.Job) error { + return store.HandleSync(ctx, job, func(ctx context.Context) (qaopsmirror.Source, error) { + current := dobotaiops.LoadSettings(getenv, governanceDSN) + if !current.Enabled || current.Problem != "" || current.TenantKey != settings.TenantKey { + return nil, dobotaiops.ErrUnavailable + } + // One source connection, shared by every manifest and bounded payload read in this job. + current.Connection.MaxConns = 1 + current.Connection.MinConns = 0 + return dobotaiops.Build(ctx, current.Connection) + }) + } + return store, nil +} + +// The next due time and automatic operation key live in PostgreSQL, so a worker +// restart does not reset the five-minute cadence or duplicate an active import. +func runRecordsSyncScheduler(ctx context.Context, store *qaopsmirror.Store, onError func(error)) { + tick := time.NewTicker(15 * time.Second) + defer tick.Stop() + for { + if _, e := store.EnqueueDue(ctx); e != nil && ctx.Err() == nil && onError != nil { + onError(e) + } + select { + case <-ctx.Done(): + return + case <-tick.C: + } + } +} diff --git a/cmd/worker/qa_mirror_test.go b/cmd/worker/qa_mirror_test.go new file mode 100644 index 0000000..4b906b7 --- /dev/null +++ b/cmd/worker/qa_mirror_test.go @@ -0,0 +1,23 @@ +package main + +import ( + "knowledge-governance/internal/jobs" + "knowledge-governance/internal/qaopsmirror" + "testing" +) + +func TestLocalMirrorWorkerRegistrationNeverConnectsSource(t *testing.T) { + values := map[string]string{} + get := func(k string) string { return values[k] } + handlers := map[string]jobs.Handler{} + if store, e := configureRecordsSync(nil, get, "postgres://fixture@localhost/local", handlers); e != nil || store != nil || len(handlers) != 0 { + t.Fatal("disabled mirror registered") + } + values["DOBOTAI_OPS_READ_ENABLED"] = "true" + values["DOBOTAI_OPS_TENANT_KEY"] = "fixture-worker-tenant" + values["DOBOTAI_OPS_READ_DSN_FILE"] = "/nonexistent-fixture-source-secret" + store, e := configureRecordsSync(nil, get, "postgres://fixture@localhost/local", handlers) + if e != nil || store == nil || handlers[qaopsmirror.NewStore(nil, "fixture-worker-tenant").SyncJobType()] == nil { + t.Fatal("local mirror handler missing; registration must not connect to source", e) + } +} diff --git a/internal/dobotaiops/mirror.go b/internal/dobotaiops/mirror.go new file mode 100644 index 0000000..42546fa --- /dev/null +++ b/internal/dobotaiops/mirror.go @@ -0,0 +1,101 @@ +package dobotaiops + +import ( + "context" + "encoding/json" + "strings" + "time" +) + +const MaxMirrorRecords = 100000 +const mirrorFingerprintSQL = `encode(sha256(convert_to((payload-'_quality_observed_at')::text,'UTF8')),'hex')` + +type MirrorKey struct { + ID string + Fingerprint string +} +type MirrorManifest struct { + Keys []MirrorKey + AsOf time.Time + Safety SourceSafety +} +type MirrorRecord struct { + Key MirrorKey + Payload json.RawMessage +} + +func validMirrorTenant(tenant string) bool { + return strings.TrimSpace(tenant) == tenant && len(tenant) > 0 && len(tenant) <= 128 && !strings.ContainsRune(tenant, 0) +} + +// Only a manifest (IDs and stable hashes), not all long answers, crosses the +// network on unchanged polls. Each read retains the source ACL and read-only gates. +func (c *Client) ReadMirrorManifest(ctx context.Context, tenant string) (MirrorManifest, error) { + out := MirrorManifest{Keys: []MirrorKey{}} + if !validMirrorTenant(tenant) { + return out, ErrInvalidQuery + } + err := c.withReadOnlyTx(ctx, func(ctx context.Context, q querier) error { + if q.QueryRow(ctx, "SELECT transaction_timestamp()").Scan(&out.AsOf) != nil { + return ErrUnavailable + } + rows, e := q.Query(ctx, recordsBaseSQL+` SELECT id,`+mirrorFingerprintSQL+` FROM records ORDER BY id LIMIT 100001`, tenant) + if e != nil { + return ErrUnavailable + } + defer rows.Close() + for rows.Next() { + var k MirrorKey + if rows.Scan(&k.ID, &k.Fingerprint) != nil { + return ErrUnavailable + } + if len(out.Keys) >= MaxMirrorRecords { + return ErrInvalidQuery + } + out.Keys = append(out.Keys, k) + } + return rows.Err() + }) + out.Safety = c.Safety() + return out, err +} +func (c *Client) ReadMirrorRecords(ctx context.Context, tenant string, ids []string) ([]MirrorRecord, error) { + if !validMirrorTenant(tenant) || len(ids) == 0 || len(ids) > 100 { + return nil, ErrInvalidQuery + } + seen := map[string]bool{} + for _, id := range ids { + if id == "" || len(id) > 256 || strings.ContainsRune(id, 0) || seen[id] { + return nil, ErrInvalidQuery + } + seen[id] = true + } + out := make([]MirrorRecord, 0, len(ids)) + size := 0 + err := c.withReadOnlyTx(ctx, func(ctx context.Context, q querier) error { + rows, e := q.Query(ctx, recordsBaseSQL+` SELECT id,`+mirrorFingerprintSQL+`,payload FROM records WHERE id=ANY($2::text[]) ORDER BY id`, tenant, ids) + if e != nil { + return ErrUnavailable + } + defer rows.Close() + for rows.Next() { + var r MirrorRecord + if rows.Scan(&r.Key.ID, &r.Key.Fingerprint, &r.Payload) != nil { + return ErrUnavailable + } + size += len(r.Payload) + if len(r.Payload) > 8<<20 || size > 32<<20 { + return ErrInvalidQuery + } + out = append(out, r) + } + if rows.Err() != nil { + return ErrUnavailable + } + if len(out) != len(ids) { + return ErrUnavailable + } + return nil + }) + return out, err +} diff --git a/internal/dobotaiops/mirror_integration_test.go b/internal/dobotaiops/mirror_integration_test.go new file mode 100644 index 0000000..a1119df --- /dev/null +++ b/internal/dobotaiops/mirror_integration_test.go @@ -0,0 +1,53 @@ +package dobotaiops + +import ( + "context" + "testing" +) + +func TestMirrorSourceManifestDetectsUpdatesWithoutVolatileTimes(t *testing.T) { + p := fixturePool(t) + c := buildReadOnlyClient(t, qaRecordsFixture(t, p)) + ctx := context.Background() + a, e := c.ReadMirrorManifest(ctx, "default") + if e != nil || len(a.Keys) != 4 || a.AsOf.IsZero() { + t.Fatal("full fixed-channel manifest is missing", e, len(a.Keys)) + } + b, e := c.ReadMirrorManifest(ctx, "default") + if e != nil || len(b.Keys) != len(a.Keys) { + t.Fatal(e) + } + old := map[string]string{} + for i, k := range a.Keys { + old[k.ID] = k.Fingerprint + if k != b.Keys[i] { + t.Fatal("source clock alone changed the content fingerprint") + } + } + rows, e := c.ReadMirrorRecords(ctx, "default", []string{"turn-3"}) + if e != nil || len(rows) != 1 || rows[0].Key.Fingerprint != old["turn-3"] { + t.Fatal("bounded safe payload fetch failed", e) + } + before, e := DecodeMirrorPayload(string(rows[0].Payload)) + if e != nil || before.Feedback.Up != 1 || before.Identity.StaffID == "" { + t.Fatal("safe payload lost required fields", e) + } + execFixture(t, p, `UPDATE answer_feedback SET value='down' WHERE id='fb1'`) + after, e := c.ReadMirrorRecords(ctx, "default", []string{"turn-3"}) + if e != nil || after[0].Key.Fingerprint == old["turn-3"] { + t.Fatal("feedback update on old question was missed", e) + } + rec, _ := DecodeMirrorPayload(string(after[0].Payload)) + if rec.QualityInputHash != before.QualityInputHash { + t.Fatal("feedback invalidated unrelated human quality input") + } + execFixture(t, p, `UPDATE turns SET answer='changed answer' WHERE id='turn-3'`) + after, e = c.ReadMirrorRecords(ctx, "default", []string{"turn-3"}) + rec, _ = DecodeMirrorPayload(string(after[0].Payload)) + if e != nil || rec.QualityInputHash == before.QualityInputHash { + t.Fatal("answer change was missed") + } + if _, e = c.ReadMirrorRecords(ctx, "default", make([]string, 101)); e == nil { + t.Fatal("unbounded payload fetch allowed") + } +} diff --git a/internal/dobotaiops/organization_records.go b/internal/dobotaiops/organization_records.go index 8e42836..8efdf74 100644 --- a/internal/dobotaiops/organization_records.go +++ b/internal/dobotaiops/organization_records.go @@ -57,7 +57,7 @@ func decodeRecord(raw string) (Record, error) { wire.Record.Coverage = CheckAnswerCoverage(wire.Record.Answer) return wire.Record, nil } -func (c *Client) ReadUsageGroups(ctx context.Context, query RecordQuery) (UsageGroups, error) { +func (c *RecordsRepository) ReadUsageGroups(ctx context.Context, query RecordQuery) (UsageGroups, error) { if err := c.validateQueryWindow(query.Window); err != nil { return UsageGroups{}, err } @@ -69,11 +69,11 @@ func (c *Client) ReadUsageGroups(ctx context.Context, query RecordQuery) (UsageG return UsageGroups{}, patternErr } result := UsageGroups{Groups: []UsageGroup{}} - err := c.withReadOnlyTx(ctx, func(ctx context.Context, q querier) error { - if q.QueryRow(ctx, "SELECT transaction_timestamp()").Scan(&result.AsOf) != nil { + err := c.transact(ctx, func(ctx context.Context, q querier) error { + if c.readAsOf(ctx, q, &result.AsOf) != nil { return ErrUnavailable } - rows, err := q.Query(ctx, recordsBaseSQL+` SELECT identity::text,count(*),count(*) FILTER(WHERE status='succeeded'),count(*) FILTER(WHERE status='failed') FROM records WHERE `+recordFilterSQL+` GROUP BY identity`, query.Window.TenantKey, query.Window.Start, query.Window.End, pattern) + rows, err := q.Query(ctx, c.baseSQL+` SELECT identity::text,count(*),count(*) FILTER(WHERE status='succeeded'),count(*) FILTER(WHERE status='failed') FROM records WHERE `+recordFilterSQL+` GROUP BY identity`, query.Window.TenantKey, query.Window.Start, query.Window.End, pattern) if err != nil { return ErrUnavailable } diff --git a/internal/dobotaiops/quality_records.go b/internal/dobotaiops/quality_records.go index 220b3cc..a2b53db 100644 --- a/internal/dobotaiops/quality_records.go +++ b/internal/dobotaiops/quality_records.go @@ -11,7 +11,7 @@ type QualityScope struct { AsOf time.Time } -func (c *Client) ReadQualityScope(ctx context.Context, query RecordQuery) (QualityScope, error) { +func (c *RecordsRepository) ReadQualityScope(ctx context.Context, query RecordQuery) (QualityScope, error) { if err := c.validateQueryWindow(query.Window); err != nil { return QualityScope{}, err } @@ -23,11 +23,15 @@ func (c *Client) ReadQualityScope(ctx context.Context, query RecordQuery) (Quali return QualityScope{}, err } result := QualityScope{Keys: []QualityKey{}} - err = c.withReadOnlyTx(ctx, func(ctx context.Context, q querier) error { - if q.QueryRow(ctx, "SELECT transaction_timestamp()").Scan(&result.AsOf) != nil { + err = c.transact(ctx, func(ctx context.Context, q querier) error { + if c.readAsOf(ctx, q, &result.AsOf) != nil { return ErrUnavailable } - rows, err := q.Query(ctx, recordsBaseSQL+` SELECT id,quality_hash,strpos(payload->>'answer',$5)>0,(payload->'source_coverage')::text FROM records WHERE `+recordFilterSQL+` ORDER BY id LIMIT 5001`, query.Window.TenantKey, query.Window.Start, query.Window.End, pattern, noPublishedMarker) + scopeSQL := c.baseSQL + ` SELECT id,quality_hash,strpos(payload->>'answer',$5)>0,(payload->'source_coverage')::text FROM records WHERE ` + if c.qualityScopeSQL != "" { + scopeSQL = c.qualityScopeSQL + } + rows, err := q.Query(ctx, scopeSQL+recordFilterSQL+` ORDER BY id LIMIT 5001`, query.Window.TenantKey, query.Window.Start, query.Window.End, pattern, noPublishedMarker) if err != nil { return ErrUnavailable } diff --git a/internal/dobotaiops/record_repository.go b/internal/dobotaiops/record_repository.go new file mode 100644 index 0000000..cc3c391 --- /dev/null +++ b/internal/dobotaiops/record_repository.go @@ -0,0 +1,59 @@ +package dobotaiops + +import ( + "context" + "time" +) + +// RecordQuerier and RecordTransaction keep the projection identical while an +// explicitly selected reader supplies either source-ACL or local-snapshot transactions. +type RecordRow = rowScanner +type RecordQuerier = querier +type RecordTransaction func(context.Context, func(context.Context, RecordQuerier) error) error +type RecordsRepositoryOptions struct { + BaseSQL string + SummarySQL string + QualityScopeSQL string + Transaction RecordTransaction + ValidateWindow func(Window) error + ReadAsOf func(context.Context, RecordQuerier, *time.Time) error + TenantKey string +} +type RecordsRepository struct { + baseSQL string + summarySQL string + qualityScopeSQL string + transact RecordTransaction + validateQueryWindow func(Window) error + readAsOf func(context.Context, RecordQuerier, *time.Time) error + tenant string +} + +func NewRecordsRepository(o RecordsRepositoryOptions) *RecordsRepository { + asOf := o.ReadAsOf + if asOf == nil { + asOf = func(ctx context.Context, q RecordQuerier, t *time.Time) error { + return q.QueryRow(ctx, "SELECT transaction_timestamp()").Scan(t) + } + } + return &RecordsRepository{summarySQL: o.SummarySQL, qualityScopeSQL: o.QualityScopeSQL, baseSQL: o.BaseSQL, transact: o.Transaction, validateQueryWindow: o.ValidateWindow, readAsOf: asOf, tenant: o.TenantKey} +} +func (c *Client) recordRepository() *RecordsRepository { + return NewRecordsRepository(RecordsRepositoryOptions{BaseSQL: recordsBaseSQL, Transaction: c.withReadOnlyTx, ValidateWindow: c.validateQueryWindow}) +} +func (c *Client) ListRecords(ctx context.Context, q RecordQuery) (RecordPage, error) { + return c.recordRepository().ListRecords(ctx, q) +} +func (c *Client) GetRecord(ctx context.Context, tenant, id string) (Record, error) { + return c.recordRepository().GetRecord(ctx, tenant, id) +} +func (c *Client) ReadUsageGroups(ctx context.Context, q RecordQuery) (UsageGroups, error) { + return c.recordRepository().ReadUsageGroups(ctx, q) +} +func (c *Client) ReadQualityScope(ctx context.Context, q RecordQuery) (QualityScope, error) { + return c.recordRepository().ReadQualityScope(ctx, q) +} + +// DecodeMirrorPayload accepts only the source adapter's fixed safe projection; +// credentials and arbitrary source tables are never part of this protocol. +func DecodeMirrorPayload(raw string) (Record, error) { return decodeRecord(raw) } diff --git a/internal/dobotaiops/records.go b/internal/dobotaiops/records.go index dc035b7..ef7e1c1 100644 --- a/internal/dobotaiops/records.go +++ b/internal/dobotaiops/records.go @@ -91,15 +91,16 @@ type QualityKey struct { InputHash string `json:"input_hash"` } type RecordPage struct { - ScopeQuality []QualityKey `json:"-"` - ScopeActors []ActorIdentity `json:"-"` - Organization *OrganizationVersion `json:"organization,omitempty"` - Items []Record `json:"items"` - Columns []string `json:"columns"` - Total int64 `json:"total"` - HasMore bool `json:"has_more"` - NextCursor string `json:"next_cursor"` - AsOf time.Time `json:"as_of"` + LocalRevision int64 `json:"local_revision,omitempty"` + ScopeQuality []QualityKey `json:"-"` + ScopeActors []ActorIdentity `json:"-"` + Organization *OrganizationVersion `json:"organization,omitempty"` + Items []Record `json:"items"` + Columns []string `json:"columns"` + Total int64 `json:"total"` + HasMore bool `json:"has_more"` + NextCursor string `json:"next_cursor"` + AsOf time.Time `json:"as_of"` } type recordCursor struct { @@ -172,7 +173,7 @@ func recordKeywordPattern(keyword string) (string, error) { return "%" + strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(keyword) + "%", nil } -func (c *Client) ListRecords(ctx context.Context, query RecordQuery) (RecordPage, error) { +func (c *RecordsRepository) ListRecords(ctx context.Context, query RecordQuery) (RecordPage, error) { if err := c.validateQueryWindow(query.Window); err != nil { return RecordPage{}, err } @@ -197,12 +198,16 @@ func (c *Client) ListRecords(ctx context.Context, query RecordQuery) (RecordPage } args := []any{query.Window.TenantKey, query.Window.Start, query.Window.End, pattern} page := RecordPage{Items: make([]Record, 0), Columns: make([]string, 0)} - err := c.withReadOnlyTx(ctx, func(ctx context.Context, q querier) error { - if err := q.QueryRow(ctx, "SELECT transaction_timestamp()").Scan(&page.AsOf); err != nil { + err := c.transact(ctx, func(ctx context.Context, q querier) error { + if err := c.readAsOf(ctx, q, &page.AsOf); err != nil { return ErrUnavailable } var rawColumns, rawActors, rawQuality string - if err := q.QueryRow(ctx, recordsBaseSQL+recordColumnsSQL+recordFilterSQL, append(args, noPublishedMarker)...).Scan(&page.Total, &rawColumns, &rawActors, &rawQuality); err != nil { + summarySQL := c.baseSQL + recordColumnsSQL + if c.summarySQL != "" { + summarySQL = c.summarySQL + } + if err := q.QueryRow(ctx, summarySQL+recordFilterSQL, append(args, noPublishedMarker)...).Scan(&page.Total, &rawColumns, &rawActors, &rawQuality); err != nil { return fmt.Errorf("%w: record count failed", ErrUnavailable) } if json.Unmarshal([]byte(rawQuality), &page.ScopeQuality) != nil { @@ -223,7 +228,7 @@ func (c *Client) ListRecords(ctx context.Context, query RecordQuery) (RecordPage page.Columns = append(page.Columns, column) } } - rows, err := q.Query(ctx, recordsBaseSQL+` SELECT payload::text FROM records WHERE `+recordFilterSQL+` + rows, err := q.Query(ctx, c.baseSQL+` SELECT payload::text FROM records WHERE `+recordFilterSQL+` AND ($5::timestamptz IS NULL OR (asked_at,id)<($5,$6)) ORDER BY asked_at DESC,id DESC LIMIT $7`, append(args, before, cursor.ID, query.Limit+1)...) if err != nil { return fmt.Errorf("%w: record list failed", ErrUnavailable) @@ -259,14 +264,14 @@ func (c *Client) ListRecords(ctx context.Context, query RecordQuery) (RecordPage return page, nil } -func (c *Client) GetRecord(ctx context.Context, tenantKey, id string) (Record, error) { - if strings.TrimSpace(tenantKey) == "" || id == "" || len(id) > 256 || strings.ContainsRune(id, 0) { +func (c *RecordsRepository) GetRecord(ctx context.Context, tenantKey, id string) (Record, error) { + if strings.TrimSpace(tenantKey) == "" || (c.tenant != "" && tenantKey != c.tenant) || id == "" || len(id) > 256 || strings.ContainsRune(id, 0) { return Record{}, ErrInvalidQuery } var item Record - err := c.withReadOnlyTx(ctx, func(ctx context.Context, q querier) error { + err := c.transact(ctx, func(ctx context.Context, q querier) error { var raw string - err := q.QueryRow(ctx, recordsBaseSQL+` SELECT payload::text FROM records WHERE id=$2`, tenantKey, id).Scan(&raw) + err := q.QueryRow(ctx, c.baseSQL+` SELECT payload::text FROM records WHERE id=$2`, tenantKey, id).Scan(&raw) if errors.Is(err, pgx.ErrNoRows) { return ErrRecordNotFound } diff --git a/internal/httpapi/qa_mirror_quality_test.go b/internal/httpapi/qa_mirror_quality_test.go new file mode 100644 index 0000000..443e089 --- /dev/null +++ b/internal/httpapi/qa_mirror_quality_test.go @@ -0,0 +1,45 @@ +package httpapi + +import ( + "context" + "knowledge-governance/internal/dobotaiops" + "knowledge-governance/internal/qaopsquality" + "net/http" + "testing" + "time" +) + +type mirroredQualitySource struct { + qualitySource + reads int +} + +func (s *mirroredQualitySource) ReadStoredQuality(_ context.Context, keys []dobotaiops.QualityKey) (map[string]qaopsquality.View, error) { + s.reads++ + out := map[string]qaopsquality.View{} + for _, key := range keys { + out[key.ID] = qaopsquality.Project(key.InputHash, 1, 0, qaopsquality.Check{Result: dobotaiops.CheckMarkerCoverage(true)}, nil) + } + return out, nil +} +func TestMirroredQualityQueriesDoNotRepeatObservationWrites(t *testing.T) { + source := &mirroredQualitySource{qualitySource: qualitySource{record: dobotaiops.Record{ID: "fixture-turn", Question: "fixture", Answer: "暂无已发布"}}} + e, _ := qaopsquality.EvidenceFor(source.record) + source.record.QualityInputHash = e.Hash + legacy := &qualityFake{fail: true} + auth := newFakeAuthStore() + auth.sessions["quality-admin"] = "usr-boss" + h, e2 := NewHandler(Config{Store: &fakeJobStore{}, Auth: &AuthConfig{Store: auth}, QAOps: source, QAOpsTenantKey: "fixed", QAQuality: legacy, Now: func() time.Time { return time.Date(2026, 10, 17, 0, 0, 0, 0, time.UTC) }}) + if e2 != nil { + t.Fatal(e2) + } + for _, q := range []struct{ method, path, body string }{{"GET", "/api/v1/qa-ops/records/fixture-turn", ""}, {"GET", "/api/v1/qa-ops/records?start=2026-10-14T00:00:00Z&end=2026-10-15T00:00:00Z", ""}, {"POST", "/api/v1/qa-ops/quality/query", `{"start":"2026-10-14T00:00:00Z","end":"2026-10-15T00:00:00Z","keyword":""}`}} { + w := qualityRequest(h, q.method, q.path, "quality-admin", q.body) + if w.Code != http.StatusOK { + t.Fatal("local mirror read went back through observation writes", q.path, w.Code) + } + } + if source.reads != 3 || legacy.calls != 0 { + t.Fatal("batched stored projection not used", source.reads, legacy.calls) + } +} diff --git a/internal/httpapi/qa_mirror_sync.go b/internal/httpapi/qa_mirror_sync.go new file mode 100644 index 0000000..81a055d --- /dev/null +++ b/internal/httpapi/qa_mirror_sync.go @@ -0,0 +1,79 @@ +package httpapi + +import ( + "context" + "encoding/json" + "errors" + "io" + "knowledge-governance/internal/qaopsmirror" + "net/http" + "strings" +) + +type qaMirrorSyncStore interface { + GetSyncStatus(context.Context) (qaopsmirror.Status, error) + EnqueueSync(context.Context, string, string) (qaopsmirror.SyncReceipt, bool, error) +} + +func qaRecordsSync(w http.ResponseWriter, r *http.Request, c Config) { + if !authorizeQAOps(w, r) { + return + } + if r.URL.RawQuery != "" { + writeJSON(w, 400, map[string]string{"error": "同步入口不接受查询参数"}) + return + } + store, ok := c.QAOps.(qaMirrorSyncStore) + if !ok || !c.QAOpsEnabled { + writeJSON(w, 503, map[string]string{"error": "本地问答同步未接线,未创建任务"}) + return + } + if r.Method == http.MethodGet { + state, e := store.GetSyncStatus(r.Context()) + if e != nil { + qaOpsError(w, e) + return + } + writeJSON(w, 200, map[string]any{"data": state}) + return + } + if !authorizeQAIntent(w, r, "qa-records-sync") { + return + } + key := r.Header.Get("Idempotency-Key") + if key == "" || len(key) > 128 || strings.TrimSpace(key) != key { + writeJSON(w, 400, map[string]string{"error": "同步请求需要有效 Idempotency-Key,重试须复用原标识"}) + return + } + var body *struct{} + d := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1024)) + d.DisallowUnknownFields() + if d.Decode(&body) != nil || body == nil || d.Decode(&struct{}{}) != io.EOF { + writeJSON(w, 400, map[string]string{"error": "同步请求体必须是空对象"}) + return + } + identity, _ := sessionFrom(r.Context()) + receipt, replayed, e := store.EnqueueSync(r.Context(), key, identity.Username) + if e != nil { + status := 503 + message := "同步未成功入队,已有本地数据保持不变" + if errors.Is(e, qaopsmirror.ErrInvalid) { + status = 400 + message = "同步请求标识无效" + } + if errors.Is(e, qaopsmirror.ErrConflict) { + status = 409 + message = "同步标识已用于另一操作,请重新发起" + } + if errors.Is(e, qaopsmirror.ErrWorkerUnavailable) { + message = "问答同步执行器未在线,未创建任务" + } + writeJSON(w, status, map[string]string{"error": message}) + return + } + operation := "created" + if replayed { + operation = "replayed" + } + writeJSON(w, 202, map[string]any{"data": receipt, "operation": operation}) +} diff --git a/internal/httpapi/qa_mirror_sync_test.go b/internal/httpapi/qa_mirror_sync_test.go new file mode 100644 index 0000000..7115b28 --- /dev/null +++ b/internal/httpapi/qa_mirror_sync_test.go @@ -0,0 +1,86 @@ +package httpapi + +import ( + "context" + "encoding/json" + "knowledge-governance/internal/qaopsmirror" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +type mirrorSyncSpy struct { + qaOpsSpy + syncCalls int + key, actor string +} + +func (s *mirrorSyncSpy) GetSyncStatus(context.Context) (qaopsmirror.Status, error) { + return qaopsmirror.Status{Mode: "local", IntervalSeconds: 300, WorkerReady: true}, nil +} +func (s *mirrorSyncSpy) EnqueueSync(_ context.Context, key, actor string) (qaopsmirror.SyncReceipt, bool, error) { + s.syncCalls++ + s.key = key + s.actor = actor + return qaopsmirror.SyncReceipt{JobID: "fixture-mirror-job"}, false, nil +} +func TestQAMirrorSyncRequiresSessionIntentAndIdempotency(t *testing.T) { + spy := &mirrorSyncSpy{} + auth := newFakeAuthStore() + auth.sessions["mirror-admin"] = "usr-boss" + auth.sessions["mirror-viewer"] = "usr-eye" + h, e := NewHandler(Config{Store: &fakeJobStore{}, QAOps: spy, QAOpsEnabled: true, QAOpsTenantKey: "fixture-tenant", Auth: &AuthConfig{Store: auth}}) + if e != nil { + t.Fatal(e) + } + for _, tc := range []struct { + method, token, key, intent, origin, body string + status int + }{ + {"GET", "", "", "", "", "", 401}, {"GET", "mirror-viewer", "", "", "", "", 403}, {"GET", "mirror-admin", "", "", "", "", 200}, + {"POST", "mirror-admin", "op-one", "", "", "{}", 403}, {"POST", "mirror-admin", "op-one", "qa-records-sync", "https://foreign.invalid", "{}", 403}, + {"POST", "mirror-admin", "", "qa-records-sync", "", "{}", 400}, {"POST", "mirror-admin", "op-one", "qa-records-sync", "", "null", 400}, + {"POST", "mirror-admin", "op-one", "qa-records-sync", "", `{"tenant":"forged"}`, 400}, {"POST", "mirror-admin", "op-one", "qa-records-sync", "", "{}", 202}, + } { + req := httptest.NewRequest(tc.method, "/api/v1/qa-ops/sync", strings.NewReader(tc.body)) + if tc.token != "" { + req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: tc.token}) + } + req.Header.Set("Idempotency-Key", tc.key) + req.Header.Set("X-Governance-Action", tc.intent) + req.Header.Set("Origin", tc.origin) + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + if w.Code != tc.status { + t.Fatalf("%s %s got %d want %d: %s", tc.method, tc.body, w.Code, tc.status, w.Body.String()) + } + } + if spy.syncCalls != 1 || spy.key != "op-one" || spy.actor == "" { + t.Fatal("invalid sync reached store or operator was not session-stamped") + } + req := httptest.NewRequest("GET", "/api/v1/qa-ops/status", nil) + req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "mirror-admin"}) + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + var body struct { + Data struct { + Cache *qaopsmirror.Status `json:"cache"` + } + } + if json.Unmarshal(w.Body.Bytes(), &body) != nil || body.Data.Cache == nil || body.Data.Cache.Mode != "local" { + t.Fatal("local serving mode missing from status") + } +} +func TestQAMirrorSyncNamespaceCannotBypassEntry(t *testing.T) { + auth := newFakeAuthStore() + auth.sessions["mirror-admin"] = "usr-boss" + h, _ := NewHandler(Config{Store: &fakeJobStore{}, Auth: &AuthConfig{Store: auth}}) + req := httptest.NewRequest("POST", "/api/v1/jobs", strings.NewReader(`{"type":"qaops.records.sync.fixture","payload":{},"idempotency_key":"bypass"}`)) + req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "mirror-admin"}) + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + if w.Code != 400 { + t.Fatal("generic jobs endpoint bypassed mirror sync admission", w.Code) + } +} diff --git a/internal/httpapi/qa_ops.go b/internal/httpapi/qa_ops.go index f9b0889..fdc9462 100644 --- a/internal/httpapi/qa_ops.go +++ b/internal/httpapi/qa_ops.go @@ -61,7 +61,16 @@ func qaOpsStatus(w http.ResponseWriter, r *http.Request, config Config) { warnings = append(warnings, "database_transport_unencrypted") } } - writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"state": state, "code": code, "warnings": warnings, "read_only": true, "source_version": "2.0", "channel": "dingtalk"}}) + data := map[string]any{"state": state, "code": code, "warnings": warnings, "read_only": true, "source_version": "2.0", "channel": "dingtalk"} + if mirror, ok := config.QAOps.(qaMirrorSyncStore); ok { + cache, err := mirror.GetSyncStatus(r.Context()) + if err != nil { + qaOpsError(w, err) + return + } + data["cache"] = cache + } + writeJSON(w, http.StatusOK, map[string]any{"data": data}) } func listQAOpsRecords(w http.ResponseWriter, r *http.Request, config Config) { @@ -144,7 +153,7 @@ func getQAOpsRecord(w http.ResponseWriter, r *http.Request, config Config) { func qaOpsError(w http.ResponseWriter, err error) { status := http.StatusServiceUnavailable code := "source_unavailable" - message := "问答数据源不可用,请检查独立只读连接;原有知识治理功能不受影响" + message := "问答记录当前不可用;本地模式请检查同步状态,原有知识治理功能不受影响" if errors.Is(err, errQAOrganization) { code = "organization_unavailable" message = "通讯录或区域配置读取失败,未将不可用数据伪装成空列,请重试" diff --git a/internal/httpapi/qa_quality.go b/internal/httpapi/qa_quality.go index 9fd4fe1..c293433 100644 --- a/internal/httpapi/qa_quality.go +++ b/internal/httpapi/qa_quality.go @@ -22,6 +22,21 @@ type QAQualityStore interface { Snapshot(context.Context, string, int64) (qaopsquality.Snapshot, error) } +type storedQAQualityReader interface { + ReadStoredQuality(context.Context, []dobotaiops.QualityKey) (map[string]qaopsquality.View, error) +} + +func qaQualityViews(ctx context.Context, c Config, records []dobotaiops.Record) (map[string]qaopsquality.View, error) { + if reader, ok := c.QAOps.(storedQAQualityReader); ok { + keys := make([]dobotaiops.QualityKey, 0, len(records)) + for _, r := range records { + keys = append(keys, dobotaiops.QualityKey{ID: r.ID, InputHash: r.QualityInputHash}) + } + return reader.ReadStoredQuality(ctx, keys) + } + return c.QAQuality.Observe(ctx, records) +} + type qaQualityReader interface { ReadQualityScope(context.Context, dobotaiops.RecordQuery) (dobotaiops.QualityScope, error) } @@ -44,7 +59,7 @@ func enrichQAQualityRecord(ctx context.Context, c Config, item *dobotaiops.Recor if c.QAQuality == nil { return nil } - views, err := c.QAQuality.Observe(ctx, []dobotaiops.Record{*item}) + views, err := qaQualityViews(ctx, c, []dobotaiops.Record{*item}) if err != nil { return err } @@ -59,7 +74,7 @@ func enrichQAQualityPage(ctx context.Context, c Config, page *dobotaiops.RecordP if c.QAQuality == nil { return nil } - views, err := c.QAQuality.Observe(ctx, page.Items) + views, err := qaQualityViews(ctx, c, page.Items) if err != nil { return err } @@ -197,7 +212,12 @@ func qaQualityQuery(w http.ResponseWriter, r *http.Request, c Config) { qaOpsError(w, err) return } - views, err := c.QAQuality.ObserveKeys(r.Context(), scope.Keys) + var views map[string]qaopsquality.View + if stored, ok := c.QAOps.(storedQAQualityReader); ok { + views, err = stored.ReadStoredQuality(r.Context(), scope.Keys) + } else { + views, err = c.QAQuality.ObserveKeys(r.Context(), scope.Keys) + } if err != nil || len(views) != len(scope.Keys) { if err == nil { err = qaopsquality.ErrUnavailable diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index cba5bc1..226494b 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -19,6 +19,7 @@ import ( "knowledge-governance/internal/jobs" "knowledge-governance/internal/monitoring" postgresstore "knowledge-governance/internal/platform/postgres" + "knowledge-governance/internal/qaopsmirror" "knowledge-governance/internal/qaopsorg" ) @@ -188,6 +189,8 @@ func NewHandler(config Config) (http.Handler, error) { mux.HandleFunc("GET /api/v1/qa-ops/organization", func(w http.ResponseWriter, r *http.Request) { qaOrganization(w, r, config) }) mux.HandleFunc("POST /api/v1/qa-ops/organization/preview", func(w http.ResponseWriter, r *http.Request) { qaOrganization(w, r, config) }) mux.HandleFunc("PUT /api/v1/qa-ops/organization/regions", func(w http.ResponseWriter, r *http.Request) { qaOrganization(w, r, config) }) + mux.HandleFunc("GET /api/v1/qa-ops/sync", func(w http.ResponseWriter, r *http.Request) { qaRecordsSync(w, r, config) }) + mux.HandleFunc("POST /api/v1/qa-ops/sync", func(w http.ResponseWriter, r *http.Request) { qaRecordsSync(w, r, config) }) mux.HandleFunc("GET /api/v1/qa-ops/status", func(w http.ResponseWriter, r *http.Request) { qaOpsStatus(w, r, config) }) @@ -569,7 +572,7 @@ func createJob(w http.ResponseWriter, r *http.Request, config Config) { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON request"}) return } - if strings.HasPrefix(input.Type, qaopsorg.SyncJobPrefix) { + if strings.HasPrefix(input.Type, qaopsorg.SyncJobPrefix) || strings.HasPrefix(input.Type, qaopsmirror.SyncJobPrefix) { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "通讯录同步任务只能从问答运营的管理员入口创建"}) return } diff --git a/internal/platform/postgres/migrations/000050_qa_ops_local_mirror.down.sql b/internal/platform/postgres/migrations/000050_qa_ops_local_mirror.down.sql new file mode 100644 index 0000000..cd1beb7 --- /dev/null +++ b/internal/platform/postgres/migrations/000050_qa_ops_local_mirror.down.sql @@ -0,0 +1,2 @@ +DROP TABLE qa_ops_mirror_records; +DROP TABLE qa_ops_mirror_state; diff --git a/internal/platform/postgres/migrations/000050_qa_ops_local_mirror.up.sql b/internal/platform/postgres/migrations/000050_qa_ops_local_mirror.up.sql new file mode 100644 index 0000000..76729a9 --- /dev/null +++ b/internal/platform/postgres/migrations/000050_qa_ops_local_mirror.up.sql @@ -0,0 +1,14 @@ +CREATE TABLE qa_ops_mirror_state ( + tenant_key TEXT PRIMARY KEY CHECK(length(tenant_key) BETWEEN 1 AND 128), + ready BOOLEAN NOT NULL DEFAULT false, blocked BOOLEAN NOT NULL DEFAULT false, + revision BIGINT NOT NULL DEFAULT 0 CHECK(revision>=0), record_count BIGINT NOT NULL DEFAULT 0 CHECK(record_count>=0), + source_observed_at TIMESTAMPTZ, synced_at TIMESTAMPTZ, next_sync_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_error TEXT NOT NULL DEFAULT '', source_safety JSONB NOT NULL DEFAULT '{}'::jsonb +); +CREATE TABLE qa_ops_mirror_records ( + tenant_key TEXT NOT NULL REFERENCES qa_ops_mirror_state(tenant_key), id TEXT NOT NULL, + fingerprint TEXT NOT NULL CHECK(fingerprint ~ '^[a-f0-9]{64}$'), asked_at TIMESTAMPTZ NOT NULL, + question TEXT NOT NULL, status TEXT NOT NULL, identity JSONB NOT NULL, quality_hash TEXT NOT NULL CHECK(quality_hash ~ '^[a-f0-9]{64}$'), + payload JSONB NOT NULL CHECK(jsonb_typeof(payload)='object'), PRIMARY KEY(tenant_key,id) +); +CREATE INDEX idx_qa_mirror_records_window ON qa_ops_mirror_records(tenant_key,asked_at DESC,id DESC); diff --git a/internal/platform/postgres/migrations_test.go b/internal/platform/postgres/migrations_test.go index 08f49c5..773313f 100644 --- a/internal/platform/postgres/migrations_test.go +++ b/internal/platform/postgres/migrations_test.go @@ -11,8 +11,8 @@ func TestEmbeddedMigrationsAreVersionSorted(t *testing.T) { if err != nil { t.Fatalf("loadMigrations() error = %v", err) } - if len(migrations) != 49 { - t.Fatalf("migration count = %d, want 49", len(migrations)) + if len(migrations) != 50 { + t.Fatalf("migration count = %d, want 50", len(migrations)) } if migrations[0].Version != 1 || !strings.Contains(migrations[0].UpSQL, "CREATE TABLE IF NOT EXISTS jobs") { t.Fatalf("migration 1 = %#v", migrations[0]) diff --git a/internal/qaopsmirror/failure_integration_test.go b/internal/qaopsmirror/failure_integration_test.go new file mode 100644 index 0000000..70fc737 --- /dev/null +++ b/internal/qaopsmirror/failure_integration_test.go @@ -0,0 +1,118 @@ +package qaopsmirror + +import ( + "context" + "errors" + "fmt" + "knowledge-governance/internal/dobotaiops" + "knowledge-governance/internal/jobs" + pg "knowledge-governance/internal/platform/postgres" + "knowledge-governance/internal/qaopsquality" + "strings" + "testing" + "time" +) + +func TestLocalMirrorFailuresUpdatesAndReviewPreservation(t *testing.T) { + p := mirrorFixture(t) + s := NewStore(p, "fixture-failures") + q := pg.NewStore(p) + ctx := context.Background() + first := &fixtureSource{rows: []dobotaiops.MirrorRecord{fixtureEntry("one", "old answer")}} + run := func(source *fixtureSource, key string) error { + j := claimMirrorJob(t, s, q, key) + e := s.HandleSync(ctx, *j, func(context.Context) (Source, error) { return source, nil }) + if e == nil { + if e = q.Complete(ctx, j.ID, j.LeaseOwner, time.Now()); e != nil { + t.Fatal(e) + } + } else { + if _, cleanupErr := p.Exec(ctx, `UPDATE jobs SET state='failed',lease_owner=NULL,lease_until=NULL WHERE id=$1`, j.ID); cleanupErr != nil { + t.Fatal(cleanupErr) + } + } + return e + } + if e := run(first, "initial-good"); e != nil { + t.Fatal(e) + } + quality := qaopsquality.NewStore(p, s.tenant) + record, _ := s.GetRecord(ctx, s.tenant, "one") + views, e := quality.Observe(ctx, []dobotaiops.Record{record}) + if e != nil { + t.Fatal(e) + } + v := views["one"] + saved, e := quality.Save(ctx, record, qaopsquality.SaveRequest{InputHash: v.InputHash, InputEpoch: v.InputEpoch, BaseRevision: v.Revision, Nonce: strings.Repeat("a", 32), Fields: qaopsquality.Fields{Coverage: "covered", Correctness: "correct"}, Reason: "explicit synthetic review"}, "fixture-admin") + if e != nil { + t.Fatal(e) + } + unchanged := &fixtureSource{rows: first.rows} + if e = run(unchanged, "unchanged-check"); e != nil || unchanged.payloadCalls != 0 { + t.Fatal("unchanged poll transferred long answers", e) + } + state, _ := s.GetSyncStatus(ctx) + if state.Revision != 1 { + t.Fatal("unchanged poll invalidated query generations") + } + current, e := quality.Observe(ctx, []dobotaiops.Record{record}) + if e != nil || current["one"].State != "reviewed" || current["one"].Revision != saved.SavedRevision { + t.Fatal("unchanged synchronization damaged review", e) + } + partial := &fixtureSource{payloadError: errors.New("fixture interrupted payload"), failAfter: 2} + partial.rows = append(partial.rows, fixtureEntry("one", "new answer")) + for n := 0; n < 101; n++ { + partial.rows = append(partial.rows, fixtureEntry(fmt.Sprintf("extra-%03d", n), "additional answer")) + } + if e = run(partial, "interrupted-full-copy"); e == nil { + t.Fatal("partial fetch was called complete") + } + old, e := s.GetRecord(ctx, s.tenant, "one") + state, _ = s.GetSyncStatus(ctx) + if e != nil || old.Answer != "old answer" || state.Records != 1 || state.Revision != 1 || state.ErrorCode == "" || !state.Ready { + t.Fatal("partial failure mutated last-good local copy", e) + } + current, e = quality.Observe(ctx, []dobotaiops.Record{old}) + if e != nil || current["one"].State != "reviewed" { + t.Fatal("partial source fetch advanced review input", e) + } + changed := &fixtureSource{rows: []dobotaiops.MirrorRecord{fixtureEntry("one", "changed answer")}} + if e = run(changed, "changed-old-record"); e != nil { + t.Fatal(e) + } + fresh, _ := s.GetRecord(ctx, s.tenant, "one") + current, e = quality.Observe(ctx, []dobotaiops.Record{fresh}) + if e != nil || current["one"].State != "stale" || current["one"].LatestReview == nil || current["one"].LatestReview.Revision != saved.SavedRevision { + t.Fatal("answer change lost history or retained an obsolete judgment", e) + } + denied := &fixtureSource{err: dobotaiops.ErrNotReadOnly} + if e = run(denied, "authorization-denied"); e == nil { + t.Fatal("source authorization failure ignored") + } + if e = s.Available(ctx); !errors.Is(e, dobotaiops.ErrNotReadOnly) { + t.Fatal("revoked source left local reads enabled", e) + } + recovered := &fixtureSource{rows: changed.rows} + if e = run(recovered, "authorization-recovered"); e != nil || s.Available(ctx) != nil { + t.Fatal("verified recovery failed", e) + } +} +func TestLocalMirrorRejectsUnadmittedJobsBeforeSource(t *testing.T) { + p := mirrorFixture(t) + s := NewStore(p, "fixture-unadmitted") + q := pg.NewStore(p) + ctx := context.Background() + _, e := q.Enqueue(ctx, jobs.EnqueueRequest{ID: "fixture-bypass", Type: s.SyncJobType(), Payload: []byte(`{}`), AvailableAt: time.Now(), MaxAttempts: 1, IdempotencyKey: "fixture-bypass"}, time.Now()) + if e != nil { + t.Fatal(e) + } + j, e := q.Claim(ctx, "fixture-bypass-worker", []string{s.SyncJobType()}, time.Now(), time.Minute) + if e != nil || j == nil { + t.Fatal(e) + } + calls := 0 + e = s.HandleSync(ctx, *j, func(context.Context) (Source, error) { calls++; return &fixtureSource{}, nil }) + if !errors.Is(e, ErrLease) || calls != 0 { + t.Fatal("unadmitted job reached source", e, calls) + } +} diff --git a/internal/qaopsmirror/metadata.go b/internal/qaopsmirror/metadata.go new file mode 100644 index 0000000..38bf52b --- /dev/null +++ b/internal/qaopsmirror/metadata.go @@ -0,0 +1,10 @@ +package qaopsmirror + +import "knowledge-governance/internal/dobotaiops" + +// The complete fixed-column UI does not need to repeatedly decompress every +// large answer on every page just to rediscover which headings exist. Exact +// marker/source evidence was committed atomically with each mirror payload. +const localMetadataBase = `WITH records AS (SELECT m.id,m.asked_at,m.question,m.identity,m.quality_hash,COALESCE(c.result->>'verdict'='uncovered',false) AS marker,COALESCE(c.source,'{}'::jsonb) AS source_coverage FROM qa_ops_mirror_records m LEFT JOIN qa_ops_quality_records h ON h.tenant_key=m.tenant_key AND h.record_id=m.id AND h.input_hash=m.quality_hash LEFT JOIN qa_ops_quality_checks c ON c.tenant_key=h.tenant_key AND c.record_id=h.record_id AND c.input_epoch=h.input_epoch AND c.rule_version='` + dobotaiops.CoverageRuleVersion + `' WHERE m.tenant_key=$1)` +const localSummarySQL = localMetadataBase + ` SELECT count(*),'{}'::text,COALESCE(jsonb_agg(DISTINCT identity),'[]'::jsonb)::text,COALESCE(jsonb_agg(jsonb_build_object('id',id,'input_hash',quality_hash,'marker',marker,'source',source_coverage)),'[]'::jsonb)::text FROM records WHERE $5::text IS NOT NULL AND ` +const localQualityScopeSQL = localMetadataBase + ` SELECT id,quality_hash,marker,source_coverage::text FROM records WHERE $5::text IS NOT NULL AND ` diff --git a/internal/qaopsmirror/scheduler_recovery_integration_test.go b/internal/qaopsmirror/scheduler_recovery_integration_test.go new file mode 100644 index 0000000..585386f --- /dev/null +++ b/internal/qaopsmirror/scheduler_recovery_integration_test.go @@ -0,0 +1,39 @@ +package qaopsmirror + +import ( + "context" + pg "knowledge-governance/internal/platform/postgres" + "testing" + "time" +) + +func TestLocalMirrorSchedulerRecoversExhaustedCrash(t *testing.T) { + p := mirrorFixture(t) + s := NewStore(p, "fixture-crashed-scheduler") + q := pg.NewStore(p) + ctx := context.Background() + if _, e := q.Claim(ctx, "fixture-crashed-worker", []string{s.SyncJobType()}, time.Now(), time.Minute); e != nil { + t.Fatal(e) + } + if _, e := p.Exec(ctx, `INSERT INTO qa_ops_mirror_state(tenant_key,next_sync_at) VALUES($1,now()-interval '1 hour')`, s.tenant); e != nil { + t.Fatal(e) + } + if yes, e := s.EnqueueDue(ctx); e != nil || !yes { + t.Fatal(e) + } + job, e := q.Claim(ctx, "fixture-crashed-worker", []string{s.SyncJobType()}, time.Now(), time.Minute) + if e != nil || job == nil { + t.Fatal(e) + } + // Model death after claiming the final attempt, before any failure checkpoint. + if _, e = p.Exec(ctx, `UPDATE jobs SET attempt=max_attempts,lease_until=now()-interval '1 second' WHERE id=$1`, job.ID); e != nil { + t.Fatal(e) + } + if _, e = s.EnqueueDue(ctx); e != nil { + t.Fatal(e) + } + state, e := s.GetSyncStatus(ctx) + if e != nil || state.NextSyncAt == nil || !state.NextSyncAt.After(time.Now()) || state.ErrorCode != "mirror_sync_interrupted" { + t.Fatal("scheduler replayed an exhausted operation forever instead of persisting a new retry due time", e) + } +} diff --git a/internal/qaopsmirror/store.go b/internal/qaopsmirror/store.go new file mode 100644 index 0000000..bf16e82 --- /dev/null +++ b/internal/qaopsmirror/store.go @@ -0,0 +1,163 @@ +// Package qaopsmirror serves an explicitly synchronized local copy, never an implicit source fallback. +package qaopsmirror + +import ( + "context" + "encoding/json" + "errors" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + "knowledge-governance/internal/dobotaiops" + "strings" + "sync" + "time" +) + +const Interval = 5 * time.Minute +const localBaseSQL = `WITH records AS (SELECT id,asked_at,question,status,identity,quality_hash,payload FROM qa_ops_mirror_records WHERE tenant_key=$1)` + +type Store struct { + pool *pgxpool.Pool + tenant string + mu sync.RWMutex + safety dobotaiops.SourceSafety +} + +func NewStore(pool *pgxpool.Pool, tenant string) *Store { return &Store{pool: pool, tenant: tenant} } + +type Status struct { + Mode string `json:"mode"` + Ready bool `json:"ready"` + Blocked bool `json:"blocked"` + Revision int64 `json:"revision"` + Records int64 `json:"records"` + SourceObservedAt *time.Time `json:"source_observed_at"` + LastSuccessAt *time.Time `json:"last_success_at"` + NextSyncAt *time.Time `json:"next_sync_at"` + ErrorCode string `json:"error_code"` + IntervalSeconds int `json:"interval_seconds"` + WorkerReady bool `json:"worker_ready"` + Job *SyncJob `json:"job"` + Safety dobotaiops.SourceSafety `json:"-"` +} +type SyncJob struct { + ID string `json:"id"` + State string `json:"state"` + Loaded int `json:"loaded"` + Expected int `json:"expected"` + Changed int `json:"changed"` + ErrorCode string `json:"error_code"` +} + +func (s *Store) valid() bool { + return s != nil && s.pool != nil && strings.TrimSpace(s.tenant) == s.tenant && len(s.tenant) > 0 && len(s.tenant) <= 128 && !strings.ContainsRune(s.tenant, 0) +} +func (s *Store) readState(ctx context.Context, q dobotaiops.RecordQuerier) (Status, error) { + out := Status{Mode: "local", IntervalSeconds: int(Interval / time.Second)} + var safety []byte + err := q.QueryRow(ctx, `SELECT ready,blocked,revision,record_count,source_observed_at,synced_at,next_sync_at,last_error,source_safety FROM qa_ops_mirror_state WHERE tenant_key=$1`, s.tenant).Scan(&out.Ready, &out.Blocked, &out.Revision, &out.Records, &out.SourceObservedAt, &out.LastSuccessAt, &out.NextSyncAt, &out.ErrorCode, &safety) + if errors.Is(err, pgx.ErrNoRows) { + out.ErrorCode = "initial_sync_pending" + return out, nil + } + if err != nil || json.Unmarshal(safety, &out.Safety) != nil { + return Status{}, dobotaiops.ErrUnavailable + } + s.mu.Lock() + s.safety = out.Safety + s.mu.Unlock() + return out, nil +} + +type txQuery struct{ pgx.Tx } + +func (q txQuery) QueryRow(ctx context.Context, sql string, args ...any) dobotaiops.RecordRow { + return q.Tx.QueryRow(ctx, sql, args...) +} + +type poolQuery struct{ *pgxpool.Pool } + +func (q poolQuery) QueryRow(ctx context.Context, sql string, args ...any) dobotaiops.RecordRow { + return q.Pool.QueryRow(ctx, sql, args...) +} +func (s *Store) Available(ctx context.Context) error { + if !s.valid() { + return dobotaiops.ErrUnavailable + } + state, e := s.readState(ctx, poolQuery{s.pool}) + if e != nil { + return e + } + if state.Blocked { + return dobotaiops.ErrNotReadOnly + } + if !state.Ready { + return dobotaiops.ErrUnavailable + } + return nil +} +func (s *Store) Safety() dobotaiops.SourceSafety { s.mu.RLock(); defer s.mu.RUnlock(); return s.safety } +func (s *Store) repository(revision *int64) *dobotaiops.RecordsRepository { + return dobotaiops.NewRecordsRepository(dobotaiops.RecordsRepositoryOptions{ + BaseSQL: localBaseSQL, SummarySQL: localSummarySQL, QualityScopeSQL: localQualityScopeSQL, TenantKey: s.tenant, + ValidateWindow: func(w dobotaiops.Window) error { + if w.TenantKey != s.tenant { + return dobotaiops.ErrInvalidQuery + } + _, e := dobotaiops.NewWindow(w.TenantKey, w.Start.Format(time.RFC3339Nano), w.End.Format(time.RFC3339Nano), 90*24*time.Hour, time.Now()) + return e + }, + ReadAsOf: func(ctx context.Context, q dobotaiops.RecordQuerier, t *time.Time) error { + return q.QueryRow(ctx, `SELECT source_observed_at FROM qa_ops_mirror_state WHERE tenant_key=$1`, s.tenant).Scan(t) + }, + Transaction: func(ctx context.Context, fn func(context.Context, dobotaiops.RecordQuerier) error) error { + if !s.valid() { + return dobotaiops.ErrUnavailable + } + ctx, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + tx, e := s.pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.RepeatableRead, AccessMode: pgx.ReadOnly}) + if e != nil { + return dobotaiops.ErrUnavailable + } + defer func() { _ = tx.Rollback(context.Background()) }() + q := txQuery{tx} + state, e := s.readState(ctx, q) + if e != nil { + return e + } + if state.Blocked { + return dobotaiops.ErrNotReadOnly + } + if !state.Ready { + return dobotaiops.ErrUnavailable + } + if revision != nil { + *revision = state.Revision + } + if e = fn(ctx, q); e != nil { + return e + } + if tx.Commit(ctx) != nil { + return dobotaiops.ErrUnavailable + } + return nil + }, + }) +} +func (s *Store) ListRecords(ctx context.Context, q dobotaiops.RecordQuery) (dobotaiops.RecordPage, error) { + var rev int64 + p, e := s.repository(&rev).ListRecords(ctx, q) + p.LocalRevision = rev + p.Columns = []string{"question_chinese", "answer", "coverage", "is_correct", "product_category", "problem_type", "root_cause", "asked_at", "asker", "question", "language", "trigger_person", "department", "region", "feedback"} + return p, e +} +func (s *Store) GetRecord(ctx context.Context, tenant, id string) (dobotaiops.Record, error) { + return s.repository(nil).GetRecord(ctx, tenant, id) +} +func (s *Store) ReadUsageGroups(ctx context.Context, q dobotaiops.RecordQuery) (dobotaiops.UsageGroups, error) { + return s.repository(nil).ReadUsageGroups(ctx, q) +} +func (s *Store) ReadQualityScope(ctx context.Context, q dobotaiops.RecordQuery) (dobotaiops.QualityScope, error) { + return s.repository(nil).ReadQualityScope(ctx, q) +} diff --git a/internal/qaopsmirror/store_integration_test.go b/internal/qaopsmirror/store_integration_test.go new file mode 100644 index 0000000..db18d43 --- /dev/null +++ b/internal/qaopsmirror/store_integration_test.go @@ -0,0 +1,76 @@ +package qaopsmirror + +import ( + "context" + "encoding/json" + "errors" + "github.com/jackc/pgx/v5/pgxpool" + "knowledge-governance/internal/dobotaiops" + pg "knowledge-governance/internal/platform/postgres" + "net/url" + "os" + "strings" + "testing" + "time" +) + +func mirrorFixture(t *testing.T) *pgxpool.Pool { + t.Helper() + dsn := os.Getenv("KG_QAOPS_MIRROR_TEST_DSN") + if dsn == "" { + t.Skip("explicit disposable mirror database required") + } + u, e := url.Parse(dsn) + if e != nil || u.Hostname() != "127.0.0.1" || !strings.HasPrefix(u.Path, "/kg_qaops_mirror_test_") || os.Getenv("KG_QAOPS_MIRROR_TEST_ISOLATED") != "1" { + t.Fatal("refusing shared mirror database") + } + p, e := pgxpool.New(context.Background(), dsn) + if e != nil { + t.Fatal("fixture connection failed") + } + t.Cleanup(p.Close) + if e = pg.ApplyMigrations(context.Background(), p); e != nil { + t.Fatal(e) + } + return p +} +func TestLocalMirrorQueriesNeverRequireSource(t *testing.T) { + p := mirrorFixture(t) + ctx := context.Background() + s := NewStore(p, "fixture-mirror") + if s.Available(ctx) == nil { + t.Fatal("an unsynchronized mirror pretended to be empty/ready") + } + asked := time.Now().UTC().Add(-time.Hour).Truncate(time.Second) + wire := map[string]any{"id": "fixture-one", "status": "succeeded", "question": "literal 100%_ question", "answer": "# local answer\n暂无已发布", "asked_at": asked, "time_source": "turn.created_at", "asker": "fixture name", "trigger_person": "", "department": "", "region": "", "references": []any{}, "media": []any{}, "source_coverage": map[string]string{"verdict": "covered", "reason_code": "", "evaluator_version": "v5"}, "feedback": map[string]any{"asker_value": "up", "up": 1, "down": 0}, "_identity": map[string]string{"actor_id": "actor1", "staff_id": "staff1", "union_id": ""}, "_quality_hash": strings.Repeat("a", 64), "_quality_observed_at": time.Now().UTC()} + raw, _ := json.Marshal(wire) + if _, e := p.Exec(ctx, `INSERT INTO qa_ops_mirror_state(tenant_key,ready,revision,record_count,source_observed_at,synced_at) VALUES($1,true,1,1,now(),now())`, s.tenant); e != nil { + t.Fatal(e) + } + if _, e := p.Exec(ctx, `INSERT INTO qa_ops_mirror_records(tenant_key,id,fingerprint,asked_at,question,status,identity,quality_hash,payload) VALUES($1,'fixture-one',$2,$3,$4,'succeeded',$5,$2,$6)`, s.tenant, strings.Repeat("a", 64), asked, wire["question"], `{"actor_id":"actor1","staff_id":"staff1","union_id":""}`, raw); e != nil { + t.Fatal(e) + } + // No source client or source credentials have been constructed anywhere in this test. + if e := s.Available(ctx); e != nil { + t.Fatal("local serving still requires a remote source", e) + } + q := dobotaiops.RecordQuery{Window: dobotaiops.Window{TenantKey: s.tenant, Start: asked.Add(-time.Minute), End: asked.Add(time.Minute)}, Keyword: "100%_", Limit: 1} + page, e := s.ListRecords(ctx, q) + if e != nil || page.Total != 1 || len(page.Items) != 1 || page.Items[0].Answer != wire["answer"] { + t.Fatal("local fixed-tenant literal query failed", e, page.Total) + } + if page.Items[0].Identity.StaffID != "staff1" || page.Items[0].Coverage.Verdict != "uncovered" { + t.Fatal("local copy lost source identity or deterministic rule") + } + out, _ := json.Marshal(page) + if strings.Contains(string(out), "staff1") || strings.Contains(string(out), "_identity") { + t.Fatal("private identity leaked to browser") + } + got, e := s.GetRecord(ctx, s.tenant, "fixture-one") + if e != nil || got.Answer != wire["answer"] { + t.Fatal("detail is not served from local data", e) + } + if _, e = s.GetRecord(ctx, "other-tenant", "fixture-one"); !errors.Is(e, dobotaiops.ErrInvalidQuery) { + t.Fatal("cross-tenant detail was not rejected", e) + } +} diff --git a/internal/qaopsmirror/stored_quality.go b/internal/qaopsmirror/stored_quality.go new file mode 100644 index 0000000..78bc2c9 --- /dev/null +++ b/internal/qaopsmirror/stored_quality.go @@ -0,0 +1,13 @@ +package qaopsmirror + +import ( + "context" + "knowledge-governance/internal/dobotaiops" + "knowledge-governance/internal/qaopsquality" +) + +// Payload activation observes quality atomically. Browser reads must not repeat +// per-record write transactions; mismatched inputs fail closed in ReadViews. +func (s *Store) ReadStoredQuality(ctx context.Context, keys []dobotaiops.QualityKey) (map[string]qaopsquality.View, error) { + return qaopsquality.NewStore(s.pool, s.tenant).ReadViews(ctx, keys) +} diff --git a/internal/qaopsmirror/stored_quality_integration_test.go b/internal/qaopsmirror/stored_quality_integration_test.go new file mode 100644 index 0000000..ec1240c --- /dev/null +++ b/internal/qaopsmirror/stored_quality_integration_test.go @@ -0,0 +1,83 @@ +package qaopsmirror + +import ( + "context" + "errors" + "fmt" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + "knowledge-governance/internal/dobotaiops" + "knowledge-governance/internal/qaopsquality" + "strings" + "testing" + "time" +) + +func TestLocalMirrorMetadataDoesNotReadLargeAnswerPayload(t *testing.T) { + p := mirrorFixture(t) + ctx := context.Background() + tenant := "fixture-metadata-acl" + record := dobotaiops.Record{ID: "metadata-one", Question: "fixture", Answer: "暂无已发布", QualityObservedAt: time.Now()} + views, e := qaopsquality.NewStore(p, tenant).Observe(ctx, []dobotaiops.Record{record}) + if e != nil { + t.Fatal(e) + } + hash := views[record.ID].InputHash + if _, e = p.Exec(ctx, `INSERT INTO qa_ops_mirror_state(tenant_key,ready,revision,record_count,source_observed_at,synced_at) VALUES($1,true,1,1,now(),now());`, tenant); e != nil { + t.Fatal(e) + } + if _, e = p.Exec(ctx, `INSERT INTO qa_ops_mirror_records(tenant_key,id,fingerprint,asked_at,question,status,identity,quality_hash,payload) VALUES($1,$2,$3,now()-interval '1 hour','fixture','succeeded','{}',$3,'{}')`, tenant, record.ID, hash); e != nil { + t.Fatal(e) + } + role := fmt.Sprintf("kg_mirror_metadata_%d", time.Now().UnixNano()) + if _, e = p.Exec(ctx, "CREATE ROLE "+role+" NOLOGIN"); e != nil { + t.Fatal(e) + } + defer func() { p.Exec(ctx, "DROP OWNED BY "+role); p.Exec(ctx, "DROP ROLE "+role) }() + if _, e = p.Exec(ctx, "GRANT SELECT(tenant_key,id,fingerprint,asked_at,question,status,identity,quality_hash) ON qa_ops_mirror_records TO "+role+";GRANT SELECT ON qa_ops_mirror_state,qa_ops_quality_records,qa_ops_quality_inputs,qa_ops_quality_checks TO "+role); e != nil { + t.Fatal(e) + } + config := p.Config().Copy() + config.AfterConnect = func(ctx context.Context, c *pgx.Conn) error { _, e := c.Exec(ctx, "SET ROLE "+role); return e } + limited, e := pgxpool.NewWithConfig(ctx, config) + if e != nil { + t.Fatal(e) + } + defer limited.Close() + scope, e := NewStore(limited, tenant).ReadQualityScope(ctx, dobotaiops.RecordQuery{Window: dobotaiops.Window{TenantKey: tenant, Start: time.Now().Add(-2 * time.Hour), End: time.Now()}, Limit: 100}) + if e != nil || len(scope.Keys) != 1 || !scope.Keys[0].Marker { + t.Fatal("local metadata still requires SELECT on large payload instead of committed quality evidence", e) + } +} + +func TestLocalMirrorStoredQualityUsesReadOnlyBatchedProjection(t *testing.T) { + p := mirrorFixture(t) + ctx := context.Background() + tenant := "fixture-read-stored" + record := dobotaiops.Record{ID: "stored-one", Question: "fixture question", Answer: "暂无已发布", QualityObservedAt: time.Now()} + views, e := qaopsquality.NewStore(p, tenant).Observe(ctx, []dobotaiops.Record{record}) + if e != nil { + t.Fatal(e) + } + hash := views[record.ID].InputHash + config := p.Config().Copy() + config.ConnConfig.RuntimeParams["default_transaction_read_only"] = "on" + readonly, e := pgxpool.NewWithConfig(ctx, config) + if e != nil { + t.Fatal(e) + } + defer readonly.Close() + store := NewStore(readonly, tenant) + keys := []dobotaiops.QualityKey{{ID: record.ID, InputHash: hash}} + got, e := store.ReadStoredQuality(ctx, keys) + if e != nil || len(got) != 1 || got[record.ID].InputHash != hash { + t.Fatal("synchronized quality must be readable without any write transaction", e) + } + keys[0].InputHash = strings.Repeat("f", 64) + if _, e = store.ReadStoredQuality(ctx, keys); !errors.Is(e, qaopsquality.ErrConflict) { + t.Fatal("stale mirror input paired with newer quality", e) + } + if _, e = NewStore(readonly, "other-tenant").ReadStoredQuality(ctx, []dobotaiops.QualityKey{{ID: record.ID, InputHash: hash}}); e == nil { + t.Fatal("stored quality crossed tenant scope") + } +} diff --git a/internal/qaopsmirror/sync.go b/internal/qaopsmirror/sync.go new file mode 100644 index 0000000..c7769ce --- /dev/null +++ b/internal/qaopsmirror/sync.go @@ -0,0 +1,120 @@ +package qaopsmirror + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "github.com/jackc/pgx/v5" + "knowledge-governance/internal/dobotaiops" + "knowledge-governance/internal/jobs" + pg "knowledge-governance/internal/platform/postgres" + "strings" + "time" + "unicode/utf8" +) + +const SyncJobPrefix = "qaops.records.sync." + +func (s *Store) EnqueueDue(ctx context.Context) (bool, error) { + if !s.valid() { + return false, dobotaiops.ErrUnavailable + } + // A final-attempt process can die before recordFailure. Rotate the persisted + // due time once, otherwise ExecuteOperation would replay its dead receipt forever. + if _, e := s.pool.Exec(ctx, `WITH latest AS (SELECT state,attempt,max_attempts,lease_until,updated_at FROM jobs WHERE type=$2 ORDER BY created_at DESC,id DESC LIMIT 1) UPDATE qa_ops_mirror_state s SET next_sync_at=clock_timestamp()+interval '5 minutes',last_error='mirror_sync_interrupted' FROM latest j WHERE s.tenant_key=$1 AND s.next_sync_at<=j.updated_at AND(j.state='failed' OR(j.state='running' AND j.attempt>=j.max_attempts AND j.lease_untilclock_timestamp()-interval '2 minutes') OR EXISTS(SELECT 1 FROM jobs WHERE type=$1 AND state='running' AND lease_until>clock_timestamp())` + +func validToken(v string, n int) bool { + return strings.TrimSpace(v) == v && len(v) > 0 && len(v) <= n && utf8.ValidString(v) && !strings.ContainsRune(v, 0) +} +func (s *Store) EnqueueSync(ctx context.Context, key, actor string) (SyncReceipt, bool, error) { + if !s.valid() { + return SyncReceipt{}, false, dobotaiops.ErrUnavailable + } + if !validToken(key, 128) || !validToken(actor, 256) { + return SyncReceipt{}, false, ErrInvalid + } + fp := sha256.Sum256([]byte(actor)) + now := time.Now() + raw, replay, err := pg.NewStore(s.pool).ExecuteOperation(ctx, s.SyncJobType(), key, hex.EncodeToString(fp[:]), now, func(tx pgx.Tx) (json.RawMessage, error) { + if _, e := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1,0))`, s.SyncJobType()); e != nil { + return nil, dobotaiops.ErrUnavailable + } + if _, e := tx.Exec(ctx, `UPDATE jobs SET state='failed',last_error='local mirror worker interrupted',last_error_code='mirror_sync_interrupted',lease_owner=NULL,lease_until=NULL,updated_at=now() WHERE type=$1 AND state='running' AND lease_until=max_attempts`, s.SyncJobType()); e != nil { + return nil, dobotaiops.ErrUnavailable + } + var active string + e := tx.QueryRow(ctx, `SELECT id FROM jobs WHERE type=$1 AND state IN ('pending','running','retry') ORDER BY created_at DESC,id DESC LIMIT 1`, s.SyncJobType()).Scan(&active) + if e == nil { + return json.Marshal(SyncReceipt{JobID: active, Deduplicated: true}) + } + if !errors.Is(e, pgx.ErrNoRows) { + return nil, dobotaiops.ErrUnavailable + } + var ready bool + if tx.QueryRow(ctx, workerReadySQL, s.SyncJobType()).Scan(&ready) != nil { + return nil, dobotaiops.ErrUnavailable + } + if !ready { + return nil, ErrWorkerUnavailable + } + if _, e = tx.Exec(ctx, `INSERT INTO qa_ops_mirror_state(tenant_key) VALUES($1) ON CONFLICT DO NOTHING`, s.tenant); e != nil { + return nil, dobotaiops.ErrUnavailable + } + h := sha256.Sum256([]byte(s.SyncJobType() + ":" + key)) + payload, _ := json.Marshal(map[string]string{"triggered_by": actor}) + job, e := pg.NewStore(tx).Enqueue(ctx, jobs.EnqueueRequest{ID: "job-" + hex.EncodeToString(h[:16]), Type: s.SyncJobType(), Payload: payload, AvailableAt: now, MaxAttempts: 3, IdempotencyKey: s.SyncJobType() + ":" + key}, now) + if e != nil || job == nil { + return nil, dobotaiops.ErrUnavailable + } + return json.Marshal(SyncReceipt{JobID: job.ID}) + }) + if errors.Is(err, pg.ErrOperationRequestConflict) { + return SyncReceipt{}, false, ErrConflict + } + if err != nil { + return SyncReceipt{}, false, err + } + var receipt SyncReceipt + if json.Unmarshal(raw, &receipt) != nil || receipt.JobID == "" { + return receipt, false, dobotaiops.ErrUnavailable + } + return receipt, replay, nil +} diff --git a/internal/qaopsmirror/sync_integration_test.go b/internal/qaopsmirror/sync_integration_test.go new file mode 100644 index 0000000..f2197a4 --- /dev/null +++ b/internal/qaopsmirror/sync_integration_test.go @@ -0,0 +1,71 @@ +package qaopsmirror + +import ( + "context" + "errors" + pg "knowledge-governance/internal/platform/postgres" + "sync" + "testing" + "time" +) + +func TestLocalMirrorDurableQueueCoalescesAndAudits(t *testing.T) { + p := mirrorFixture(t) + s := NewStore(p, "fixture-queue") + q := pg.NewStore(p) + ctx := context.Background() + if _, _, e := s.EnqueueSync(ctx, "fixture-no-worker", "fixture-admin"); !errors.Is(e, ErrWorkerUnavailable) { + t.Fatal("offline worker accepted work", e) + } + if _, e := q.Claim(ctx, "fixture-worker", []string{s.SyncJobType()}, time.Now(), time.Minute); e != nil { + t.Fatal(e) + } + var wg sync.WaitGroup + ids := make(chan string, 8) + errs := make(chan error, 8) + for range 8 { + wg.Add(1) + go func() { + defer wg.Done() + r, _, e := s.EnqueueSync(ctx, "fixture-same-action", "fixture-admin") + if e != nil { + errs <- e + return + } + ids <- r.JobID + }() + } + wg.Wait() + close(errs) + close(ids) + for e := range errs { + t.Fatal("durable enqueue failed", e) + } + id := "" + n := 0 + for got := range ids { + if got == "" || id != "" && got != id { + t.Fatal("same action created different effects") + } + id = got + n++ + } + if n != 8 { + t.Fatal("lost enqueue results") + } + again, _, e := s.EnqueueSync(ctx, "fixture-another-click", "fixture-admin") + if e != nil || !again.Deduplicated || again.JobID != id { + t.Fatal("in-flight synchronization did not coalesce", e) + } + if _, _, e = s.EnqueueSync(ctx, "fixture-same-action", "different-admin"); !errors.Is(e, ErrConflict) { + t.Fatal("replay changed actor", e) + } + var count int + if p.QueryRow(ctx, `SELECT count(*) FROM jobs WHERE type=$1`, s.SyncJobType()).Scan(&count) != nil || count != 1 { + t.Fatal("duplicate jobs") + } + job, e := q.Claim(ctx, "fixture-worker", []string{s.SyncJobType()}, time.Now(), time.Minute) + if e != nil || job == nil || job.ID != id { + t.Fatal("job not durable", e) + } +} diff --git a/internal/qaopsmirror/sync_run.go b/internal/qaopsmirror/sync_run.go new file mode 100644 index 0000000..4914daa --- /dev/null +++ b/internal/qaopsmirror/sync_run.go @@ -0,0 +1,283 @@ +package qaopsmirror + +import ( + "context" + "encoding/json" + "errors" + "github.com/jackc/pgx/v5" + "knowledge-governance/internal/dobotaiops" + "knowledge-governance/internal/jobs" + "knowledge-governance/internal/qaopsquality" + "regexp" + "sort" + "time" +) + +type Source interface { + ReadMirrorManifest(context.Context, string) (dobotaiops.MirrorManifest, error) + ReadMirrorRecords(context.Context, string, []string) ([]dobotaiops.MirrorRecord, error) +} +type SourceFactory func(context.Context) (Source, error) + +var hashPattern = regexp.MustCompile(`^[a-f0-9]{64}$`) + +func (s *Store) lease(ctx context.Context, q dobotaiops.RecordQuerier, j jobs.Job, lock bool) (bool, error) { + if j.Type != s.SyncJobType() || j.LeaseOwner == "" { + return false, ErrLease + } + sql := `SELECT payload ? 'mirror_result' FROM jobs WHERE id=$1 AND type=$2 AND state='running' AND lease_owner=$3 AND attempt=$4 AND lease_until>clock_timestamp() AND EXISTS(SELECT 1 FROM operation_requests op WHERE op.operation_type=jobs.type AND op.result->>'job_id'=jobs.id)` + if lock { + sql += " FOR UPDATE" + } + var done bool + if q.QueryRow(ctx, sql, j.ID, s.SyncJobType(), j.LeaseOwner, j.Attempt).Scan(&done) != nil { + return false, ErrLease + } + return done, nil +} +func (s *Store) progress(ctx context.Context, j jobs.Job, loaded, expected, changed int) error { + p, _ := json.Marshal(map[string]int{"loaded": loaded, "expected": expected, "changed": changed}) + tag, e := s.pool.Exec(ctx, `UPDATE jobs SET payload=jsonb_set(payload,'{mirror_progress}',$5::jsonb),updated_at=now() WHERE id=$1 AND type=$2 AND lease_owner=$3 AND attempt=$4 AND state='running' AND lease_until>clock_timestamp()`, j.ID, s.SyncJobType(), j.LeaseOwner, j.Attempt, p) + if e != nil || tag.RowsAffected() != 1 { + return ErrLease + } + return nil +} +func (s *Store) recordFailure(j jobs.Job, cause error) { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + code := "mirror_source_unavailable" + blocked := errors.Is(cause, dobotaiops.ErrNotReadOnly) + if blocked { + code = "mirror_source_authorization_failed" + } + _, _ = s.pool.Exec(ctx, `UPDATE qa_ops_mirror_state SET last_error=$2,blocked=blocked OR $3,next_sync_at=clock_timestamp()+interval '5 minutes' WHERE tenant_key=$1 AND EXISTS(SELECT 1 FROM jobs WHERE id=$4 AND type=$5 AND state='running' AND lease_owner=$6 AND attempt=$7 AND lease_until>clock_timestamp())`, s.tenant, code, blocked, j.ID, s.SyncJobType(), j.LeaseOwner, j.Attempt) +} +func (s *Store) HandleSync(ctx context.Context, j jobs.Job, factory SourceFactory) (err error) { + if !s.valid() || factory == nil { + return ErrSync + } + done, e := s.lease(ctx, poolQuery{s.pool}, j, false) + if e != nil { + return e + } + if done { + return nil + } + defer func() { + if err != nil && !errors.Is(err, ErrLease) { + s.recordFailure(j, err) + if errors.Is(err, dobotaiops.ErrNotReadOnly) { + err = jobs.Permanent("mirror_source_authorization_failed", ErrSync) + } else { + err = ErrSync + } + } + }() + source, e := factory(ctx) + if e != nil { + return e + } + if closer, ok := source.(interface{ Close() }); ok { + defer closer.Close() + } + known := map[string]string{} + rows, e := s.pool.Query(ctx, `SELECT id,fingerprint FROM qa_ops_mirror_records WHERE tenant_key=$1`, s.tenant) + if e != nil { + return dobotaiops.ErrUnavailable + } + for rows.Next() { + var id, fp string + if rows.Scan(&id, &fp) != nil { + rows.Close() + return dobotaiops.ErrUnavailable + } + known[id] = fp + } + e = rows.Err() + rows.Close() + if e != nil { + return dobotaiops.ErrUnavailable + } + manifest, e := source.ReadMirrorManifest(ctx, s.tenant) + if e != nil { + return e + } + if manifest.AsOf.IsZero() || manifest.AsOf.After(time.Now().Add(5*time.Minute)) || len(manifest.Keys) > dobotaiops.MaxMirrorRecords { + return ErrSync + } + // A sudden completely empty scope is not accepted as authority to erase a populated local history. + if len(manifest.Keys) == 0 && len(known) > 0 { + return ErrSync + } + keys := map[string]string{} + changed := []string{} + ids := make([]string, 0, len(manifest.Keys)) + for _, k := range manifest.Keys { + if !validToken(k.ID, 256) || !hashPattern.MatchString(k.Fingerprint) || keys[k.ID] != "" { + return ErrSync + } + keys[k.ID] = k.Fingerprint + ids = append(ids, k.ID) + if known[k.ID] != k.Fingerprint { + changed = append(changed, k.ID) + } + } + sort.Strings(changed) + loaded := len(ids) - len(changed) + if e = s.progress(ctx, j, loaded, len(ids), 0); e != nil { + return e + } + collected := make([]dobotaiops.MirrorRecord, 0, len(changed)) + totalBytes := 0 + for start := 0; start < len(changed); start += 100 { + if _, e = s.lease(ctx, poolQuery{s.pool}, j, false); e != nil { + return e + } + batch := changed[start:min(start+100, len(changed))] + got, e := source.ReadMirrorRecords(ctx, s.tenant, batch) + if e != nil { + return e + } + if len(got) != len(batch) { + return ErrSync + } + expected := map[string]bool{} + for _, id := range batch { + expected[id] = true + } + for _, row := range got { + if !expected[row.Key.ID] || !hashPattern.MatchString(row.Key.Fingerprint) { + return ErrSync + } + delete(expected, row.Key.ID) + record, e := dobotaiops.DecodeMirrorPayload(string(row.Payload)) + if e != nil || record.ID != row.Key.ID || !hashPattern.MatchString(record.QualityInputHash) || record.QualityObservedAt.IsZero() || record.AskedAt.IsZero() || len(row.Payload) > 8<<20 { + return ErrSync + } + totalBytes += len(row.Payload) + if totalBytes > 256<<20 { + return ErrSync + } + // A row can complete between manifest and payload reads. Keep that newer full + // row, never store an old manifest hash against a different current payload. + keys[row.Key.ID] = row.Key.Fingerprint + if record.QualityObservedAt.After(manifest.AsOf) { + manifest.AsOf = record.QualityObservedAt + } + collected = append(collected, row) + } + loaded += len(got) + if e = s.progress(ctx, j, loaded, len(ids), len(collected)); e != nil { + return e + } + } + return s.activate(ctx, j, manifest, ids, known, collected) +} +func (s *Store) activate(ctx context.Context, j jobs.Job, m dobotaiops.MirrorManifest, ids []string, known map[string]string, rows []dobotaiops.MirrorRecord) error { + tx, e := s.pool.Begin(ctx) + if e != nil { + return dobotaiops.ErrUnavailable + } + defer func() { _ = tx.Rollback(context.Background()) }() + if _, e = tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1,0))`, s.SyncJobType()); e != nil { + return dobotaiops.ErrUnavailable + } + done, e := s.lease(ctx, txQuery{tx}, j, true) + if e != nil { + return e + } + if done { + return nil + } + quality := qaopsquality.NewStore(s.pool, s.tenant) + for start := 0; start < len(rows); start += 100 { + batch := rows[start:min(start+100, len(rows))] + records := make([]dobotaiops.Record, 0, len(batch)) + for _, row := range batch { + rec, e := dobotaiops.DecodeMirrorPayload(string(row.Payload)) + if e != nil { + return ErrSync + } + records = append(records, rec) + identity, _ := json.Marshal(rec.Identity) + if _, e = tx.Exec(ctx, `INSERT INTO qa_ops_mirror_records(tenant_key,id,fingerprint,asked_at,question,status,identity,quality_hash,payload) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9) ON CONFLICT(tenant_key,id) DO UPDATE SET fingerprint=EXCLUDED.fingerprint,asked_at=EXCLUDED.asked_at,question=EXCLUDED.question,status=EXCLUDED.status,identity=EXCLUDED.identity,quality_hash=EXCLUDED.quality_hash,payload=EXCLUDED.payload`, s.tenant, rec.ID, row.Key.Fingerprint, rec.AskedAt, rec.Question, rec.Status, identity, rec.QualityInputHash, row.Payload); e != nil { + return dobotaiops.ErrUnavailable + } + } + if e = quality.ObserveRecordsInTx(ctx, tx, records); e != nil { + return e + } + } + deleted, e := tx.Exec(ctx, `DELETE FROM qa_ops_mirror_records WHERE tenant_key=$1 AND NOT(id=ANY($2::text[]))`, s.tenant, ids) + if e != nil { + return dobotaiops.ErrUnavailable + } + changed := len(rows) > 0 || deleted.RowsAffected() > 0 + var revision int64 + var wasReady bool + if tx.QueryRow(ctx, `SELECT revision,ready FROM qa_ops_mirror_state WHERE tenant_key=$1 FOR UPDATE`, s.tenant).Scan(&revision, &wasReady) != nil { + return dobotaiops.ErrUnavailable + } + if changed || !wasReady { + revision++ + } + safety, _ := json.Marshal(m.Safety) + if _, e = tx.Exec(ctx, `UPDATE qa_ops_mirror_state SET ready=true,blocked=false,revision=$2,record_count=$3,source_observed_at=$4,synced_at=clock_timestamp(),next_sync_at=clock_timestamp()+interval '5 minutes',last_error='',source_safety=$5 WHERE tenant_key=$1`, s.tenant, revision, len(ids), m.AsOf, safety); e != nil { + return dobotaiops.ErrUnavailable + } + result, _ := json.Marshal(map[string]any{"records": len(ids), "changed": len(rows), "deleted": deleted.RowsAffected(), "revision": revision}) + tag, e := tx.Exec(ctx, `UPDATE jobs SET payload=jsonb_set(payload,'{mirror_result}',$5::jsonb),updated_at=now() WHERE id=$1 AND type=$2 AND lease_owner=$3 AND attempt=$4 AND state='running' AND lease_until>clock_timestamp()`, j.ID, s.SyncJobType(), j.LeaseOwner, j.Attempt, result) + if e != nil || tag.RowsAffected() != 1 { + return ErrLease + } + if tx.Commit(ctx) != nil { + return dobotaiops.ErrUnavailable + } + return nil +} +func (s *Store) GetSyncStatus(ctx context.Context) (Status, error) { + if !s.valid() { + return Status{}, dobotaiops.ErrUnavailable + } + state, e := s.readState(ctx, poolQuery{s.pool}) + if e != nil { + return state, e + } + if s.pool.QueryRow(ctx, workerReadySQL, s.SyncJobType()).Scan(&state.WorkerReady) != nil { + return Status{}, dobotaiops.ErrUnavailable + } + var j SyncJob + var payload []byte + var expired bool + var attempt, maxAttempts int + e = s.pool.QueryRow(ctx, `SELECT id,state,payload,COALESCE(last_error_code,''),COALESCE(state='running' AND lease_until= maxAttempts { + j.State = "failed" + } + j.ErrorCode = "mirror_sync_interrupted" + } + state.Job = &j + return state, nil +} diff --git a/internal/qaopsmirror/sync_run_integration_test.go b/internal/qaopsmirror/sync_run_integration_test.go new file mode 100644 index 0000000..de74cb7 --- /dev/null +++ b/internal/qaopsmirror/sync_run_integration_test.go @@ -0,0 +1,143 @@ +package qaopsmirror + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "knowledge-governance/internal/dobotaiops" + "knowledge-governance/internal/jobs" + pg "knowledge-governance/internal/platform/postgres" + "testing" + "time" +) + +type fixtureSource struct { + rows []dobotaiops.MirrorRecord + calls int + payloadCalls int + err error + payloadError error + failAfter int + hook func() +} + +func (f *fixtureSource) ReadMirrorManifest(context.Context, string) (dobotaiops.MirrorManifest, error) { + f.calls++ + m := dobotaiops.MirrorManifest{AsOf: time.Now().UTC(), Safety: dobotaiops.SourceSafety{SSL: true, TablesChecked: 5, ConnectionLimit: 1}} + for _, r := range f.rows { + m.Keys = append(m.Keys, r.Key) + } + return m, f.err +} +func (f *fixtureSource) ReadMirrorRecords(_ context.Context, _ string, ids []string) ([]dobotaiops.MirrorRecord, error) { + f.payloadCalls++ + if f.payloadError != nil && f.payloadCalls >= f.failAfter { + return nil, f.payloadError + } + out := []dobotaiops.MirrorRecord{} + for _, id := range ids { + for _, r := range f.rows { + if r.Key.ID == id { + out = append(out, r) + } + } + } + if f.hook != nil { + f.hook() + } + return out, f.err +} +func fixtureEntry(id, answer string) dobotaiops.MirrorRecord { + qh := sha256.Sum256([]byte(id + answer)) + fp := sha256.Sum256([]byte("source:" + id + answer)) + now := time.Now().UTC() + p, _ := json.Marshal(map[string]any{"id": id, "status": "succeeded", "question": "fixture question", "answer": answer, "asked_at": now.Add(-time.Hour), "time_source": "turn.created_at", "asker": "", "trigger_person": "", "department": "", "region": "", "references": []any{}, "media": []any{}, "source_coverage": map[string]string{"verdict": "covered", "reason_code": "", "evaluator_version": "v5"}, "feedback": map[string]any{"asker_value": "", "up": 0, "down": 0}, "_identity": map[string]string{"actor_id": "a", "staff_id": "s", "union_id": ""}, "_quality_hash": hex.EncodeToString(qh[:]), "_quality_observed_at": now}) + return dobotaiops.MirrorRecord{Key: dobotaiops.MirrorKey{ID: id, Fingerprint: hex.EncodeToString(fp[:])}, Payload: p} +} +func claimMirrorJob(t *testing.T, s *Store, q *pg.Store, key string) *jobs.Job { + t.Helper() + ctx := context.Background() + if _, e := q.Claim(ctx, "fixture-sync-worker", []string{s.SyncJobType()}, time.Now(), time.Minute); e != nil { + t.Fatal(e) + } + _, _, e := s.EnqueueSync(ctx, key, "fixture-admin") + if e != nil { + t.Fatal(e) + } + j, e := q.Claim(ctx, "fixture-sync-worker", []string{s.SyncJobType()}, time.Now(), time.Minute) + if e != nil || j == nil { + t.Fatal("missing durable sync job", e) + } + return j +} +func TestLocalMirrorPeriodicSyncIsDurableAndDueOnly(t *testing.T) { + p := mirrorFixture(t) + s := NewStore(p, "fixture-periodic") + q := pg.NewStore(p) + ctx := context.Background() + if _, e := q.Claim(ctx, "fixture-periodic-worker", []string{s.SyncJobType()}, time.Now(), time.Minute); e != nil { + t.Fatal(e) + } + if enqueued, e := s.EnqueueDue(ctx); e != nil || !enqueued { + t.Fatal("first automatic sync was not scheduled", e) + } + if _, e := s.EnqueueDue(ctx); e != nil { + t.Fatal(e) + } + var count int + if p.QueryRow(ctx, `SELECT count(*) FROM jobs WHERE type=$1`, s.SyncJobType()).Scan(&count) != nil || count != 1 { + t.Fatal("scheduler duplicated active job") + } + job, e := q.Claim(ctx, "fixture-periodic-worker", []string{s.SyncJobType()}, time.Now(), time.Minute) + if e != nil || job == nil { + t.Fatal(e) + } + source := &fixtureSource{rows: []dobotaiops.MirrorRecord{fixtureEntry("auto-one", "automatic")}} + if e = s.HandleSync(ctx, *job, func(context.Context) (Source, error) { return source, nil }); e != nil { + t.Fatal(e) + } + if e = q.Complete(ctx, job.ID, job.LeaseOwner, time.Now()); e != nil { + t.Fatal(e) + } + if enqueued, e := s.EnqueueDue(ctx); e != nil || enqueued { + t.Fatal("automatic sync ignored the persisted five-minute interval", e) + } + if _, e = p.Exec(ctx, `UPDATE qa_ops_mirror_state SET next_sync_at=now()-interval '1 second' WHERE tenant_key=$1`, s.tenant); e != nil { + t.Fatal(e) + } + if enqueued, e := s.EnqueueDue(ctx); e != nil || !enqueued { + t.Fatal("due sync did not resume", e) + } +} +func TestLocalMirrorSyncActivatesAndCheckpoints(t *testing.T) { + p := mirrorFixture(t) + s := NewStore(p, "fixture-sync") + q := pg.NewStore(p) + ctx := context.Background() + job := claimMirrorJob(t, s, q, "fixture-first-sync") + source := &fixtureSource{rows: []dobotaiops.MirrorRecord{fixtureEntry("one", "# first answer")}} + factory := func(context.Context) (Source, error) { return source, nil } + if e := s.HandleSync(ctx, *job, factory); e != nil { + t.Fatal("first full sync did not activate", e) + } + state, e := s.GetSyncStatus(ctx) + if e != nil || !state.Ready || state.Records != 1 || state.Revision != 1 || state.LastSuccessAt == nil || state.NextSyncAt == nil || state.NextSyncAt.Sub(*state.LastSuccessAt) < Interval-time.Second { + t.Fatal("incomplete local sync state", e, state) + } + record, e := s.GetRecord(ctx, s.tenant, "one") + if e != nil || record.Answer != "# first answer" { + t.Fatal("committed local answer missing", e) + } + var inputs int + if p.QueryRow(ctx, `SELECT count(*) FROM qa_ops_quality_inputs WHERE tenant_key=$1 AND payload IS NOT NULL`, s.tenant).Scan(&inputs) != nil || inputs != 1 { + t.Fatal("quality evidence did not commit with mirror") + } + calls := source.calls + if e = s.HandleSync(ctx, *job, factory); e != nil || source.calls != calls { + t.Fatal("restart after commit fetched source twice", e) + } + if e = q.Complete(ctx, job.ID, job.LeaseOwner, time.Now()); e != nil { + t.Fatal(e) + } +} diff --git a/internal/qaopsquality/read_views.go b/internal/qaopsquality/read_views.go new file mode 100644 index 0000000..72604dc --- /dev/null +++ b/internal/qaopsquality/read_views.go @@ -0,0 +1,62 @@ +package qaopsquality + +import ( + "context" + "encoding/json" + "knowledge-governance/internal/dobotaiops" +) + +// ReadViews projects quality already observed by mirror activation. It performs +// one SELECT, never inserts inputs/checks or advances an observation epoch. +func (s *Store) ReadViews(ctx context.Context, keys []dobotaiops.QualityKey) (map[string]View, error) { + if !s.available() { + return nil, ErrUnavailable + } + if len(keys) > 5000 { + return nil, ErrInvalid + } + expected := map[string]string{} + ids := make([]string, 0, len(keys)) + for _, k := range keys { + if !validID(k.ID) || !hashPattern.MatchString(k.InputHash) { + return nil, ErrInvalid + } + if old, exists := expected[k.ID]; exists { + if old != k.InputHash { + return nil, ErrInvalid + } + continue + } + expected[k.ID] = k.InputHash + ids = append(ids, k.ID) + } + out := map[string]View{} + if len(ids) == 0 { + return out, nil + } + rows, e := s.pool.Query(ctx, `SELECT h.record_id,h.input_hash,h.input_epoch,h.review_revision,`+checkObject+`::text,COALESCE((SELECT `+reviewObject+` FROM qa_ops_quality_reviews r WHERE r.tenant_key=h.tenant_key AND r.record_id=h.record_id AND r.revision=h.review_revision),'null'::jsonb)::text FROM qa_ops_quality_records h JOIN qa_ops_quality_inputs i ON i.tenant_key=h.tenant_key AND i.record_id=h.record_id AND i.input_epoch=h.input_epoch JOIN qa_ops_quality_checks c ON c.tenant_key=h.tenant_key AND c.record_id=h.record_id AND c.input_epoch=h.input_epoch AND c.rule_version=$3 WHERE h.tenant_key=$1 AND h.record_id=ANY($2::text[])`, s.tenant, ids, dobotaiops.CoverageRuleVersion) + if e != nil { + return nil, ErrUnavailable + } + defer rows.Close() + for rows.Next() { + var id, hash, checkRaw, reviewRaw string + var epoch, revision int64 + if rows.Scan(&id, &hash, &epoch, &revision, &checkRaw, &reviewRaw) != nil { + return nil, ErrUnavailable + } + if expected[id] != hash { + return nil, ErrConflict + } + var automatic Check + var review *Review + if json.Unmarshal([]byte(checkRaw), &automatic) != nil || json.Unmarshal([]byte(reviewRaw), &review) != nil { + return nil, ErrUnavailable + } + out[id] = Project(hash, epoch, revision, automatic, review) + } + if rows.Err() != nil || len(out) != len(ids) { + return nil, ErrUnavailable + } + return out, nil +} diff --git a/internal/qaopsquality/store.go b/internal/qaopsquality/store.go index 93f7138..18aff00 100644 --- a/internal/qaopsquality/store.go +++ b/internal/qaopsquality/store.go @@ -131,6 +131,34 @@ func (s *Store) view(ctx context.Context, tx pgx.Tx, id string) (View, error) { } return Project(hash, epoch, revision, automatic, review), nil } + +// ObserveRecordsInTx makes mirror activation and review invalidation one commit. +// Only the caller owns commit/rollback; no remote I/O occurs while these locks are held. +func (s *Store) ObserveRecordsInTx(ctx context.Context, tx pgx.Tx, records []dobotaiops.Record) error { + if !s.available() || tx == nil || len(records) > 100 { + return ErrInvalid + } + items := make([]Evidence, 0, len(records)) + size := 0 + for _, r := range records { + e, err := EvidenceFor(r) + if err != nil { + return err + } + size += len(e.Payload) + if size > 32<<20 { + return ErrInvalid + } + items = append(items, e) + } + sort.Slice(items, func(i, j int) bool { return items[i].Record.ID < items[j].Record.ID }) + for _, e := range items { + if err := s.observeLocked(ctx, tx, e); err != nil { + return err + } + } + return nil +} func (s *Store) Observe(ctx context.Context, records []dobotaiops.Record) (map[string]View, error) { if len(records) > 5000 { return nil, ErrInvalid diff --git a/scripts/test-scope.json b/scripts/test-scope.json index 5e4046b..50189f0 100644 --- a/scripts/test-scope.json +++ b/scripts/test-scope.json @@ -9,9 +9,22 @@ "⚠️ 文件缺失或解析不了 ⇒ 门禁红:范围声明不出来时,不许默认放行。" ], "optional": [ - {"package":"knowledge-governance/internal/qaopsquality","test":"TestQualityStoreImmutableHistoryReplayConcurrencyAndStaleness","reason":"仅在显式一次性 loopback kg_qaops_quality_test_ 库执行;默认 CI 不连接共享/生产库,隔离 PG 另行实跑"}, - {"package": "knowledge-governance/internal/dobotaiops", "test": "TestQARecordInternalIdentityAndWholeFilterKeys", "reason": "必须显式一次性隔离PG;验证真实只读SQL身份关联/计数及隐私,不连接生产;默认无库时明确跳过,另行本地补验"}, - {"package": "knowledge-governance/internal/dobotaiops", "test": "TestQAUsageGroupsCountEventsOnceOverTheFullFilter", "reason": "必须显式一次性隔离PG;验证真实只读SQL身份关联/计数及隐私,不连接生产;默认无库时明确跳过,另行本地补验"}, + {"package":"knowledge-governance/internal/qaopsmirror","reason":"本包所有集成用例只在显式一次性 loopback kg_qaops_mirror_test_ 库运行;默认 CI 不连共享或生产库;隔离 PG -race 另行实跑"}, + { + "package": "knowledge-governance/internal/qaopsquality", + "test": "TestQualityStoreImmutableHistoryReplayConcurrencyAndStaleness", + "reason": "仅在显式一次性 loopback kg_qaops_quality_test_ 库执行;默认 CI 不连接共享/生产库,隔离 PG 另行实跑" + }, + { + "package": "knowledge-governance/internal/dobotaiops", + "test": "TestQARecordInternalIdentityAndWholeFilterKeys", + "reason": "必须显式一次性隔离PG;验证真实只读SQL身份关联/计数及隐私,不连接生产;默认无库时明确跳过,另行本地补验" + }, + { + "package": "knowledge-governance/internal/dobotaiops", + "test": "TestQAUsageGroupsCountEventsOnceOverTheFullFilter", + "reason": "必须显式一次性隔离PG;验证真实只读SQL身份关联/计数及隐私,不连接生产;默认无库时明确跳过,另行本地补验" + }, { "package": "knowledge-governance/internal/qaopsorg", "test": "TestDirectorySyncQueueDurableReplayAndLeaseFencing", @@ -111,6 +124,31 @@ "package": "knowledge-governance/internal/versionhistory", "test": "TestLiveSyncAgainstRealRepository", "reason": "真实仓库的只读同步用例需要内部可达的 Gitea(**匿名**,不读凭据);无环境即跳过(补验路径:设 VERSION_HISTORY_LIVE_BASE_URL 后单跑这条)" + }, + { + "package": "knowledge-governance/internal/qaopsmirror", + "test": "TestLocalMirrorQueriesNeverRequireSource", + "reason": "仅显式一次性隔离PG验证本地副本/持久同步或专门只读源夹具;默认CI不连接共享与生产库,另行实际执行隔离验收" + }, + { + "package": "knowledge-governance/internal/qaopsmirror", + "test": "TestLocalMirrorDurableQueueCoalescesAndAudits", + "reason": "仅显式一次性隔离PG验证本地副本/持久同步或专门只读源夹具;默认CI不连接共享与生产库,另行实际执行隔离验收" + }, + { + "package": "knowledge-governance/internal/qaopsmirror", + "test": "TestLocalMirrorSyncActivatesAndCheckpoints", + "reason": "仅显式一次性隔离PG验证本地副本/持久同步或专门只读源夹具;默认CI不连接共享与生产库,另行实际执行隔离验收" + }, + { + "package": "knowledge-governance/internal/qaopsmirror", + "test": "TestLocalMirrorPeriodicSyncIsDurableAndDueOnly", + "reason": "仅显式一次性隔离PG验证本地副本/持久同步或专门只读源夹具;默认CI不连接共享与生产库,另行实际执行隔离验收" + }, + { + "package": "knowledge-governance/internal/dobotaiops", + "test": "TestMirrorSourceManifestDetectsUpdatesWithoutVolatileTimes", + "reason": "仅显式一次性隔离PG验证本地副本/持久同步或专门只读源夹具;默认CI不连接共享与生产库,另行实际执行隔离验收" } ] } diff --git a/web/package-lock.json b/web/package-lock.json index 95be8fc..b762d00 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -9,7 +9,9 @@ "version": "0.1.0", "dependencies": { "react": "19.1.1", - "react-dom": "19.1.1" + "react-dom": "19.1.1", + "react-markdown": "10.1.0", + "remark-gfm": "4.0.1" }, "devDependencies": { "@types/react": "19.1.10", @@ -1225,6 +1227,15 @@ "assertion-error": "^2.0.1" } }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -1236,14 +1247,45 @@ "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", - "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/hast": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz", + "integrity": "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", "license": "MIT" }, "node_modules/@types/react": { "version": "19.1.10", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.10.tgz", "integrity": "sha512-EhBeSYX0Y6ye8pNebpKrwFJq7BoQ8J5SO6NlvNwwHjSj6adXJViPQrKlsyPw7hLBLvckEMO1yxeGdR82YBBlDg==", - "dev": true, "license": "MIT", "dependencies": { "csstype": "^3.0.2" @@ -1259,6 +1301,18 @@ "@types/react": "^19.0.0" } }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.4.0.tgz", + "integrity": "sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==", + "license": "ISC" + }, "node_modules/@vitejs/plugin-react": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.0.1.tgz", @@ -1328,6 +1382,16 @@ "node": ">=12" } }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.11.20", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.20.tgz", @@ -1396,6 +1460,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/chai": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", @@ -1406,6 +1480,56 @@ "node": ">=18" } }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1417,14 +1541,12 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, "license": "MIT" }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -1438,6 +1560,41 @@ } } }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.420", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.420.tgz", @@ -1504,6 +1661,28 @@ "node": ">=6" } }, + "node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", @@ -1524,6 +1703,12 @@ "node": ">=12.0.0" } }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1567,6 +1752,118 @@ "node": ">=6.9.0" } }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -1600,6 +1897,16 @@ "node": ">=6" } }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -1620,11 +1927,876 @@ "@jridgewell/sourcemap-codec": "^1.6.0" } }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.3.tgz", + "integrity": "sha512-xPgpDNl0/OXHsI7TlaIs22lWnH3KlpvZdXG1ET/m/YT2Hhdkx8lJV4kLphE6l9simyM4KPDFBwas08BgEob6Jw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.1.0.tgz", + "integrity": "sha512-KRLgmPaP2H1ul2bpoJkOS+9pAcDAAj9Nu1mqLnh8NLeJG5tlvj42TH80PIGBpROWlRXLk0lDrWNuZCFy/tsB4A==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.1.tgz", + "integrity": "sha512-OuJHqvr455pwu2OaOrir7dbzqs4jlWKOtlu9L6GjcIjjQwNyw02VntQsr/Ek6/A13vgnXfUhBekWRUP1OkNivw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.2.0.tgz", + "integrity": "sha512-2Jn/ADrHFFyyniBMi0bncTbJqU78K3/TEYvzvwrWWzD9MYPUa/Gm4r3nOeKFQg/PDM5BsbDYdc8ackW4NTY6NA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.3.tgz", + "integrity": "sha512-oGYfQzHSG5dOMovQcJ3fyTmZlWAWpi0XA0sJwJs+i6OT88o1+Jtw/8z0CmdowSLxGhhFK89rQ/oXph/wN02PNw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-edit-map": "^1.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.4.tgz", + "integrity": "sha512-wxEeE8v8XVvOrxn1TZj74qYhAtTQsSqufHVS3uNVyT1MupXxhyaHk8/9xDNPh9BmL77QNDgWTkZ3RsYHB2ry7w==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.1.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-edit-map": "^1.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.3" + } + }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.2.tgz", + "integrity": "sha512-pRzm4kDTu0MjlmBkxmS9yYhw60nncfcEwu9NNdPFSQEFXS95ZKyIIyTSHu/o3ReBUrLKYEq+7YaXCRn/bPB4MA==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.1.0.tgz", + "integrity": "sha512-fS8hnLIjnjvdQIj39Geug8wWsR0HrYZ43KShKxNfwT7t2LOHo/LbWZEzEaSOjwtjdCsjoE6syHhonEzW0zv0+Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-edit-map": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/micromark-util-edit-map/-/micromark-util-edit-map-1.0.0.tgz", + "integrity": "sha512-Pa2ljlsEL6sVwFaYeyrOLSYbQt73JvGbPOYFq+9AElXiSnfI5Q4465RRZ1sHv7lDjDOnGRDDaqPXqClqWK/q1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.3.tgz", + "integrity": "sha512-oxB2Ik03hI0gv+VNn9tnh1t1YEe9MDPptViAEgfdf3YQHsn0pzGTgCdlSCJXcwhqm8phaEuM7zeEu3QQzVBrPg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/nanoid": { @@ -1670,6 +2842,31 @@ "node": ">=12.20.0" } }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1735,6 +2932,16 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/property-information": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", + "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/react": { "version": "19.1.1", "resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz", @@ -1756,6 +2963,33 @@ "react": "^19.1.1" } }, + "node_modules/react-markdown": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", + "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -1766,6 +3000,72 @@ "node": ">=0.10.0" } }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/rollup": { "version": "4.63.1", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.1.tgz", @@ -1838,6 +3138,16 @@ "node": ">=0.10.0" } }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/std-env": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", @@ -1845,6 +3155,38 @@ "dev": true, "license": "MIT" }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, "node_modules/tinybench": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.2.tgz", @@ -1882,6 +3224,26 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/typescript": { "version": "5.9.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", @@ -1896,6 +3258,93 @@ "node": ">=14.17" } }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/update-browserslist-db": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz", @@ -1927,6 +3376,34 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/vite": { "version": "7.3.7", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.7.tgz", @@ -2104,6 +3581,16 @@ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "dev": true, "license": "ISC" + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } } } } diff --git a/web/package.json b/web/package.json index a71af93..f99a47b 100644 --- a/web/package.json +++ b/web/package.json @@ -13,7 +13,9 @@ }, "dependencies": { "react": "19.1.1", - "react-dom": "19.1.1" + "react-dom": "19.1.1", + "react-markdown": "10.1.0", + "remark-gfm": "4.0.1" }, "devDependencies": { "@types/react": "19.1.10", diff --git a/web/src/QAAnswerMarkdown.test.tsx b/web/src/QAAnswerMarkdown.test.tsx new file mode 100644 index 0000000..9d8264b --- /dev/null +++ b/web/src/QAAnswerMarkdown.test.tsx @@ -0,0 +1,30 @@ +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, it, expect } from 'vitest' +import { QAAnswerMarkdown } from './QAAnswerMarkdown' +describe('AI answer Markdown preview', () => { + it('renders headings, tables, code, links and keeps original source', () => { + const answer = + '# 安装步骤\n\n- **先断电**\n- 检查接线\n\n| 参数 | 值 |\n| --- | --- |\n| 速度 | 50 |\n\n```python\nprint("ready")\n```\n\n[说明](https://example.invalid/guide)' + const html = renderToStaticMarkup() + expect(html).toContain('

安装步骤

') + expect(html).toContain('先断电') + expect(html).toContain('') + expect(html).toContain('language-python') + expect(html).toContain('href="https://example.invalid/guide"') + expect(html).toContain('noopener noreferrer') + expect(html).toContain('查看 Markdown 原文') + }) + it('never executes raw HTML, unsafe URL schemes or automatically loads external images', () => { + const answer = + '\n\n\n\n[unsafe](javascript:alert%281%29)\n\n![图片](https://images.invalid/private.png)' + const html = renderToStaticMarkup() + expect(html).not.toContain('