Răsfoiți Sursa

feat: add AI gateway, product knowledge, and competitor enrichment

- Server-side Fmode AI gateway with prompt-config store and live-test routes
- Product knowledge APIs (own/focus products, competitor material, audit)
- JD search adapter and local competitor enrichment script
- Action-item source integrity and idempotency migrations
- Update schema docs, README, and env example

Co-Authored-By: Claude <noreply@anthropic.com>
gangvy 1 lună în urmă
părinte
comite
c375c8cd3f
46 a modificat fișierele cu 6321 adăugiri și 146 ștergeri
  1. 7 1
      .env.example
  2. 50 6
      README.md
  3. 26 6
      docs/parse-rest-schema.md
  4. 55 0
      migrations/003_action_item_insight_source.sql
  5. 105 0
      migrations/004_action_item_source_integrity.sql
  6. 330 0
      migrations/005_insight_decision_action_idempotency.sql
  7. 261 0
      migrations/006_insight_action_guard_hardening.sql
  8. 2 0
      package.json
  9. 243 0
      scripts/enrich-local-competitors.ts
  10. 65 0
      scripts/seed-product-knowledge.ts
  11. 25 1
      scripts/verify-parse-rest.ts
  12. 16 0
      src/app.ts
  13. 16 0
      src/config/env.ts
  14. 101 37
      src/db/parse-rest.client.ts
  15. 111 3
      src/db/parse-rest.schema.ts
  16. 20 0
      src/local-app.ts
  17. 80 2
      src/local-server.ts
  18. 47 0
      src/modules/ai-gateway/client.ts
  19. 157 0
      src/modules/ai-gateway/default-prompt-configs.ts
  20. 95 0
      src/modules/ai-gateway/prompt-config.repository.ts
  21. 193 0
      src/modules/ai-gateway/routes.ts
  22. 125 0
      src/modules/domestic-voc/adapters/jd-search.adapter.ts
  23. 25 46
      src/modules/domestic-voc/services/parse-rest-dataset-import.service.ts
  24. 168 0
      src/modules/product-knowledge/local-product-knowledge.store.ts
  25. 170 0
      src/modules/product-knowledge/product-knowledge.store.ts
  26. 109 0
      src/modules/product-knowledge/routes.ts
  27. 359 2
      src/modules/saas-platform/domain.ts
  28. 149 2
      src/modules/saas-platform/local-platform.repository.ts
  29. 294 19
      src/modules/saas-platform/parse-rest-voc.repository.ts
  30. 374 14
      src/modules/saas-platform/postgres-platform.repository.ts
  31. 148 5
      src/modules/saas-platform/routes.ts
  32. 8 0
      src/server.ts
  33. 24 0
      src/types/domestic-dataset.ts
  34. 354 0
      test/action-item-source.repository.test.ts
  35. 183 0
      test/ai-gateway.test.ts
  36. 100 0
      test/ai-prompt-config.repository.test.ts
  37. 261 0
      test/analysis-run.test.ts
  38. 3 0
      test/env.test.ts
  39. 273 0
      test/insight-decision.repository.test.ts
  40. 572 0
      test/insight-decision.test.ts
  41. 41 0
      test/jd-search.adapter.test.ts
  42. 24 0
      test/local-app.test.ts
  43. 95 0
      test/parse-rest-client.test.ts
  44. 50 0
      test/parse-rest-dataset-import.test.ts
  45. 90 0
      test/product-knowledge.store.test.ts
  46. 317 2
      test/saas-platform.test.ts

+ 7 - 1
.env.example

@@ -3,6 +3,8 @@ STORAGE_DRIVER=parse_rest
 LOCAL_HOST=127.0.0.1
 LOCAL_PORT=4400
 LOCAL_WORKSPACE_ID=demashi
+LOCAL_KNOWLEDGE_PATH=logs/local-product-knowledge.json
+LOCAL_CORS_ORIGINS=http://127.0.0.1:4202,http://localhost:4202,http://127.0.0.1:4200,http://localhost:4200
 LOCAL_CORS_ORIGINS=http://localhost:4200,http://127.0.0.1:4200
 HOST=127.0.0.1
 PORT=4400
@@ -28,8 +30,12 @@ FMODE_BASE_URL=https://server.fmode.cn/api/voc-e-commerce
 FMODE_API_KEY=
 FMODE_TIMEOUT_MS=30000
 FMODE_RETRIES=2
+FMODE_AI_BASE_URL=https://api.fmode.cn
+FMODE_AI_TOKEN=
+FMODE_AI_MODEL=deepseek-v4-pro
+FMODE_AI_TIMEOUT_MS=120000
 SYNC_WORKER_ENABLED=true
 SYNC_WORKER_POLL_MS=2000
 SYNC_JOB_STALE_AFTER_MS=900000
 JD_REVIEW_MAX_PAGES=1
-CORS_ORIGINS=http://127.0.0.1:4300,http://localhost:4200
+CORS_ORIGINS=http://127.0.0.1:4202,http://localhost:4202,http://127.0.0.1:4200,http://localhost:4200

+ 50 - 6
README.md

@@ -4,7 +4,7 @@ Independent backend template for the domestic ecommerce VOC product. The first c
 
 ## Current state
 
-Completed through 2026-07-24:
+Completed through 2026-07-29:
 
 - Independent Git repository and Node.js 22 / TypeScript service baseline.
 - Independent PostgreSQL schema with workspace, source, product, metric, relation, review, import, and sync-job tables.
@@ -29,8 +29,12 @@ Completed through 2026-07-24:
 - Master-key-only `Voc*` class schemas, resumable bounded imports, direct count verification, and a Parse-backed sync worker.
 - Direct streaming import from the source Demashi workbook without a frontend-generated JSON intermediate.
 - Idempotent competitor detail backfill through the company ecommerce relay, with mapped placeholders preserved when a request fails.
+- Server-side Fmode AI gateway with bounded request validation, JSON/SSE passthrough, timeout handling, and no browser-visible token.
+- AI status and live-test endpoints plus a Parse REST-backed `DomesticAiPromptConfig` store for the reused prompt-management UI.
+- Brand/category competitor discovery through the company ecommerce relay, with strict brand relevance filtering and local enriched snapshot output.
+- Product knowledge APIs for own products, focus products, competitor material, editable metadata, and audit history.
 
-The current case resolves to 2,817 operating products, 37 mapped competitor products, 9,717 daily metrics, 40 relations, and 355 first-page competitor review samples. All 37 competitor product details and review requests were persisted through the company relay on 2026-07-24. The PostgreSQL importer may create internal `relation_stub` rows to preserve foreign keys; the Parse REST model stores denormalized relation identities and therefore does not create those extra catalog products.
+The current local demo case resolves to 2,817 operating products, 96 unique competitor products, 9,717 daily metrics, 138 relations, and 206 competitor review samples. Of the competitor catalog, 70 products have current market snapshots collected from 27 brand/category queries and the remaining products preserve workbook mapping placeholders. The PostgreSQL importer may create internal `relation_stub` rows to preserve foreign keys; the Parse REST model stores denormalized relation identities and therefore does not create those extra catalog products.
 
 The worker calls JD product-detail and review paths only through the company `/api/voc-e-commerce` gateway. It never sends a browser request to a supplier endpoint and never returns raw gateway errors or credentials.
 
@@ -46,15 +50,19 @@ Saas-voc frontend
       -> dedicated Parse application (production authentication)
       -> sync queue
           -> existing /api/voc-e-commerce gateway
+      -> AI gateway
+          -> Fmode AI chat completions
+          -> DomesticAiPromptConfig via Parse REST
 ```
 
-The browser only calls this service. `FMODE_API_KEY` is used in the server-side `Authorization` header and is never returned to the browser or included in a request URL.
+The browser only calls this service. `FMODE_API_KEY` and `FMODE_AI_TOKEN` are used only in server-side authorization headers and are never returned to the browser or included in a request URL.
 
 Registered JD gateway contracts:
 
 ```text
 GET /api/voc-e-commerce/jd/get-item-detail/v1?itemId={productId}
 GET /api/voc-e-commerce/jd/get-item-comments/v1?itemId={productId}&page={page}
+GET /api/voc-e-commerce/jd/search-item-list/v1?keyword={brand+category}
 ```
 
 The first worker run defaults to one review page. `JD_REVIEW_MAX_PAGES` can raise the bounded limit after quota and response validation.
@@ -65,6 +73,17 @@ Use Node.js 22.13 or newer within the Node 22 release line. The repository inten
 
 Required configuration is listed in `.env.example`. Empty secrets are rejected before the HTTP server starts. `STORAGE_DRIVER=parse_rest` requires the external Parse URL, application id, and master key but no direct database connection. `STORAGE_DRIVER=postgres` additionally requires `DATABASE_URL`, a maintenance key, and optionally a separate `MIGRATION_DATABASE_URL`. Never commit live credentials.
 
+AI analysis is configured independently with:
+
+```text
+FMODE_AI_BASE_URL=https://api.fmode.cn
+FMODE_AI_TOKEN=<process-only-token>
+FMODE_AI_MODEL=deepseek-v4-pro
+FMODE_AI_TIMEOUT_MS=120000
+```
+
+When the token is absent, the server still starts and `/api/ai/status` reports `configured: false`; completion and test calls return a controlled 503 response.
+
 ### Parse REST runtime
 
 Use this mode when PostgreSQL is not exposed and the project must connect through an existing Parse Server REST API:
@@ -83,7 +102,9 @@ npm run verify:parse-rest -- demashi jd
 npm run dev
 ```
 
-The bootstrap command creates or reconciles 14 isolated `Voc*` classes, seeds the workspace/member/source records, imports the normalized case in request-size-bounded batches, and is idempotent for the same source hash and verified counts. The workbook command performs the same bounded import directly from Excel using a streaming reader. Competitor sync creates all mapped competitor records first, then skips already completed details on later runs. The verification command checks the schema set, class-level permissions, workspace/import readiness, exact own/competitor/detail counts, and denial of app-id-only reads. See `docs/parse-rest-schema.md` for the class contract.
+The bootstrap command creates or reconciles 16 isolated `Voc*` classes, seeds the workspace/member/source records, imports the normalized case in request-size-bounded batches, and is idempotent for the same source hash and verified counts. The workbook command performs the same bounded import directly from Excel using a streaming reader. Competitor sync creates all mapped competitor records first, then skips already completed details on later runs. `npm run seed:knowledge:parse-rest -- demashi 8` initializes missing top-selling product knowledge without overwriting manual metadata. The verification command checks the schema set, class-level permissions, workspace/import readiness, exact own/competitor/detail counts, product knowledge, and denial of app-id-only reads. See `docs/parse-rest-schema.md` for the class contract.
+
+The REST client first writes the full schema contract. For managed Parse deployments that reject the `required` field attribute, it retries the schema write without that attribute while retaining application validation and master-only class permissions. Master-authenticated class and batch requests retry only an exact transient `403 unauthorized` response, with a strict attempt bound; ordinary permission failures and browser requests are never retried.
 
 ### Local frontend integration without a database
 
@@ -103,6 +124,14 @@ npm run start:local
 
 Local demo `sync` requests create queryable in-memory completion records that validate whether requested products exist in the packaged dataset. They do not collect external data or persist anything. Production collection and persistence remain in `npm run dev`.
 
+To regenerate the enriched local snapshot from workbook brand/category relations, configure the company gateway key in the backend process and run:
+
+```powershell
+npm run enrich:competitors:local
+```
+
+The command writes `logs/local-enriched-dataset.json`. Set `LOCAL_DATASET_PATH=logs/local-enriched-dataset.json` before starting local mode. Search results must pass the brand/category relevance filter; review failures leave an explicit partial enrichment status without discarding successfully collected product snapshots.
+
 Local mode injects the fixed `local-admin` owner. It is intentionally database-free and must not be internet-facing.
 Set `LOCAL_WORKSPACE_ID` when using a packaged dataset under a workspace other than `demashi`; omitted API workspace ids then resolve to that configured default.
 
@@ -194,8 +223,22 @@ GET|POST /api/saas/workspaces/{workspaceId}/analyses
 GET|POST|PATCH /api/saas/workspaces/{workspaceId}/actions[/actionId]
 GET|POST|PATCH /api/saas/workspaces/{workspaceId}/alerts[/alertId]
 GET /api/saas/workspaces/{workspaceId}/audit
+
+GET /api/ai/status
+POST /api/ai/test
+POST /api/ai/chat/completions
+GET /api/ai/prompts
+PUT /api/ai/prompts/{promptKey}
+
+GET /api/knowledge/products
+PUT /api/knowledge/products
+DELETE /api/knowledge/products/{productKey}
 ```
 
+`POST /api/ai/chat/completions` accepts a bounded OpenAI-compatible message payload and supports both JSON and SSE responses. Unknown fields are rejected, so a browser cannot supply a token or turn the endpoint into a generic proxy. Prompt configuration is exposed through the dedicated AI routes; the browser never connects to Parse with a master key.
+
+With Parse REST storage, startup reconciles 16 managed `Voc*` classes and idempotently seeds the 13 domestic AI prompt/model configurations for the default workspace. Existing prompt customizations and product knowledge metadata are preserved.
+
 List endpoints use opaque cursor pagination (`limit` plus optional `cursor`). Analysis creation returns a truthful `pending` record; an analysis worker is not included yet.
 
 An empty database returns a valid empty dataset. It does not invent reviews, ratings, sentiment, pain points, or AI output.
@@ -243,14 +286,15 @@ npm audit --omit=dev
 Current result:
 
 - TypeScript build: passed.
-- Unit, REST client, adapter, worker recovery, local-demo, authentication, RBAC, cursor, workflow, audit, and HTTP contract tests: 37 passed.
+- Unit, AI gateway, REST client, product knowledge, adapter, worker recovery, local-demo, authentication, RBAC, cursor, workflow, audit, and HTTP contract tests: 51 passed.
 - Production dependency audit: 0 critical, 0 high, 14 moderate.
 - Company gateway health: HTTP 200 on 2026-07-23.
 - Credentialed live JD product-detail request: passed through the company gateway for product `11266507445`; the adapter extracted the product id, title, and brand from the live double-`data` envelope.
 - Credentialed live JD review requests: all 37 mapped competitors returned usable first-page evidence; 355 sanitized reviews were persisted without nickname, avatar, GUID, or other reviewer identity fields.
 - Live credentials are absent from the repository, fixtures, logs, and Git history. No supplier endpoint is contacted directly.
-- Parse REST development verification: 14/14 `Voc*` schemas present, master-key-only access confirmed, 2,817 own products, 37 competitor products with 37 details ready, 9,717 metrics, 40 relations, and 355 competitor reviews.
+- Dedicated Parse REST development verification (2026-07-27): 16/16 `Voc*` schemas present, master-key-only access confirmed, 2,817 own products, 37 competitor products with 37 details ready, 9,717 metrics, 40 relations, 355 competitor reviews, and 8 initialized product knowledge records.
 - End-to-end local API mode: `/health`, SaaS context, full snapshot, frontend proxy, desktop navigation, and 390x844 responsive rendering passed against the Parse REST store.
+- End-to-end AI mode: status, live test, visual report, follow-up report, prompt loading, desktop rendering, and 390x844 responsive rendering passed through the local gateway with no browser console errors or warnings.
 - PostgreSQL integration: pending a newly provisioned database. The local Docker CLI is installed but its engine was unavailable on 2026-07-23; no existing database was contacted.
 
 See `TASKS.md` for the implementation sequence and acceptance boundary.

+ 26 - 6
docs/parse-rest-schema.md

@@ -15,19 +15,39 @@ The Parse REST storage driver uses isolated `Voc*` classes so it can coexist wit
 | `VocSyncJob` | Idempotent collection queue record | `publicId` and `idempotencyKey` |
 | `VocSyncJobEvent` | Sync progress and failure events | `publicId` |
 | `VocAnalysisRun` | Truthful pending/completed analysis lifecycle | `publicId` |
-| `VocActionItem` | Operational action workflow | `publicId` |
+| `VocInsightDecision` | Append-only human decision versions for an AI insight | `publicId`; source tuple plus `version` |
+| `VocActionItem` | Operational action workflow with decision provenance and retry idempotency | `publicId`; workspace plus `creationKey` |
 | `VocAlert` | Risk/data-quality alert workflow | `publicId` |
 | `VocAuditLog` | Workspace-scoped write audit trail | `publicId` |
+| `VocPromptConfig` | Workspace-scoped AI prompt, model, output-style, and revision metadata | `workspaceId + promptKey` in `naturalKey` |
+| `VocProductKnowledge` | Featured state, tags, notes, and ownership metadata kept separate from imported product facts | `workspaceId + productKey` in `naturalKey` |
 
-Parse does not provide the same relational constraints as the PostgreSQL `voc` schema. The application therefore validates enums and permissions with Zod/RBAC, uses deterministic natural keys for idempotency, denormalizes relation identities, and restricts the current Parse worker to a single process because claim-by-update is not a SQL row lock.
+Parse does not provide the same relational constraints as the PostgreSQL `voc` schema. The application therefore validates enums and permissions with Zod/RBAC, uses deterministic natural keys for idempotency, denormalizes relation identities, and restricts the current Parse worker to a single process because claim-by-update is not a SQL row lock. `VocInsightDecision` content is append-only; creating a new version marks the prior version non-current. `VocActionItem.creationKey` is checked before create, but Parse indexes are non-unique, so concurrent writers still require deployment-level serialization.
 
-The current Demashi acceptance totals are:
+The current Demashi acceptance totals before product-knowledge curation are:
 
 ```text
-VocProduct          2817
+VocWorkspace           1
+VocWorkspaceMember     1
+VocSourceConnection    1
+VocImportBatch         1
+VocProduct          2854
 VocDailyMetric      9717
 VocProductRelation    40
-VocReview              0
+VocReview            355
+VocPromptConfig       13
+VocProductKnowledge    0+
 ```
 
-Run `npm run verify:parse-rest -- demashi jd` after schema changes or imports. A zero review count is intentional until the company relay returns verified review evidence.
+Run `npm run verify:parse-rest -- demashi jd` after schema changes or imports.
+
+## Current boundary
+
+The 17 managed `Voc*` classes are the deployable minimum for the current product, not the final generic SaaS model. The current revision includes query-driven indexes, non-destructive natural-key imports, workspace-scoped AI prompt configuration, independently maintained product knowledge metadata, insight decision history, and action creation keys. Before adding more tenants or sustained collection volume, the next schema revision should cover:
+
+1. Immutable prompt revision history, analysis evidence, follow-up messages, model parameters, latency, and usage metadata.
+2. Store/channel-account and listing identities so a generic domestic SaaS can distinguish a product definition from the same listing sold by different stores or channels.
+3. Database-enforced uniqueness. Parse indexes in this deployment are non-unique, so deterministic `naturalKey` values and application-side upserts remain mandatory.
+4. Transactional imports and concurrent worker claims when sustained collection volume requires stronger guarantees than a single Parse worker.
+
+All 17 managed classes have query-driven indexes for their current list and lookup routes. The managed Parse deployment does not consistently preserve field-level `required` flags, so application validation remains mandatory. PostgreSQL migration `005_insight_decision_action_idempotency.sql` supplies database-enforced current-decision and action-creation-key uniqueness for concurrent production writers.

+ 55 - 0
migrations/003_action_item_insight_source.sql

@@ -0,0 +1,55 @@
+ALTER TABLE voc.analysis_run
+  DROP CONSTRAINT IF EXISTS analysis_run_analysis_type_check;
+
+ALTER TABLE voc.analysis_run
+  ADD CONSTRAINT analysis_run_analysis_type_check
+  CHECK (analysis_type IN ('voice', 'pain_point', 'feature', 'scenario', 'risk', 'report', 'voc_insight'));
+
+ALTER TABLE voc.action_item
+  ADD COLUMN IF NOT EXISTS source_insight_id text,
+  ADD COLUMN IF NOT EXISTS evidence_ids jsonb NOT NULL DEFAULT '[]'::jsonb,
+  ADD COLUMN IF NOT EXISTS validation_metric text NOT NULL DEFAULT '';
+
+ALTER TABLE voc.action_item
+  DROP CONSTRAINT IF EXISTS action_item_evidence_ids_check;
+
+ALTER TABLE voc.action_item
+  ADD CONSTRAINT action_item_evidence_ids_check
+  CHECK (jsonb_typeof(evidence_ids) = 'array' AND jsonb_array_length(evidence_ids) <= 100);
+
+CREATE INDEX IF NOT EXISTS action_item_workspace_source_insight_idx
+  ON voc.action_item (workspace_id, source_insight_id, id DESC)
+  WHERE source_insight_id IS NOT NULL;
+
+CREATE OR REPLACE FUNCTION voc.validate_action_item_source_analysis()
+RETURNS trigger
+LANGUAGE plpgsql
+AS $$
+BEGIN
+  IF NEW.source_analysis_id IS NULL THEN
+    RETURN NEW;
+  END IF;
+
+  PERFORM 1
+  FROM voc.analysis_run analysis
+  WHERE analysis.id = NEW.source_analysis_id
+    AND analysis.workspace_id = NEW.workspace_id
+    AND analysis.analysis_type = 'voc_insight'
+    AND analysis.status IN ('completed', 'partial');
+
+  IF NOT FOUND THEN
+    RAISE EXCEPTION USING
+      ERRCODE = '23514',
+      MESSAGE = 'action source analysis must be a completed or partial voc_insight run in the same workspace';
+  END IF;
+
+  RETURN NEW;
+END;
+$$;
+
+DROP TRIGGER IF EXISTS action_item_source_analysis_guard ON voc.action_item;
+
+CREATE TRIGGER action_item_source_analysis_guard
+BEFORE INSERT OR UPDATE OF source_analysis_id, workspace_id ON voc.action_item
+FOR EACH ROW
+EXECUTE FUNCTION voc.validate_action_item_source_analysis();

+ 105 - 0
migrations/004_action_item_source_integrity.sql

@@ -0,0 +1,105 @@
+CREATE OR REPLACE FUNCTION voc.validate_action_item_source_analysis()
+RETURNS trigger
+LANGUAGE plpgsql
+AS $$
+DECLARE
+  source_result jsonb;
+  matched_insight jsonb;
+  allowed_evidence_ids jsonb;
+BEGIN
+  IF NEW.source_analysis_id IS NULL THEN
+    IF NEW.source_insight_id IS NOT NULL THEN
+      RAISE EXCEPTION USING
+        ERRCODE = '23514',
+        MESSAGE = 'action source insight requires a source analysis';
+    END IF;
+    RETURN NEW;
+  END IF;
+
+  SELECT analysis.result
+  INTO source_result
+  FROM voc.analysis_run analysis
+  WHERE analysis.id = NEW.source_analysis_id
+    AND analysis.workspace_id = NEW.workspace_id
+    AND analysis.analysis_type = 'voc_insight'
+    AND analysis.status IN ('completed', 'partial');
+
+  IF NOT FOUND THEN
+    RAISE EXCEPTION USING
+      ERRCODE = '23514',
+      MESSAGE = 'action source analysis must be a completed or partial voc_insight run in the same workspace';
+  END IF;
+
+  IF NEW.source_insight_id IS NULL OR btrim(NEW.source_insight_id) = '' THEN
+    RAISE EXCEPTION USING
+      ERRCODE = '23514',
+      MESSAGE = 'action source insight is required';
+  END IF;
+
+  SELECT insight.value
+  INTO matched_insight
+  FROM jsonb_array_elements(
+    CASE
+      WHEN jsonb_typeof(source_result -> 'insights') = 'array' THEN source_result -> 'insights'
+      ELSE '[]'::jsonb
+    END
+  ) AS insight(value)
+  WHERE insight.value ->> 'id' = NEW.source_insight_id
+  LIMIT 1;
+
+  IF matched_insight IS NULL THEN
+    RAISE EXCEPTION USING
+      ERRCODE = '23514',
+      MESSAGE = 'action source insight does not exist in the source analysis result';
+  END IF;
+
+  IF jsonb_typeof(NEW.evidence_ids) IS DISTINCT FROM 'array' THEN
+    RAISE EXCEPTION USING
+      ERRCODE = '23514',
+      MESSAGE = 'action source evidence must be an array';
+  END IF;
+  IF jsonb_array_length(NEW.evidence_ids) = 0 THEN
+    RAISE EXCEPTION USING
+      ERRCODE = '23514',
+      MESSAGE = 'action source evidence is required';
+  END IF;
+  IF EXISTS (
+    SELECT 1
+    FROM jsonb_array_elements(NEW.evidence_ids) AS submitted(value)
+    WHERE jsonb_typeof(submitted.value) <> 'string'
+  ) THEN
+    RAISE EXCEPTION USING
+      ERRCODE = '23514',
+      MESSAGE = 'action source evidence IDs must be strings';
+  END IF;
+
+  allowed_evidence_ids := CASE
+    WHEN jsonb_typeof(matched_insight -> 'evidenceIds') = 'array' THEN matched_insight -> 'evidenceIds'
+    ELSE '[]'::jsonb
+  END;
+
+  IF EXISTS (
+    SELECT 1
+    FROM jsonb_array_elements_text(NEW.evidence_ids) AS submitted(id)
+    WHERE NOT EXISTS (
+      SELECT 1
+      FROM jsonb_array_elements(allowed_evidence_ids) AS allowed(value)
+      WHERE jsonb_typeof(allowed.value) = 'string'
+        AND allowed.value = to_jsonb(submitted.id)
+    )
+  ) THEN
+    RAISE EXCEPTION USING
+      ERRCODE = '23514',
+      MESSAGE = 'action source evidence must belong to the selected insight';
+  END IF;
+
+  RETURN NEW;
+END;
+$$;
+
+DROP TRIGGER IF EXISTS action_item_source_analysis_guard ON voc.action_item;
+
+CREATE TRIGGER action_item_source_analysis_guard
+BEFORE INSERT OR UPDATE OF source_analysis_id, source_insight_id, evidence_ids, workspace_id ON voc.action_item
+FOR EACH ROW
+EXECUTE FUNCTION voc.validate_action_item_source_analysis();

+ 330 - 0
migrations/005_insight_decision_action_idempotency.sql

@@ -0,0 +1,330 @@
+CREATE TABLE IF NOT EXISTS voc.insight_decision (
+  id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+  public_id text NOT NULL UNIQUE,
+  workspace_id bigint NOT NULL REFERENCES voc.workspace(id) ON DELETE CASCADE,
+  source_analysis_id bigint NOT NULL REFERENCES voc.analysis_run(id) ON DELETE RESTRICT,
+  source_insight_id text NOT NULL,
+  decision text NOT NULL CHECK (decision IN ('confirmed', 'rejected', 'needs_more_evidence')),
+  reviewed_evidence_ids jsonb NOT NULL DEFAULT '[]'::jsonb,
+  comment text NOT NULL DEFAULT '',
+  decided_by_external_id text NOT NULL,
+  decided_at timestamptz NOT NULL DEFAULT now(),
+  version integer NOT NULL CHECK (version > 0),
+  supersedes_id bigint REFERENCES voc.insight_decision(id) ON DELETE RESTRICT,
+  is_current boolean NOT NULL DEFAULT true,
+  created_at timestamptz NOT NULL DEFAULT now(),
+  updated_at timestamptz NOT NULL DEFAULT now(),
+  CONSTRAINT insight_decision_reviewed_evidence_ids_check CHECK (
+    jsonb_typeof(reviewed_evidence_ids) = 'array'
+    AND jsonb_array_length(reviewed_evidence_ids) <= 100
+  ),
+  CONSTRAINT insight_decision_comment_check CHECK (
+    decision = 'confirmed' OR btrim(comment) <> ''
+  ),
+  CONSTRAINT insight_decision_source_version_unique UNIQUE (
+    workspace_id,
+    source_analysis_id,
+    source_insight_id,
+    version
+  )
+);
+
+CREATE UNIQUE INDEX IF NOT EXISTS insight_decision_source_current_unique
+  ON voc.insight_decision (workspace_id, source_analysis_id, source_insight_id)
+  WHERE is_current;
+
+CREATE UNIQUE INDEX IF NOT EXISTS insight_decision_supersedes_unique
+  ON voc.insight_decision (supersedes_id)
+  WHERE supersedes_id IS NOT NULL;
+
+CREATE INDEX IF NOT EXISTS insight_decision_workspace_decided_idx
+  ON voc.insight_decision (workspace_id, decided_at DESC, id DESC);
+
+CREATE INDEX IF NOT EXISTS insight_decision_analysis_insight_version_idx
+  ON voc.insight_decision (source_analysis_id, source_insight_id, version DESC, id DESC);
+
+CREATE OR REPLACE FUNCTION voc.validate_insight_decision_insert()
+RETURNS trigger
+LANGUAGE plpgsql
+AS $$
+DECLARE
+  source_result jsonb;
+  matched_insight jsonb;
+  previous_decision voc.insight_decision%ROWTYPE;
+BEGIN
+  SELECT analysis.result
+  INTO source_result
+  FROM voc.analysis_run analysis
+  WHERE analysis.id = NEW.source_analysis_id
+    AND analysis.workspace_id = NEW.workspace_id
+    AND analysis.analysis_type = 'voc_insight'
+    AND analysis.status IN ('completed', 'partial');
+
+  IF NOT FOUND THEN
+    RAISE EXCEPTION USING
+      ERRCODE = '23514',
+      MESSAGE = 'insight decision source must be a completed or partial voc_insight run in the same workspace';
+  END IF;
+
+  SELECT insight.value
+  INTO matched_insight
+  FROM jsonb_array_elements(
+    CASE
+      WHEN jsonb_typeof(source_result -> 'insights') = 'array' THEN source_result -> 'insights'
+      ELSE '[]'::jsonb
+    END
+  ) AS insight(value)
+  WHERE insight.value ->> 'id' = NEW.source_insight_id
+  LIMIT 1;
+
+  IF matched_insight IS NULL THEN
+    RAISE EXCEPTION USING
+      ERRCODE = '23514',
+      MESSAGE = 'insight decision source insight does not exist in the source analysis result';
+  END IF;
+
+  IF jsonb_array_length(NEW.reviewed_evidence_ids) = 0 THEN
+    RAISE EXCEPTION USING
+      ERRCODE = '23514',
+      MESSAGE = 'at least one reviewed evidence ID is required';
+  END IF;
+
+  IF source_result ->> 'mode' = 'deterministic'
+    AND NEW.decision <> 'needs_more_evidence'
+  THEN
+    RAISE EXCEPTION USING
+      ERRCODE = '23514',
+      MESSAGE = 'deterministic insight results require a needs_more_evidence decision';
+  END IF;
+
+  IF EXISTS (
+    SELECT 1
+    FROM jsonb_array_elements(NEW.reviewed_evidence_ids) AS reviewed(value)
+    WHERE jsonb_typeof(reviewed.value) <> 'string'
+  ) THEN
+    RAISE EXCEPTION USING
+      ERRCODE = '23514',
+      MESSAGE = 'reviewed evidence IDs must be strings';
+  END IF;
+
+  IF EXISTS (
+    SELECT 1
+    FROM jsonb_array_elements_text(NEW.reviewed_evidence_ids) AS reviewed(id)
+    WHERE NOT EXISTS (
+      SELECT 1
+      FROM jsonb_array_elements(
+        CASE
+          WHEN jsonb_typeof(matched_insight -> 'evidenceIds') = 'array' THEN matched_insight -> 'evidenceIds'
+          ELSE '[]'::jsonb
+        END
+      ) AS allowed(value)
+      WHERE jsonb_typeof(allowed.value) = 'string'
+        AND allowed.value = to_jsonb(reviewed.id)
+    )
+  ) THEN
+    RAISE EXCEPTION USING
+      ERRCODE = '23514',
+      MESSAGE = 'reviewed evidence must belong to the selected insight';
+  END IF;
+
+  IF NEW.supersedes_id IS NULL THEN
+    IF NEW.version <> 1 THEN
+      RAISE EXCEPTION USING
+        ERRCODE = '23514',
+        MESSAGE = 'the first insight decision version must be 1';
+    END IF;
+  ELSE
+    SELECT * INTO previous_decision
+    FROM voc.insight_decision
+    WHERE id = NEW.supersedes_id;
+
+    IF NOT FOUND
+      OR previous_decision.workspace_id <> NEW.workspace_id
+      OR previous_decision.source_analysis_id <> NEW.source_analysis_id
+      OR previous_decision.source_insight_id <> NEW.source_insight_id
+      OR previous_decision.version + 1 <> NEW.version
+    THEN
+      RAISE EXCEPTION USING
+        ERRCODE = '23514',
+        MESSAGE = 'superseded insight decision must be the preceding version for the same source';
+    END IF;
+  END IF;
+
+  RETURN NEW;
+END;
+$$;
+
+DROP TRIGGER IF EXISTS insight_decision_insert_guard ON voc.insight_decision;
+
+CREATE TRIGGER insight_decision_insert_guard
+BEFORE INSERT ON voc.insight_decision
+FOR EACH ROW
+EXECUTE FUNCTION voc.validate_insight_decision_insert();
+
+CREATE OR REPLACE FUNCTION voc.enforce_insight_decision_append_only()
+RETURNS trigger
+LANGUAGE plpgsql
+AS $$
+BEGIN
+  IF OLD.workspace_id IS DISTINCT FROM NEW.workspace_id
+    OR OLD.source_analysis_id IS DISTINCT FROM NEW.source_analysis_id
+    OR OLD.source_insight_id IS DISTINCT FROM NEW.source_insight_id
+    OR OLD.decision IS DISTINCT FROM NEW.decision
+    OR OLD.reviewed_evidence_ids IS DISTINCT FROM NEW.reviewed_evidence_ids
+    OR OLD.comment IS DISTINCT FROM NEW.comment
+    OR OLD.decided_by_external_id IS DISTINCT FROM NEW.decided_by_external_id
+    OR OLD.decided_at IS DISTINCT FROM NEW.decided_at
+    OR OLD.version IS DISTINCT FROM NEW.version
+    OR OLD.supersedes_id IS DISTINCT FROM NEW.supersedes_id
+    OR OLD.created_at IS DISTINCT FROM NEW.created_at
+    OR NOT OLD.is_current
+    OR NEW.is_current
+  THEN
+    RAISE EXCEPTION USING
+      ERRCODE = '23514',
+      MESSAGE = 'insight decisions are append-only; only the current flag may be retired';
+  END IF;
+
+  RETURN NEW;
+END;
+$$;
+
+DROP TRIGGER IF EXISTS insight_decision_append_only_guard ON voc.insight_decision;
+
+CREATE TRIGGER insight_decision_append_only_guard
+BEFORE UPDATE ON voc.insight_decision
+FOR EACH ROW
+EXECUTE FUNCTION voc.enforce_insight_decision_append_only();
+
+ALTER TABLE voc.action_item
+  ADD COLUMN IF NOT EXISTS source_decision_id bigint,
+  ADD COLUMN IF NOT EXISTS source_kind text,
+  ADD COLUMN IF NOT EXISTS creation_key text;
+
+DO $$
+BEGIN
+  IF NOT EXISTS (
+    SELECT 1
+    FROM pg_constraint
+    WHERE conname = 'action_item_source_decision_fkey'
+      AND conrelid = 'voc.action_item'::regclass
+  ) THEN
+    ALTER TABLE voc.action_item
+      ADD CONSTRAINT action_item_source_decision_fkey
+      FOREIGN KEY (source_decision_id)
+      REFERENCES voc.insight_decision(id)
+      ON DELETE RESTRICT;
+  END IF;
+END;
+$$;
+
+UPDATE voc.action_item
+SET source_kind = CASE
+      WHEN source_analysis_id IS NOT NULL THEN 'insight'
+      ELSE 'rule_action'
+    END
+WHERE source_kind IS NULL;
+
+UPDATE voc.action_item
+SET creation_key = public_id
+WHERE creation_key IS NULL OR btrim(creation_key) = '';
+
+ALTER TABLE voc.action_item
+  ALTER COLUMN source_kind SET DEFAULT 'rule_action',
+  ALTER COLUMN source_kind SET NOT NULL,
+  ALTER COLUMN creation_key SET NOT NULL;
+
+ALTER TABLE voc.action_item
+  DROP CONSTRAINT IF EXISTS action_item_source_kind_check;
+
+ALTER TABLE voc.action_item
+  ADD CONSTRAINT action_item_source_kind_check
+  CHECK (source_kind IN ('insight', 'raw_feedback', 'rule_action'));
+
+ALTER TABLE voc.action_item
+  DROP CONSTRAINT IF EXISTS action_item_source_decision_kind_check;
+
+ALTER TABLE voc.action_item
+  ADD CONSTRAINT action_item_source_decision_kind_check
+  CHECK (source_decision_id IS NULL OR source_kind = 'insight');
+
+CREATE UNIQUE INDEX IF NOT EXISTS action_item_workspace_creation_key_unique
+  ON voc.action_item (workspace_id, creation_key);
+
+CREATE INDEX IF NOT EXISTS action_item_workspace_source_kind_idx
+  ON voc.action_item (workspace_id, source_kind, id DESC);
+
+CREATE INDEX IF NOT EXISTS action_item_source_decision_idx
+  ON voc.action_item (source_decision_id, id DESC)
+  WHERE source_decision_id IS NOT NULL;
+
+CREATE OR REPLACE FUNCTION voc.validate_action_item_source_decision()
+RETURNS trigger
+LANGUAGE plpgsql
+AS $$
+DECLARE
+  source_decision voc.insight_decision%ROWTYPE;
+BEGIN
+  IF NEW.source_decision_id IS NULL THEN
+    RETURN NEW;
+  END IF;
+
+  SELECT decision.*
+  INTO source_decision
+  FROM voc.insight_decision decision
+  WHERE decision.id = NEW.source_decision_id
+    AND decision.workspace_id = NEW.workspace_id
+    AND decision.source_analysis_id = NEW.source_analysis_id
+    AND decision.source_insight_id = NEW.source_insight_id;
+
+  IF NOT FOUND THEN
+    RAISE EXCEPTION USING
+      ERRCODE = '23514',
+      MESSAGE = 'action source decision must match the action workspace, analysis, and insight';
+  END IF;
+
+  IF NOT source_decision.is_current THEN
+    RAISE EXCEPTION USING
+      ERRCODE = '23514',
+      MESSAGE = 'action source decision must be the current decision version';
+  END IF;
+
+  IF source_decision.decision = 'rejected' THEN
+    RAISE EXCEPTION USING
+      ERRCODE = '23514',
+      MESSAGE = 'rejected insight decisions cannot create actions';
+  END IF;
+
+  IF source_decision.decision = 'needs_more_evidence' THEN
+    IF NEW.action_type <> 'data_quality' OR btrim(NEW.validation_metric) = '' THEN
+      RAISE EXCEPTION USING
+        ERRCODE = '23514',
+        MESSAGE = 'needs_more_evidence decisions require a data_quality action and validation metric';
+    END IF;
+  ELSIF NEW.action_type = 'data_quality' THEN
+    RAISE EXCEPTION USING
+      ERRCODE = '23514',
+      MESSAGE = 'confirmed insight decisions require a formal action type';
+  END IF;
+
+  IF EXISTS (
+    SELECT 1
+    FROM jsonb_array_elements_text(NEW.evidence_ids) AS evidence(id)
+    WHERE NOT (source_decision.reviewed_evidence_ids ? evidence.id)
+  ) THEN
+    RAISE EXCEPTION USING
+      ERRCODE = '23514',
+      MESSAGE = 'action evidence must be included in the reviewed decision evidence';
+  END IF;
+
+  RETURN NEW;
+END;
+$$;
+
+DROP TRIGGER IF EXISTS action_item_source_decision_guard ON voc.action_item;
+
+CREATE TRIGGER action_item_source_decision_guard
+BEFORE INSERT OR UPDATE OF source_decision_id, source_analysis_id, source_insight_id, workspace_id
+ON voc.action_item
+FOR EACH ROW
+EXECUTE FUNCTION voc.validate_action_item_source_decision();

+ 261 - 0
migrations/006_insight_action_guard_hardening.sql

@@ -0,0 +1,261 @@
+CREATE OR REPLACE FUNCTION voc.validate_insight_decision_insert()
+RETURNS trigger
+LANGUAGE plpgsql
+AS $$
+DECLARE
+  source_result jsonb;
+  matched_insight jsonb;
+  allowed_evidence_ids jsonb;
+  previous_decision voc.insight_decision%ROWTYPE;
+BEGIN
+  SELECT analysis.result
+  INTO source_result
+  FROM voc.analysis_run analysis
+  WHERE analysis.id = NEW.source_analysis_id
+    AND analysis.workspace_id = NEW.workspace_id
+    AND analysis.analysis_type = 'voc_insight'
+    AND analysis.status IN ('completed', 'partial');
+
+  IF NOT FOUND THEN
+    RAISE EXCEPTION USING
+      ERRCODE = '23514',
+      MESSAGE = 'insight decision source must be a completed or partial voc_insight run in the same workspace';
+  END IF;
+
+  SELECT insight.value
+  INTO matched_insight
+  FROM jsonb_array_elements(
+    CASE
+      WHEN jsonb_typeof(source_result -> 'insights') = 'array' THEN source_result -> 'insights'
+      ELSE '[]'::jsonb
+    END
+  ) AS insight(value)
+  WHERE insight.value ->> 'id' = NEW.source_insight_id
+  LIMIT 1;
+
+  IF matched_insight IS NULL THEN
+    RAISE EXCEPTION USING
+      ERRCODE = '23514',
+      MESSAGE = 'insight decision source insight does not exist in the source analysis result';
+  END IF;
+
+  IF jsonb_array_length(NEW.reviewed_evidence_ids) = 0 THEN
+    RAISE EXCEPTION USING
+      ERRCODE = '23514',
+      MESSAGE = 'at least one reviewed evidence ID is required';
+  END IF;
+
+  IF source_result ->> 'mode' = 'deterministic'
+    AND NEW.decision <> 'needs_more_evidence'
+  THEN
+    RAISE EXCEPTION USING
+      ERRCODE = '23514',
+      MESSAGE = 'deterministic insight results require a needs_more_evidence decision';
+  END IF;
+
+  IF EXISTS (
+    SELECT 1
+    FROM jsonb_array_elements(NEW.reviewed_evidence_ids) AS reviewed(value)
+    WHERE jsonb_typeof(reviewed.value) <> 'string'
+  ) THEN
+    RAISE EXCEPTION USING
+      ERRCODE = '23514',
+      MESSAGE = 'reviewed evidence IDs must be strings';
+  END IF;
+
+  allowed_evidence_ids := CASE
+    WHEN jsonb_typeof(matched_insight -> 'evidenceIds') = 'array' THEN matched_insight -> 'evidenceIds'
+    ELSE '[]'::jsonb
+  END;
+
+  IF EXISTS (
+    SELECT 1
+    FROM jsonb_array_elements(NEW.reviewed_evidence_ids) AS reviewed(value)
+    WHERE NOT EXISTS (
+      SELECT 1
+      FROM jsonb_array_elements(allowed_evidence_ids) AS allowed(value)
+      WHERE jsonb_typeof(allowed.value) = 'string'
+        AND allowed.value = reviewed.value
+    )
+  ) THEN
+    RAISE EXCEPTION USING
+      ERRCODE = '23514',
+      MESSAGE = 'reviewed evidence must belong to the selected insight';
+  END IF;
+
+  IF EXISTS (
+    SELECT 1
+    FROM jsonb_array_elements(allowed_evidence_ids) AS allowed(value)
+    WHERE jsonb_typeof(allowed.value) <> 'string'
+      OR NOT EXISTS (
+        SELECT 1
+        FROM jsonb_array_elements(NEW.reviewed_evidence_ids) AS reviewed(value)
+        WHERE reviewed.value = allowed.value
+      )
+  ) THEN
+    RAISE EXCEPTION USING
+      ERRCODE = '23514',
+      MESSAGE = 'reviewed evidence must cover every evidence ID in the selected insight';
+  END IF;
+
+  IF NEW.supersedes_id IS NULL THEN
+    IF NEW.version <> 1 THEN
+      RAISE EXCEPTION USING
+        ERRCODE = '23514',
+        MESSAGE = 'the first insight decision version must be 1';
+    END IF;
+  ELSE
+    SELECT * INTO previous_decision
+    FROM voc.insight_decision
+    WHERE id = NEW.supersedes_id;
+
+    IF NOT FOUND
+      OR previous_decision.workspace_id <> NEW.workspace_id
+      OR previous_decision.source_analysis_id <> NEW.source_analysis_id
+      OR previous_decision.source_insight_id <> NEW.source_insight_id
+      OR previous_decision.version + 1 <> NEW.version
+    THEN
+      RAISE EXCEPTION USING
+        ERRCODE = '23514',
+        MESSAGE = 'superseded insight decision must be the preceding version for the same source';
+    END IF;
+  END IF;
+
+  RETURN NEW;
+END;
+$$;
+
+ALTER TABLE voc.action_item
+  DROP CONSTRAINT IF EXISTS action_item_source_decision_kind_check;
+
+-- NOT VALID preserves legacy rows while enforcing the source combination on new writes.
+ALTER TABLE voc.action_item
+  ADD CONSTRAINT action_item_source_decision_kind_check
+  CHECK (
+    source_kind = 'insight'
+    OR (
+      source_analysis_id IS NULL
+      AND source_insight_id IS NULL
+      AND source_decision_id IS NULL
+    )
+  ) NOT VALID;
+
+CREATE OR REPLACE FUNCTION voc.validate_action_item_source_decision()
+RETURNS trigger
+LANGUAGE plpgsql
+AS $$
+DECLARE
+  source_decision voc.insight_decision%ROWTYPE;
+BEGIN
+  IF NEW.source_kind IS DISTINCT FROM 'insight' THEN
+    IF NEW.source_analysis_id IS NOT NULL
+      OR NEW.source_insight_id IS NOT NULL
+      OR NEW.source_decision_id IS NOT NULL
+    THEN
+      RAISE EXCEPTION USING
+        ERRCODE = '23514',
+        MESSAGE = 'non-insight actions cannot carry insight analysis, insight, or decision sources';
+    END IF;
+
+    RETURN NEW;
+  END IF;
+
+  IF NEW.source_analysis_id IS NULL
+    OR NEW.source_insight_id IS NULL
+    OR btrim(NEW.source_insight_id) = ''
+    OR NEW.source_decision_id IS NULL
+  THEN
+    RAISE EXCEPTION USING
+      ERRCODE = '23514',
+      MESSAGE = 'insight actions require analysis, insight, and decision sources';
+  END IF;
+
+  SELECT decision.*
+  INTO source_decision
+  FROM voc.insight_decision decision
+  WHERE decision.id = NEW.source_decision_id
+    AND decision.workspace_id = NEW.workspace_id
+    AND decision.source_analysis_id = NEW.source_analysis_id
+    AND decision.source_insight_id = NEW.source_insight_id;
+
+  IF NOT FOUND THEN
+    RAISE EXCEPTION USING
+      ERRCODE = '23514',
+      MESSAGE = 'action source decision must match the action workspace, analysis, and insight';
+  END IF;
+
+  IF NOT source_decision.is_current THEN
+    RAISE EXCEPTION USING
+      ERRCODE = '23514',
+      MESSAGE = 'action source decision must be the current decision version';
+  END IF;
+
+  IF source_decision.decision = 'rejected' THEN
+    RAISE EXCEPTION USING
+      ERRCODE = '23514',
+      MESSAGE = 'rejected insight decisions cannot create actions';
+  END IF;
+
+  IF source_decision.decision = 'needs_more_evidence' THEN
+    IF NEW.action_type <> 'data_quality' OR btrim(NEW.validation_metric) = '' THEN
+      RAISE EXCEPTION USING
+        ERRCODE = '23514',
+        MESSAGE = 'needs_more_evidence decisions require a data_quality action and validation metric';
+    END IF;
+  ELSIF NEW.action_type = 'data_quality' THEN
+    RAISE EXCEPTION USING
+      ERRCODE = '23514',
+      MESSAGE = 'confirmed insight decisions require a formal action type';
+  END IF;
+
+  IF jsonb_typeof(NEW.evidence_ids) IS DISTINCT FROM 'array'
+    OR jsonb_array_length(NEW.evidence_ids) = 0
+  THEN
+    RAISE EXCEPTION USING
+      ERRCODE = '23514',
+      MESSAGE = 'insight action evidence must be a non-empty array';
+  END IF;
+
+  IF EXISTS (
+    SELECT 1
+    FROM jsonb_array_elements(NEW.evidence_ids) AS evidence(value)
+    WHERE jsonb_typeof(evidence.value) <> 'string'
+  ) THEN
+    RAISE EXCEPTION USING
+      ERRCODE = '23514',
+      MESSAGE = 'insight action evidence IDs must be strings';
+  END IF;
+
+  IF EXISTS (
+    SELECT 1
+    FROM jsonb_array_elements(NEW.evidence_ids) AS evidence(value)
+    WHERE NOT EXISTS (
+      SELECT 1
+      FROM jsonb_array_elements(source_decision.reviewed_evidence_ids) AS reviewed(value)
+      WHERE reviewed.value = evidence.value
+    )
+  ) THEN
+    RAISE EXCEPTION USING
+      ERRCODE = '23514',
+      MESSAGE = 'action evidence must be included in the reviewed decision evidence';
+  END IF;
+
+  RETURN NEW;
+END;
+$$;
+
+DROP TRIGGER IF EXISTS action_item_source_decision_guard ON voc.action_item;
+
+CREATE TRIGGER action_item_source_decision_guard
+BEFORE INSERT OR UPDATE OF
+  workspace_id,
+  source_kind,
+  source_analysis_id,
+  source_insight_id,
+  source_decision_id,
+  action_type,
+  validation_metric,
+  evidence_ids
+ON voc.action_item
+FOR EACH ROW
+EXECUTE FUNCTION voc.validate_action_item_source_decision();

+ 2 - 0
package.json

@@ -18,6 +18,8 @@
     "verify:parse-rest": "tsx scripts/verify-parse-rest.ts",
     "import:workbook:parse-rest": "tsx scripts/import-workbook-parse-rest.ts",
     "sync:competitors:parse-rest": "tsx scripts/sync-parse-rest-competitors.ts",
+    "seed:knowledge:parse-rest": "tsx scripts/seed-product-knowledge.ts",
+    "enrich:competitors:local": "tsx scripts/enrich-local-competitors.ts",
     "import:dataset": "tsx scripts/import-dataset.ts",
     "test": "tsx --test test/**/*.test.ts",
     "test:coverage": "tsx --test --experimental-test-coverage test/**/*.test.ts",

+ 243 - 0
scripts/enrich-local-competitors.ts

@@ -0,0 +1,243 @@
+import 'dotenv/config';
+import { mkdir, readFile, writeFile } from 'node:fs/promises';
+import { dirname, resolve } from 'node:path';
+import type {
+  DomesticDataset,
+  DomesticProduct,
+  DomesticProductRelation,
+  DomesticReview,
+} from '../src/types/domestic-dataset.js';
+import { adaptJdReviewResponse, JD_PRODUCT_COMMENTS_PATH } from '../src/modules/domestic-voc/adapters/jd-review.adapter.js';
+import { adaptJdSearchResponse, JD_PRODUCT_SEARCH_PATH } from '../src/modules/domestic-voc/adapters/jd-search.adapter.js';
+import { FmodeVocEcommerceClient } from '../src/modules/domestic-voc/upstream/fmode-client.js';
+
+interface BrandCategoryPair {
+  key: string;
+  brand: string;
+  category: string;
+  baseRelations: DomesticProductRelation[];
+}
+
+interface SearchResult {
+  pair: BrandCategoryPair;
+  products: DomesticProduct[];
+  ok: boolean;
+}
+
+function defaultDatasetPath(): string {
+  return resolve(process.cwd(), '..', '..', 'Saas-voc', 'src', 'assets', 'data', 'demashi-summary.json');
+}
+
+function positiveInteger(value: string | undefined, fallback: number): number {
+  const parsed = Number(value);
+  return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
+}
+
+async function mapLimit<T, R>(items: T[], concurrency: number, worker: (item: T, index: number) => Promise<R>): Promise<R[]> {
+  const results = new Array<R>(items.length);
+  let cursor = 0;
+  async function run(): Promise<void> {
+    while (cursor < items.length) {
+      const index = cursor;
+      cursor += 1;
+      results[index] = await worker(items[index] as T, index);
+    }
+  }
+  await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, () => run()));
+  return results;
+}
+
+function buildPairs(relations: DomesticProductRelation[]): BrandCategoryPair[] {
+  const grouped = new Map<string, DomesticProductRelation[]>();
+  for (const relation of relations) {
+    if (!relation.competitorBrand || !relation.category) continue;
+    const key = `${relation.competitorBrand}\u0000${relation.category}`;
+    const rows = grouped.get(key) ?? [];
+    rows.push(relation);
+    grouped.set(key, rows);
+  }
+  return [...grouped.entries()]
+    .map(([key, rows]) => ({
+      key,
+      brand: rows[0]?.competitorBrand ?? '',
+      category: rows[0]?.category ?? '',
+      baseRelations: rows,
+    }))
+    .sort((left, right) => right.baseRelations.length - left.baseRelations.length || left.key.localeCompare(right.key, 'zh-CN'));
+}
+
+function relevanceScore(product: DomesticProduct, pair: BrandCategoryPair): number {
+  const normalize = (value: string) => value.toLowerCase().replace(/^pop/i, '').replace(/[^a-z0-9\u3400-\u9fff]/g, '');
+  const brand = normalize(pair.brand);
+  const title = normalize(product.title);
+  const shop = normalize(product.market?.shopName ?? '');
+  let score = 0;
+  if (brand && title.includes(brand)) score += 20;
+  if (brand && shop.includes(brand)) score += 10;
+  if (product.market?.monthSalesText) score += 4;
+  if (product.market?.salesText) score += 2;
+  if (product.market?.currentPrice) score += 1;
+  if (/德玛仕|demashi/i.test(product.title)) score -= 100;
+  return score;
+}
+
+function mergeProduct(existing: DomesticProduct | undefined, discovered: DomesticProduct): DomesticProduct {
+  if (!existing) return discovered;
+  return {
+    ...existing,
+    ...discovered,
+    productId: existing.productId,
+    productKey: existing.productKey,
+    asin: existing.asin || discovered.asin,
+    role: 'competitor',
+    relationCount: existing.relationCount,
+  };
+}
+
+async function main(): Promise<void> {
+  const baseUrl = process.env.FMODE_BASE_URL?.trim();
+  const apiKey = process.env.FMODE_API_KEY?.trim();
+  if (!baseUrl || !apiKey) throw new Error('Company ecommerce gateway configuration is missing');
+
+  const inputPath = resolve(process.env.COMPETITOR_BASE_DATASET_PATH || defaultDatasetPath());
+  const outputPath = resolve(process.env.LOCAL_ENRICHED_DATASET_PATH || resolve(process.cwd(), 'logs', 'local-enriched-dataset.json'));
+  const resultsPerPair = positiveInteger(process.env.COMPETITOR_RESULTS_PER_PAIR, 3);
+  const reviewProductsPerPair = positiveInteger(process.env.COMPETITOR_REVIEW_PRODUCTS_PER_PAIR, 1);
+  const concurrency = positiveInteger(process.env.COMPETITOR_ENRICH_CONCURRENCY, 3);
+  const dataset = JSON.parse(await readFile(inputPath, 'utf8')) as DomesticDataset;
+  dataset.reviews = Array.isArray(dataset.reviews) ? dataset.reviews : [];
+  const collectedAt = new Date().toISOString();
+  const baseRelations = dataset.relations.map((relation) => ({ ...relation, discoverySource: 'workbook' as const }));
+  const pairs = buildPairs(baseRelations);
+  const client = new FmodeVocEcommerceClient({
+    baseUrl,
+    apiKey,
+    timeoutMs: positiveInteger(process.env.FMODE_TIMEOUT_MS, 30_000),
+    retries: positiveInteger(process.env.FMODE_RETRIES, 2),
+  });
+
+  console.log(`[competitor-enrich] searching ${pairs.length} brand/category combinations`);
+  const searches = await mapLimit(pairs, concurrency, async (pair): Promise<SearchResult> => {
+    const keyword = `${pair.brand} ${pair.category}`;
+    try {
+      const response = await client.request<unknown>(JD_PRODUCT_SEARCH_PATH, { params: { keyword, page: 1 } });
+      const products = adaptJdSearchResponse(response, { brand: pair.brand, category: pair.category, keyword, collectedAt })
+        .sort((left, right) => relevanceScore(right, pair) - relevanceScore(left, pair))
+        .filter((product) => relevanceScore(product, pair) >= 10)
+        .slice(0, resultsPerPair);
+      console.log(`[competitor-enrich] ${pair.brand} / ${pair.category}: ${products.length} products`);
+      return { pair, products, ok: true };
+    } catch (error) {
+      const status = typeof error === 'object' && error && 'status' in error ? String(error.status ?? '') : '';
+      console.warn(`[competitor-enrich] ${pair.brand} / ${pair.category}: request failed${status ? ` (${status})` : ''}`);
+      return { pair, products: [], ok: false };
+    }
+  });
+
+  const productsByKey = new Map(dataset.products.map((product) => [product.productKey, product]));
+  const relationsByKey = new Map<string, DomesticProductRelation>(baseRelations.map((relation) => [relation.relationKey, relation]));
+  const discoveredProductKeys = new Set<string>();
+  for (const search of searches) {
+    for (const product of search.products) {
+      discoveredProductKeys.add(product.productKey);
+      productsByKey.set(product.productKey, mergeProduct(productsByKey.get(product.productKey), product));
+      const ownRelations = new Map(search.pair.baseRelations.map((relation) => [relation.ownProductKey, relation]));
+      for (const relation of ownRelations.values()) {
+        const relationKey = `${relation.ownProductKey}:${product.productKey}`;
+        if (relationsByKey.has(relationKey)) continue;
+        relationsByKey.set(relationKey, {
+          relationKey,
+          ownProductKey: relation.ownProductKey,
+          ownProductId: relation.ownProductId,
+          competitorProductKey: product.productKey,
+          competitorProductId: product.productId,
+          competitorBrand: search.pair.brand,
+          category: search.pair.category,
+          discoverySource: 'brand_category_search',
+          ...(product.market?.searchKeyword ? { searchKeyword: product.market.searchKeyword } : {}),
+          discoveredAt: collectedAt,
+        });
+      }
+    }
+  }
+
+  const reviewTargets = [...new Map(searches.flatMap((search) => search.products.slice(0, reviewProductsPerPair))
+    .map((product) => [product.productKey, product])).values()];
+  console.log(`[competitor-enrich] collecting reviews for ${reviewTargets.length} representative products`);
+  const reviewPages = await mapLimit(reviewTargets, concurrency, async (product) => {
+    try {
+      const response = await client.request<unknown>(JD_PRODUCT_COMMENTS_PATH, { params: { itemId: product.productId, page: 1 } });
+      const reviews = adaptJdReviewResponse(response, product.productId, 1).reviews;
+      console.log(`[competitor-enrich] ${product.productId}: ${reviews.length} reviews`);
+      return { product, reviews, ok: true };
+    } catch (error) {
+      const status = typeof error === 'object' && error && 'status' in error ? String(error.status ?? '') : '';
+      console.warn(`[competitor-enrich] ${product.productId}: review request failed${status ? ` (${status})` : ''}`);
+      return { product, reviews: [] as DomesticReview[], ok: false };
+    }
+  });
+
+  const reviewByKey = new Map(dataset.reviews.map((review) => [`${review.productId}:${review.reviewId}`, review]));
+  for (const page of reviewPages) {
+    for (const review of page.reviews) reviewByKey.set(`${review.productId}:${review.reviewId}`, review);
+  }
+  const relations = [...relationsByKey.values()];
+  const relationCounts = new Map<string, Set<string>>();
+  for (const relation of relations) {
+    const ownSet = relationCounts.get(relation.ownProductKey) ?? new Set<string>();
+    ownSet.add(relation.competitorProductKey);
+    relationCounts.set(relation.ownProductKey, ownSet);
+    const competitorSet = relationCounts.get(relation.competitorProductKey) ?? new Set<string>();
+    competitorSet.add(relation.ownProductKey);
+    relationCounts.set(relation.competitorProductKey, competitorSet);
+  }
+  const products = [...productsByKey.values()].map((product) => ({
+    ...product,
+    relationCount: relationCounts.get(product.productKey)?.size ?? product.relationCount,
+  }));
+  const relationsByOwnId = new Map<string, DomesticProductRelation[]>();
+  for (const relation of relations) {
+    const rows = relationsByOwnId.get(relation.ownProductId) ?? [];
+    rows.push(relation);
+    relationsByOwnId.set(relation.ownProductId, rows);
+  }
+  const mappingGroups = dataset.mappingGroups.map((group) => ({
+    ...group,
+    competitors: relationsByOwnId.get(group.ownProductId) ?? group.competitors,
+  }));
+  const reviews = [...reviewByKey.values()];
+  const successfulQueries = searches.filter((search) => search.ok).length;
+  const successfulReviewQueries = reviewPages.filter((page) => page.ok).length;
+  const output: DomesticDataset = {
+    ...dataset,
+    generatedAt: collectedAt,
+    products,
+    relations,
+    mappingGroups,
+    reviews,
+    summary: {
+      ...dataset.summary,
+      relations: relations.length,
+      uniqueCompetitorProducts: products.filter((product) => product.role === 'competitor').length,
+      reviewCount: reviews.length,
+    },
+    enrichment: {
+      status: successfulQueries === pairs.length && successfulReviewQueries === reviewTargets.length ? 'complete' : 'partial',
+      queryCount: pairs.length,
+      successfulQueries,
+      discoveredProducts: discoveredProductKeys.size,
+      reviewedProducts: new Set(reviews.map((review) => review.productId)).size,
+      collectedReviews: reviews.length,
+      collectedAt,
+    },
+  };
+  await mkdir(dirname(outputPath), { recursive: true });
+  await writeFile(outputPath, `${JSON.stringify(output, null, 2)}\n`, 'utf8');
+  console.log(`[competitor-enrich] wrote ${output.products.length} products, ${output.relations.length} relations, and ${output.reviews.length} reviews`);
+  console.log(`[competitor-enrich] output ${outputPath}`);
+}
+
+main().catch((error) => {
+  console.error('[competitor-enrich] failed', error instanceof Error ? error.message : error);
+  process.exitCode = 1;
+});

+ 65 - 0
scripts/seed-product-knowledge.ts

@@ -0,0 +1,65 @@
+import 'dotenv/config';
+import { loadConfig } from '../src/config/env.js';
+import { ParseRestClient } from '../src/db/parse-rest.client.js';
+import { VOC_PARSE_CLASSES } from '../src/db/parse-rest.schema.js';
+
+interface ProductObject {
+  workspaceId: string;
+  productKey: string;
+  productId: string;
+  role: 'own' | 'competitor';
+  category1?: string;
+  category2?: string;
+  category3?: string;
+  summary?: { gmv?: number; soldUnits?: number };
+}
+
+const config = loadConfig();
+if (config.storageDriver !== 'parse_rest') throw new Error('STORAGE_DRIVER must be parse_rest');
+const workspaceId = process.argv[2] || config.auth.defaultWorkspaceId;
+const limitArgument = process.argv[3] || '8';
+const limit = Math.max(1, Math.min(50, Number.parseInt(limitArgument, 10) || 8));
+const actorUserId = config.auth.localUserId || 'bootstrap';
+const client = new ParseRestClient({
+  serverUrl: config.parse.serverUrl,
+  appId: config.parse.appId,
+  masterKey: config.parse.masterKey,
+  timeoutMs: config.parse.timeoutMs,
+});
+
+const products = await client.findAll<ProductObject>(VOC_PARSE_CLASSES.product, {
+  workspaceId,
+  role: 'own',
+});
+const candidates = products
+  .sort((left, right) => Number(right.summary?.gmv ?? 0) - Number(left.summary?.gmv ?? 0)
+    || Number(right.summary?.soldUnits ?? 0) - Number(left.summary?.soldUnits ?? 0))
+  .slice(0, limit);
+let created = 0;
+let existing = 0;
+
+for (const product of candidates) {
+  const naturalKey = `${workspaceId}:${product.productKey}`;
+  if (await client.findOne(VOC_PARSE_CLASSES.productKnowledge, { naturalKey })) {
+    existing += 1;
+    continue;
+  }
+  const category = product.category3 || product.category2 || product.category1 || '';
+  await client.create(VOC_PARSE_CLASSES.productKnowledge, {
+    naturalKey,
+    workspaceId,
+    productKey: product.productKey,
+    productId: product.productId,
+    productRole: product.role,
+    featured: true,
+    status: 'active',
+    tags: ['经营TOP', ...(category ? [category] : [])],
+    note: '按成交金额初始化的重点商品,可在知识库内补充关注事项。',
+    ownerUserId: '',
+    createdBy: actorUserId,
+    updatedBy: actorUserId,
+  });
+  created += 1;
+}
+
+console.log(JSON.stringify({ workspaceId, candidates: candidates.length, created, existing }, null, 2));

+ 25 - 1
scripts/verify-parse-rest.ts

@@ -27,13 +27,31 @@ const masterOnly = vocSchemas.every((schema) => operations.every((operation) =>
 )));
 
 const where = { workspaceId, platform };
-const [ownProducts, competitorProducts, competitorDetailsReady, metrics, relations, reviews, workspace, completedImport] = await Promise.all([
+const [
+  ownProducts,
+  competitorProducts,
+  competitorDetailsReady,
+  metrics,
+  relations,
+  reviews,
+  promptConfigs,
+  defaultModelPrompt,
+  productKnowledge,
+  workspace,
+  completedImport,
+] = await Promise.all([
   client.count(VOC_PARSE_CLASSES.product, { ...where, role: 'own' }),
   client.count(VOC_PARSE_CLASSES.product, { ...where, role: 'competitor' }),
   client.count(VOC_PARSE_CLASSES.product, { ...where, role: 'competitor', source: 'fmode_gateway' }),
   client.count(VOC_PARSE_CLASSES.dailyMetric, where),
   client.count(VOC_PARSE_CLASSES.productRelation, where),
   client.count(VOC_PARSE_CLASSES.review, where),
+  client.count(VOC_PARSE_CLASSES.promptConfig, { workspaceId }),
+  client.findOne<{ template?: string; model?: string }>(VOC_PARSE_CLASSES.promptConfig, {
+    workspaceId,
+    promptKey: 'global.analysis.defaultModel',
+  }),
+  client.count(VOC_PARSE_CLASSES.productKnowledge, { workspaceId }),
   client.findOne(VOC_PARSE_CLASSES.workspace, { publicId: workspaceId, status: 'active' }),
   client.findOne(VOC_PARSE_CLASSES.importBatch, { workspaceId, platform, status: 'completed' }),
 ]);
@@ -65,6 +83,12 @@ console.log(JSON.stringify({
     relations,
     reviews,
   },
+  aiConfiguration: {
+    promptConfigs,
+    defaultModel: defaultModelPrompt?.model ?? defaultModelPrompt?.template ?? null,
+    ready: promptConfigs === 13 && Boolean(defaultModelPrompt),
+  },
+  productKnowledge,
   publicReadBlocked: !publicResponse.ok,
   publicReadStatus: publicResponse.status,
   publicReadCode: publicPayload.code ?? null,

+ 16 - 0
src/app.ts

@@ -12,6 +12,11 @@ import { createAuthenticationMiddleware, createAuthenticator, WorkspaceAccessSer
 import type { PlatformRepository } from './modules/saas-platform/domain.js';
 import { PostgresPlatformRepository } from './modules/saas-platform/postgres-platform.repository.js';
 import { createSaasPlatformRouter } from './modules/saas-platform/routes.js';
+import { FmodeAiClient } from './modules/ai-gateway/client.js';
+import { createAiGatewayRouter } from './modules/ai-gateway/routes.js';
+import type { AiPromptConfigStore } from './modules/ai-gateway/prompt-config.repository.js';
+import { createProductKnowledgeRouter } from './modules/product-knowledge/routes.js';
+import type { ProductKnowledgeStore } from './modules/product-knowledge/product-knowledge.store.js';
 
 export function createApp(input: {
   config: AppConfig;
@@ -21,6 +26,8 @@ export function createApp(input: {
   jobs?: SyncJobStore;
   snapshot?: DomesticSnapshotProvider;
   healthCheck?: () => Promise<{ ready: boolean; missingObjects: string[] }>;
+  aiPromptConfigs?: AiPromptConfigStore;
+  productKnowledge?: ProductKnowledgeStore;
 }) {
   const app = express();
   app.disable('x-powered-by');
@@ -106,6 +113,7 @@ export function createApp(input: {
   const platform = input.platformRepository ?? new PostgresPlatformRepository(input.pool!);
   const access = new WorkspaceAccessService(platform);
   app.use('/api', createAuthenticationMiddleware(createAuthenticator(input.config)));
+  app.use('/api/ai', createAiGatewayRouter(new FmodeAiClient(input.config.ai), input.aiPromptConfigs));
 
   const jobs = input.jobs ?? new SyncJobRepository(input.pool!);
   const sync = new SyncService(jobs);
@@ -119,6 +127,14 @@ export function createApp(input: {
     defaultWorkspaceId: input.config.auth.defaultWorkspaceId,
   }));
   app.use('/api/saas', createSaasPlatformRouter({ repository: platform, access }));
+  if (input.productKnowledge) {
+    app.use('/api/knowledge', createProductKnowledgeRouter({
+      store: input.productKnowledge,
+      repository: platform,
+      access,
+      defaultWorkspaceId: input.config.auth.defaultWorkspaceId,
+    }));
+  }
 
   app.use((_request, response) => {
     response.status(404).json({ error: 'not_found' });

+ 16 - 0
src/config/env.ts

@@ -32,6 +32,10 @@ const environmentSchema = z.object({
   FMODE_API_KEY: z.string().min(1, 'FMODE_API_KEY is required'),
   FMODE_TIMEOUT_MS: z.coerce.number().int().min(100).max(120_000).default(30_000),
   FMODE_RETRIES: z.coerce.number().int().min(0).max(5).default(2),
+  FMODE_AI_BASE_URL: z.url().default('https://api.fmode.cn'),
+  FMODE_AI_TOKEN: optionalNonEmptyString,
+  FMODE_AI_MODEL: z.string().min(1).default('deepseek-v4-pro'),
+  FMODE_AI_TIMEOUT_MS: z.coerce.number().int().min(1_000).max(300_000).default(120_000),
   SYNC_WORKER_ENABLED: z.enum(['true', 'false']).default('true'),
   SYNC_WORKER_POLL_MS: z.coerce.number().int().min(500).max(60_000).default(2_000),
   SYNC_JOB_STALE_AFTER_MS: z.coerce.number().int().min(60_000).max(86_400_000).default(900_000),
@@ -74,6 +78,12 @@ export type AppConfig = {
     timeoutMs: number;
     retries: number;
   };
+  ai: {
+    baseUrl: string;
+    token: string;
+    defaultModel: string;
+    timeoutMs: number;
+  };
   worker: {
     enabled: boolean;
     pollMs: number;
@@ -153,6 +163,12 @@ export function loadConfig(environment: NodeJS.ProcessEnv = process.env): AppCon
       timeoutMs: value.FMODE_TIMEOUT_MS,
       retries: value.FMODE_RETRIES,
     },
+    ai: {
+      baseUrl: value.FMODE_AI_BASE_URL.replace(/\/+$/, ''),
+      token: value.FMODE_AI_TOKEN ?? '',
+      defaultModel: value.FMODE_AI_MODEL,
+      timeoutMs: value.FMODE_AI_TIMEOUT_MS,
+    },
     worker: {
       enabled: value.SYNC_WORKER_ENABLED === 'true',
       pollMs: value.SYNC_WORKER_POLL_MS,

+ 101 - 37
src/db/parse-rest.client.ts

@@ -16,6 +16,7 @@ export interface ParseClassSchema {
   className: string;
   fields: Record<string, ParseFieldDefinition>;
   classLevelPermissions?: Record<string, Record<string, unknown>>;
+  indexes?: Record<string, Record<string, 1 | -1>>;
 }
 
 export interface ParseObject {
@@ -36,6 +37,34 @@ export interface ParseRequestOptions {
   sessionToken?: string;
 }
 
+function withoutRequiredFlags(fields: Record<string, ParseFieldDefinition>): Record<string, ParseFieldDefinition> {
+  return Object.fromEntries(Object.entries(fields).map(([name, definition]) => {
+    const { required: _required, ...compatibleDefinition } = definition;
+    return [name, compatibleDefinition];
+  }));
+}
+
+function shouldRetryWithoutRequired(error: unknown, fields: Record<string, ParseFieldDefinition>): boolean {
+  return error instanceof ParseRestError
+    && error.status === 403
+    && Object.values(fields).some((field) => field.required !== undefined);
+}
+
+function canRetryTransientMasterAuthorization(path: string, options: ParseRequestOptions, response: Response, payload: unknown): boolean {
+  const error = payload as ParseErrorPayload;
+  const schemaFields = (options.body as { fields?: Record<string, ParseFieldDefinition> } | undefined)?.fields;
+  const schemaWriteCanRetry = (path === '/schemas' || path.startsWith('/schemas/'))
+    && (!schemaFields || !Object.values(schemaFields).some((field) => field.required !== undefined));
+  return options.master !== false
+    && response.status === 403
+    && error.error === 'unauthorized'
+    && (path.startsWith('/classes/') || path === '/batch' || schemaWriteCanRetry);
+}
+
+function retryDelay(): Promise<void> {
+  return new Promise((resolve) => setTimeout(resolve, 40));
+}
+
 export interface ParseFindOptions {
   where?: Record<string, unknown>;
   order?: string;
@@ -104,41 +133,52 @@ export class ParseRestClient {
       Accept: 'application/json',
       'X-Parse-Application-Id': this.appId,
     };
+    if ((options.method ?? 'GET') === 'GET') {
+      headers['Cache-Control'] = 'no-cache, no-store, max-age=0';
+      headers.Pragma = 'no-cache';
+    }
     if (options.master !== false) headers['X-Parse-Master-Key'] = this.masterKey;
     if (options.sessionToken) headers['X-Parse-Session-Token'] = options.sessionToken;
     if (options.body !== undefined) headers['Content-Type'] = 'application/json';
 
-    let response: Response;
-    try {
-      const init: RequestInit = {
-        method: options.method ?? 'GET',
-        headers,
-        signal: AbortSignal.timeout(this.timeoutMs),
-      };
-      if (options.body !== undefined) init.body = JSON.stringify(options.body);
-      response = await this.fetchImplementation(`${this.baseUrl}${path}`, init);
-    } catch (error) {
-      throw new ParseRestError(503, null, error instanceof Error ? error.message : 'Parse REST request failed');
-    }
-
-    const text = await response.text();
-    let payload: unknown = {};
-    if (text) {
+    for (let attempt = 0; attempt < 10; attempt += 1) {
+      let response: Response;
       try {
-        payload = JSON.parse(text);
-      } catch {
-        throw new ParseRestError(response.status, null, 'Parse REST returned a non-JSON response');
+        const init: RequestInit = {
+          method: options.method ?? 'GET',
+          headers,
+          signal: AbortSignal.timeout(this.timeoutMs),
+        };
+        if (options.body !== undefined) init.body = JSON.stringify(options.body);
+        response = await this.fetchImplementation(`${this.baseUrl}${path}`, init);
+      } catch (error) {
+        throw new ParseRestError(503, null, error instanceof Error ? error.message : 'Parse REST request failed');
       }
+
+      const text = await response.text();
+      let payload: unknown = {};
+      if (text) {
+        try {
+          payload = JSON.parse(text);
+        } catch {
+          throw new ParseRestError(response.status, null, 'Parse REST returned a non-JSON response');
+        }
+      }
+      if (canRetryTransientMasterAuthorization(path, options, response, payload) && attempt < 9) {
+        await retryDelay();
+        continue;
+      }
+      if (!response.ok) {
+        const error = payload as ParseErrorPayload;
+        throw new ParseRestError(
+          response.status,
+          typeof error.code === 'number' ? error.code : null,
+          typeof error.error === 'string' ? error.error : `Parse REST request failed with ${response.status}`,
+        );
+      }
+      return payload as T;
     }
-    if (!response.ok) {
-      const error = payload as ParseErrorPayload;
-      throw new ParseRestError(
-        response.status,
-        typeof error.code === 'number' ? error.code : null,
-        typeof error.error === 'string' ? error.error : `Parse REST request failed with ${response.status}`,
-      );
-    }
-    return payload as T;
+    throw new ParseRestError(503, null, 'Parse REST request failed');
   }
 
   async health(): Promise<boolean> {
@@ -156,27 +196,51 @@ export class ParseRestClient {
   }
 
   async createSchema(schema: ParseClassSchema): Promise<void> {
-    await this.request(`/schemas/${encodeURIComponent(schema.className)}`, {
-      method: 'POST',
-      body: {
-        fields: schema.fields,
-        classLevelPermissions: masterOnlyClassPermissions(),
+    const write = (fields: Record<string, ParseFieldDefinition>) => this.request(
+      `/schemas/${encodeURIComponent(schema.className)}`,
+      {
+        method: 'POST',
+        body: {
+          fields,
+          classLevelPermissions: masterOnlyClassPermissions(),
+          ...(schema.indexes ? { indexes: schema.indexes } : {}),
+        },
       },
-    });
+    );
+    try {
+      await write(schema.fields);
+    } catch (error) {
+      if (!shouldRetryWithoutRequired(error, schema.fields)) throw error;
+      await write(withoutRequiredFlags(schema.fields));
+    }
   }
 
   async addSchemaFields(className: string, fields: Record<string, ParseFieldDefinition>): Promise<void> {
     if (!Object.keys(fields).length) return;
+    const write = (definitions: Record<string, ParseFieldDefinition>) => this.request(
+      `/schemas/${encodeURIComponent(className)}`,
+      { method: 'PUT', body: { fields: definitions } },
+    );
+    try {
+      await write(fields);
+    } catch (error) {
+      if (!shouldRetryWithoutRequired(error, fields)) throw error;
+      await write(withoutRequiredFlags(fields));
+    }
+  }
+
+  async setSchemaPermissions(className: string): Promise<void> {
     await this.request(`/schemas/${encodeURIComponent(className)}`, {
       method: 'PUT',
-      body: { fields },
+      body: { classLevelPermissions: masterOnlyClassPermissions() },
     });
   }
 
-  async setSchemaPermissions(className: string): Promise<void> {
+  async addSchemaIndexes(className: string, indexes: Record<string, Record<string, 1 | -1>>): Promise<void> {
+    if (!Object.keys(indexes).length) return;
     await this.request(`/schemas/${encodeURIComponent(className)}`, {
       method: 'PUT',
-      body: { classLevelPermissions: masterOnlyClassPermissions() },
+      body: { indexes },
     });
   }
 

+ 111 - 3
src/db/parse-rest.schema.ts

@@ -3,9 +3,13 @@ import { ParseRestClient } from './parse-rest.client.js';
 
 const string = (required = false): ParseFieldDefinition => ({ type: 'String', required });
 const number = (required = false): ParseFieldDefinition => ({ type: 'Number', required });
+const boolean = (required = false): ParseFieldDefinition => ({ type: 'Boolean', required });
 const date = (required = false): ParseFieldDefinition => ({ type: 'Date', required });
 const object = (required = false): ParseFieldDefinition => ({ type: 'Object', required });
 const array = (required = false): ParseFieldDefinition => ({ type: 'Array', required });
+const indexes = (prefix: string, ...fields: string[]): Record<string, Record<string, 1>> => Object.fromEntries(
+  fields.map((field) => [`${prefix}_${field.toLowerCase()}_idx`, { [field]: 1 }]),
+);
 
 export const VOC_PARSE_CLASSES = {
   workspace: 'VocWorkspace',
@@ -19,9 +23,12 @@ export const VOC_PARSE_CLASSES = {
   syncJobEvent: 'VocSyncJobEvent',
   workspaceMember: 'VocWorkspaceMember',
   analysisRun: 'VocAnalysisRun',
+  insightDecision: 'VocInsightDecision',
   actionItem: 'VocActionItem',
   alert: 'VocAlert',
   auditLog: 'VocAuditLog',
+  promptConfig: 'VocPromptConfig',
+  productKnowledge: 'VocProductKnowledge',
 } as const;
 
 export const VOC_PARSE_SCHEMAS: ParseClassSchema[] = [
@@ -30,6 +37,7 @@ export const VOC_PARSE_SCHEMAS: ParseClassSchema[] = [
     fields: {
       publicId: string(true), name: string(true), caseName: string(true), status: string(true),
     },
+    indexes: indexes('voc_workspace', 'publicId', 'status'),
   },
   {
     className: VOC_PARSE_CLASSES.sourceConnection,
@@ -37,6 +45,7 @@ export const VOC_PARSE_SCHEMAS: ParseClassSchema[] = [
       naturalKey: string(true), workspaceId: string(true), platform: string(true),
       connectionKind: string(true), status: string(true), metadata: object(true), lastCheckedAt: date(),
     },
+    indexes: indexes('voc_source', 'naturalKey', 'workspaceId', 'platform', 'status'),
   },
   {
     className: VOC_PARSE_CLASSES.importBatch,
@@ -48,6 +57,7 @@ export const VOC_PARSE_SCHEMAS: ParseClassSchema[] = [
       startedAt: date(), completedAt: date(), sourceDateStart: string(), sourceDateEnd: string(),
       dailyTotals: array(true), quality: object(true),
     },
+    indexes: indexes('voc_import', 'publicId', 'workspaceId', 'platform', 'sourceHash', 'status', 'createdAt'),
   },
   {
     className: VOC_PARSE_CLASSES.product,
@@ -57,6 +67,7 @@ export const VOC_PARSE_SCHEMAS: ParseClassSchema[] = [
       category1: string(), category2: string(), category3: string(), source: string(true),
       relationCount: number(true), summary: object(true), trend: array(true), rawPayload: object(),
     },
+    indexes: indexes('voc_product', 'naturalKey', 'workspaceId', 'platform', 'productId', 'productKey', 'role', 'category2', 'category3', 'source'),
   },
   {
     className: VOC_PARSE_CLASSES.dailyMetric,
@@ -68,6 +79,7 @@ export const VOC_PARSE_SCHEMAS: ParseClassSchema[] = [
       orderAmount: number(true), orderUnits: number(true), orderCount: number(true),
       refundAmount: number(true), refundUnits: number(true), refundOrders: number(true),
     },
+    indexes: indexes('voc_metric', 'naturalKey', 'workspaceId', 'platform', 'productId', 'metricDate'),
   },
   {
     className: VOC_PARSE_CLASSES.productRelation,
@@ -77,6 +89,7 @@ export const VOC_PARSE_SCHEMAS: ParseClassSchema[] = [
       competitorProductKey: string(true), competitorBrand: string(), category: string(),
       ownModel: string(), ownCategory1: string(), ownCategory2: string(), ownCategory3: string(),
     },
+    indexes: indexes('voc_relation', 'naturalKey', 'workspaceId', 'platform', 'ownProductId', 'competitorProductId'),
   },
   {
     className: VOC_PARSE_CLASSES.review,
@@ -85,6 +98,7 @@ export const VOC_PARSE_SCHEMAS: ParseClassSchema[] = [
       sourceReviewId: string(), reviewKey: string(true), rating: number(), content: string(true),
       reviewDate: date(), rawPayload: object(),
     },
+    indexes: indexes('voc_review', 'naturalKey', 'workspaceId', 'platform', 'productId', 'reviewDate'),
   },
   {
     className: VOC_PARSE_CLASSES.syncJob,
@@ -94,6 +108,7 @@ export const VOC_PARSE_SCHEMAS: ParseClassSchema[] = [
       attempts: number(true), maxAttempts: number(true), workerId: string(), errorSummary: string(),
       requestedAt: date(true), startedAt: date(), completedAt: date(),
     },
+    indexes: indexes('voc_job', 'publicId', 'workspaceId', 'platform', 'idempotencyKey', 'status', 'requestedAt'),
   },
   {
     className: VOC_PARSE_CLASSES.syncJobEvent,
@@ -101,6 +116,7 @@ export const VOC_PARSE_SCHEMAS: ParseClassSchema[] = [
       publicId: string(true), workspaceId: string(true), jobPublicId: string(true), level: string(true),
       eventType: string(true), message: string(true), details: object(true),
     },
+    indexes: indexes('voc_job_event', 'publicId', 'workspaceId', 'jobPublicId', 'createdAt'),
   },
   {
     className: VOC_PARSE_CLASSES.workspaceMember,
@@ -108,6 +124,7 @@ export const VOC_PARSE_SCHEMAS: ParseClassSchema[] = [
       naturalKey: string(true), workspaceId: string(true), userId: string(true), email: string(),
       displayName: string(), role: string(true), status: string(true),
     },
+    indexes: indexes('voc_member', 'naturalKey', 'workspaceId', 'userId', 'role', 'status'),
   },
   {
     className: VOC_PARSE_CLASSES.analysisRun,
@@ -117,14 +134,66 @@ export const VOC_PARSE_SCHEMAS: ParseClassSchema[] = [
       evidenceCount: number(true), requestedBy: string(true), errorSummary: string(),
       requestedAt: date(true), startedAt: date(), completedAt: date(),
     },
+    indexes: indexes('voc_analysis', 'publicId', 'workspaceId', 'status', 'targetKey', 'requestedAt'),
+  },
+  {
+    className: VOC_PARSE_CLASSES.insightDecision,
+    fields: {
+      publicId: string(true), workspaceId: string(true), sourceAnalysisId: string(true),
+      sourceInsightId: string(true), decision: string(true), reviewedEvidenceIds: array(true),
+      comment: string(true), decidedBy: string(true), decidedAt: date(true), version: number(true),
+      supersedesId: string(), isCurrent: boolean(true),
+    },
+    indexes: {
+      ...indexes(
+        'voc_insight_decision',
+        'publicId',
+        'workspaceId',
+        'sourceAnalysisId',
+        'sourceInsightId',
+        'decision',
+        'decidedBy',
+        'decidedAt',
+        'isCurrent',
+      ),
+      voc_insight_decision_source_current_idx: {
+        workspaceId: 1,
+        sourceAnalysisId: 1,
+        sourceInsightId: 1,
+        isCurrent: 1,
+      },
+      voc_insight_decision_source_version_idx: {
+        workspaceId: 1,
+        sourceAnalysisId: 1,
+        sourceInsightId: 1,
+        version: -1,
+      },
+    },
   },
   {
     className: VOC_PARSE_CLASSES.actionItem,
     fields: {
-      publicId: string(true), workspaceId: string(true), sourceAnalysisId: string(), actionType: string(true),
+      publicId: string(true), workspaceId: string(true), sourceAnalysisId: string(), sourceInsightId: string(),
+      sourceDecisionId: string(), sourceKind: string(true), creationKey: string(true),
+      evidenceIds: array(), validationMetric: string(), actionType: string(true),
       title: string(true), description: string(), priority: string(true), status: string(true),
       productKey: string(), assigneeUserId: string(), dueAt: date(), createdBy: string(true), completedAt: date(),
     },
+    indexes: {
+      ...indexes(
+        'voc_action',
+        'publicId',
+        'workspaceId',
+        'sourceAnalysisId',
+        'sourceDecisionId',
+        'sourceKind',
+        'creationKey',
+        'status',
+        'assigneeUserId',
+        'createdAt',
+      ),
+      voc_action_workspace_creation_key_idx: { workspaceId: 1, creationKey: 1 },
+    },
   },
   {
     className: VOC_PARSE_CLASSES.alert,
@@ -133,6 +202,7 @@ export const VOC_PARSE_SCHEMAS: ParseClassSchema[] = [
       status: string(true), productKey: string(), title: string(true), summary: string(), evidence: array(true),
       detectedAt: date(true), acknowledgedBy: string(), acknowledgedAt: date(), resolvedAt: date(),
     },
+    indexes: indexes('voc_alert', 'publicId', 'workspaceId', 'status', 'productKey', 'detectedAt'),
   },
   {
     className: VOC_PARSE_CLASSES.auditLog,
@@ -140,6 +210,37 @@ export const VOC_PARSE_SCHEMAS: ParseClassSchema[] = [
       publicId: string(true), workspaceId: string(true), actorUserId: string(true), action: string(true),
       entityType: string(true), entityId: string(), metadata: object(true),
     },
+    indexes: indexes('voc_audit', 'publicId', 'workspaceId', 'entityType', 'createdAt'),
+  },
+  {
+    className: VOC_PARSE_CLASSES.promptConfig,
+    fields: {
+      naturalKey: string(true), workspaceId: string(true), promptKey: string(true),
+      name: string(true), module: string(true), scope: string(true), description: string(),
+      template: string(true), enabled: boolean(true), variables: array(true), dataSources: array(true),
+      targetKey: string(), targetStylesJson: string(), model: string(), revision: number(true),
+      updatedBy: string(true),
+    },
+    indexes: indexes('voc_prompt', 'naturalKey', 'workspaceId', 'promptKey', 'module', 'scope'),
+  },
+  {
+    className: VOC_PARSE_CLASSES.productKnowledge,
+    fields: {
+      naturalKey: string(true), workspaceId: string(true), productKey: string(true), productId: string(true),
+      productRole: string(true), featured: boolean(true), status: string(true), tags: array(true),
+      note: string(), ownerUserId: string(), createdBy: string(true), updatedBy: string(true),
+    },
+    indexes: indexes(
+      'voc_product_knowledge',
+      'naturalKey',
+      'workspaceId',
+      'productKey',
+      'productId',
+      'productRole',
+      'featured',
+      'status',
+      'updatedAt',
+    ),
   },
 ];
 
@@ -164,10 +265,14 @@ export async function ensureVocParseSchemas(client: ParseRestClient): Promise<Pa
         const field = current.fields?.[name];
         return !field
           || field.type !== definition.type
-          || Boolean(field.required) !== Boolean(definition.required);
+          || (field.required !== undefined && Boolean(field.required) !== Boolean(definition.required));
       }),
     );
     const fieldsChanged = Object.keys(changed).length > 0;
+    const missingIndexes = Object.fromEntries(
+      Object.entries(schema.indexes ?? {}).filter(([name]) => !current.indexes?.[name]),
+    );
+    const indexesChanged = Object.keys(missingIndexes).length > 0;
     const permissionsChanged = !hasMasterOnlyPermissions(current.classLevelPermissions);
     if (fieldsChanged) {
       await client.addSchemaFields(schema.className, changed);
@@ -175,7 +280,10 @@ export async function ensureVocParseSchemas(client: ParseRestClient): Promise<Pa
     if (permissionsChanged) {
       await client.setSchemaPermissions(schema.className);
     }
-    if (fieldsChanged || permissionsChanged) {
+    if (indexesChanged) {
+      await client.addSchemaIndexes(schema.className, missingIndexes);
+    }
+    if (fieldsChanged || permissionsChanged || indexesChanged) {
       result.updated.push(schema.className);
     } else {
       result.unchanged.push(schema.className);

+ 20 - 0
src/local-app.ts

@@ -10,11 +10,18 @@ import { ApiError } from './http/api-error.js';
 import { createAuthenticationMiddleware, DisabledAuthenticator, WorkspaceAccessService } from './modules/saas-platform/auth.js';
 import { LocalPlatformRepository } from './modules/saas-platform/local-platform.repository.js';
 import { createSaasPlatformRouter } from './modules/saas-platform/routes.js';
+import { FmodeAiClient, type AiGatewayConfig } from './modules/ai-gateway/client.js';
+import { createAiGatewayRouter } from './modules/ai-gateway/routes.js';
+import { createProductKnowledgeRouter } from './modules/product-knowledge/routes.js';
+import { LocalProductKnowledgeStore } from './modules/product-knowledge/local-product-knowledge.store.js';
+import type { ProductKnowledgeStore } from './modules/product-knowledge/product-knowledge.store.js';
 
 export function createLocalDemoApp(input: {
   dataset: DomesticDataset;
   corsOrigins: string[];
   workspaceId?: string;
+  ai?: AiGatewayConfig;
+  productKnowledge?: ProductKnowledgeStore;
 }) {
   const app = express();
   app.disable('x-powered-by');
@@ -37,8 +44,15 @@ export function createLocalDemoApp(input: {
   const platform = new LocalPlatformRepository(input.dataset, jobs, localPrincipal, workspaceId);
   const access = new WorkspaceAccessService(platform);
   app.use('/api', createAuthenticationMiddleware(new DisabledAuthenticator(localPrincipal)));
+  app.use('/api/ai', createAiGatewayRouter(new FmodeAiClient(input.ai ?? {
+    baseUrl: 'https://api.fmode.cn',
+    token: '',
+    defaultModel: 'deepseek-v4-pro',
+    timeoutMs: 120_000,
+  })));
   const sync = new SyncService(jobs);
   const snapshot = new LocalSnapshotService(input.dataset, workspaceId);
+  const productKnowledge = input.productKnowledge ?? new LocalProductKnowledgeStore(input.dataset, workspaceId);
 
   app.get('/health', (_request, response) => {
     response.json({
@@ -66,6 +80,12 @@ export function createLocalDemoApp(input: {
     defaultWorkspaceId: workspaceId,
   }));
   app.use('/api/saas', createSaasPlatformRouter({ repository: platform, access }));
+  app.use('/api/knowledge', createProductKnowledgeRouter({
+    store: productKnowledge,
+    repository: platform,
+    access,
+    defaultWorkspaceId: workspaceId,
+  }));
 
   app.use((_request, response) => {
     response.status(404).json({ error: 'not_found' });

+ 80 - 2
src/local-server.ts

@@ -5,6 +5,7 @@ import { resolve } from 'node:path';
 import { z } from 'zod';
 import { createLocalDemoApp } from './local-app.js';
 import type { DomesticDataset } from './types/domestic-dataset.js';
+import { LocalProductKnowledgeStore } from './modules/product-knowledge/local-product-knowledge.store.js';
 
 const localEnvironmentSchema = z.object({
   LOCAL_HOST: z.string().min(1).default('127.0.0.1'),
@@ -14,7 +15,15 @@ const localEnvironmentSchema = z.object({
     (value) => value === '' ? undefined : value,
     z.string().min(1).optional(),
   ),
-  LOCAL_CORS_ORIGINS: z.string().min(1).default('http://localhost:4200,http://127.0.0.1:4200'),
+  LOCAL_KNOWLEDGE_PATH: z.preprocess(
+    (value) => value === '' ? undefined : value,
+    z.string().min(1).optional(),
+  ),
+  LOCAL_CORS_ORIGINS: z.string().min(1).default('http://localhost:4202,http://127.0.0.1:4202,http://localhost:4200,http://127.0.0.1:4200'),
+  FMODE_AI_BASE_URL: z.url().default('https://api.fmode.cn'),
+  FMODE_AI_TOKEN: z.string().default(''),
+  FMODE_AI_MODEL: z.string().min(1).default('deepseek-v4-pro'),
+  FMODE_AI_TIMEOUT_MS: z.coerce.number().int().min(1_000).max(300_000).default(120_000),
 });
 
 function defaultDatasetPath(): string {
@@ -34,20 +43,89 @@ async function loadDataset(path: string): Promise<DomesticDataset> {
   ) {
     throw new Error('Local dataset does not match the domestic VOC snapshot contract');
   }
-  return {
+  const dataset = {
     ...parsed,
     reviews: Array.isArray(parsed.reviews) ? parsed.reviews : [],
   } as DomesticDataset;
+  const existingKeys = new Set(dataset.products.map((product) => product.productKey));
+  const relationsByCompetitor = new Map<string, typeof dataset.relations>();
+  for (const relation of dataset.relations) {
+    const relations = relationsByCompetitor.get(relation.competitorProductKey) ?? [];
+    relations.push(relation);
+    relationsByCompetitor.set(relation.competitorProductKey, relations);
+  }
+  const emptySummary = {
+    gmv: 0,
+    soldUnits: 0,
+    transactionOrders: 0,
+    transactionCustomers: 0,
+    impressions: 0,
+    clicks: 0,
+    views: 0,
+    visitors: 0,
+    cartUnits: 0,
+    orderAmount: 0,
+    orderUnits: 0,
+    orderCount: 0,
+    refundAmount: 0,
+    refundUnits: 0,
+    refundOrders: 0,
+    conversionRate: 0,
+    clickThroughRate: 0,
+    averageUnitPrice: 0,
+    refundToGmvRate: 0,
+  };
+  for (const [productKey, relations] of relationsByCompetitor) {
+    if (existingKeys.has(productKey)) continue;
+    const relation = relations[0];
+    if (!relation) continue;
+    dataset.products.push({
+      platform: dataset.platform,
+      productId: relation.competitorProductId,
+      productKey,
+      asin: relation.competitorProductId,
+      role: 'competitor',
+      brand: relation.competitorBrand,
+      title: [relation.competitorBrand, relation.category].filter(Boolean).join(' '),
+      model: '',
+      category1: '',
+      category2: relation.category,
+      category3: relation.category,
+      source: 'workbook-competitor-relation',
+      relationCount: new Set(relations.map((item) => item.ownProductKey)).size,
+      summary: { ...emptySummary },
+      trend: [],
+    });
+  }
+  dataset.summary = {
+    ...dataset.summary,
+    relations: dataset.relations.length,
+    uniqueCompetitorProducts: dataset.products.filter((product) => product.role === 'competitor').length,
+    reviewCount: dataset.reviews.length,
+  };
+  return dataset;
 }
 
 async function main(): Promise<void> {
   const config = localEnvironmentSchema.parse(process.env);
   const datasetPath = resolve(config.LOCAL_DATASET_PATH || defaultDatasetPath());
   const dataset = await loadDataset(datasetPath);
+  const productKnowledge = await LocalProductKnowledgeStore.open({
+    dataset,
+    workspaceId: config.LOCAL_WORKSPACE_ID,
+    persistencePath: resolve(config.LOCAL_KNOWLEDGE_PATH || resolve(process.cwd(), 'logs', 'local-product-knowledge.json')),
+  });
   const app = createLocalDemoApp({
     dataset,
     workspaceId: config.LOCAL_WORKSPACE_ID,
     corsOrigins: config.LOCAL_CORS_ORIGINS.split(',').map((origin) => origin.trim()).filter(Boolean),
+    productKnowledge,
+    ai: {
+      baseUrl: config.FMODE_AI_BASE_URL.replace(/\/+$/, ''),
+      token: config.FMODE_AI_TOKEN,
+      defaultModel: config.FMODE_AI_MODEL,
+      timeoutMs: config.FMODE_AI_TIMEOUT_MS,
+    },
   });
   const server = createServer(app);
 

+ 47 - 0
src/modules/ai-gateway/client.ts

@@ -0,0 +1,47 @@
+export interface AiGatewayConfig {
+  baseUrl: string;
+  token: string;
+  defaultModel: string;
+  timeoutMs: number;
+}
+
+export type AiGatewayFetch = typeof fetch;
+
+export class FmodeAiClient {
+  constructor(
+    readonly config: AiGatewayConfig,
+    private readonly fetchImpl: AiGatewayFetch = fetch,
+  ) {}
+
+  get configured(): boolean {
+    return Boolean(this.config.token.trim());
+  }
+
+  async createChatCompletion(body: Record<string, unknown>, signal?: AbortSignal): Promise<Response> {
+    if (!this.configured) {
+      throw new AiGatewayNotConfiguredError();
+    }
+
+    const timeoutSignal = AbortSignal.timeout(this.config.timeoutMs);
+    const requestSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
+    const url = `${this.config.baseUrl.replace(/\/+$/, '')}/v1/chat/completions`;
+
+    return this.fetchImpl(url, {
+      method: 'POST',
+      headers: {
+        authorization: `Bearer ${this.config.token}`,
+        'content-type': 'application/json',
+        accept: body['stream'] === true ? 'text/event-stream' : 'application/json',
+      },
+      body: JSON.stringify({ ...body, model: body['model'] || this.config.defaultModel }),
+      signal: requestSignal,
+    });
+  }
+}
+
+export class AiGatewayNotConfiguredError extends Error {
+  constructor() {
+    super('AI gateway token is not configured');
+    this.name = 'AiGatewayNotConfiguredError';
+  }
+}

+ 157 - 0
src/modules/ai-gateway/default-prompt-configs.ts

@@ -0,0 +1,157 @@
+import type { AiPromptConfigRecord } from './prompt-config.repository.js';
+
+type DefaultPromptConfig = Omit<AiPromptConfigRecord, 'naturalKey' | 'workspaceId'>;
+
+export const DEFAULT_DOMESTIC_AI_PROMPT_CONFIGS: DefaultPromptConfig[] = [
+  {
+    promptKey: 'shared.analysisPanel.defaultSystem',
+    name: '共享 AI 分析面板默认角色',
+    module: '共享组件 / AI分析',
+    scope: 'analysis',
+    description: '通用 AI 分析面板未显式传入角色时使用的国内电商 VOC 角色。',
+    template: '你是一名专业的国内电商 VOC 经营分析师。请严格区分本品、竞品、平台、店铺和时间范围,只依据上下文中的真实数据与评论证据给出简洁、专业、可落地的中文分析;数据不足时明确说明缺口,使用 Markdown 格式输出。',
+    enabled: true,
+    variables: ['页面传入 prompt', '页面数据上下文'],
+    dataSources: ['AiAnalysisPanelComponent.prompt', 'PageDataContextService.buildFullPrompt()'],
+  },
+  {
+    promptKey: 'domestic.competitorReview.system',
+    name: '国内竞品评论分析角色',
+    module: '市场与竞品 / 竞品详情',
+    scope: 'analysis',
+    description: '竞品详情评价工作台的 AI 可视化分析和后续追问使用的系统提示词。',
+    template: [
+      '你是一名专业的国内电商 VOC 与竞品研究分析师。严格依据提供的商品档案、评论样本和关联数据作答,不得补造价格、销量、市场份额或历史趋势。',
+      '评分星级和评论正文必须分开判断。4-5 星占比只能描述评分分组,不得改写成正文情绪正向率;如果评分与正文矛盾,必须明确标记为数据质量风险。',
+      '本次必须只返回一个合法 JSON 对象,不要输出 Markdown、代码块或额外说明。顶层字段必须是 visualReport。',
+      'visualReport 至少包含 title、blocks 和 summary。blocks 依次使用 metric-grid、chart、evidence-list、action-list;chart 使用 pie 或 bar,并且每个数值只能来自上下文。',
+      'metric-grid 只允许使用上下文可直接计算的指标,不得生成评价一致性、健康分、竞争力等推断指标。数据不足时明确写“数据不足”,仍返回合法 JSON。',
+    ].join('\n'),
+    enabled: true,
+    variables: ['竞品商品档案', '评分分布', '有效评论样本', '关联德玛仕本品', '数据边界'],
+    dataSources: ['DomesticCompetitorDetailComponent.buildAiReviewPrompt()', 'DomesticDatasetService.dataset$', 'DomesticReview / ProductRelation'],
+  },
+  {
+    promptKey: 'vocInsight.painPointSuggestion.system',
+    name: '痛点改进建议角色',
+    module: '产品VOC深度洞察 / 痛点深度洞察',
+    scope: 'analysis',
+    description: '单个痛点 AI 改进建议使用的系统提示词。',
+    template: '你是一名国内电商 VOC 痛点诊断与产品优化专家。请基于痛点分类、严重程度、频次、影响比例、关键词、评论原文和商品上下文,输出可执行的产品、商品详情页和客服体验优化建议。所有建议必须绑定具体痛点和证据;如果数据不足,直接说明缺口。',
+    enabled: true,
+    variables: ['痛点详情', '商品信息', '评论原文', '规格上下文'],
+    dataSources: ['PainPointInsightComponent.generateAiSuggestion()', 'VocInsightStateService.currentProduct', '国内本品评论'],
+  },
+  {
+    promptKey: 'returnDepth.overview.system',
+    name: '退货深度分析总览角色',
+    module: '退货深度分析 / 退货整体概况',
+    scope: 'analysis',
+    description: '退款总览、生命周期和特征关联完整报告使用的系统提示词。',
+    template: '你是一名国内电商退款与售后分析师。请严格基于退款金额、退款率、商品、类目、时间和原因字段识别经营影响与优先级;优先看退款金额影响,再结合退款率和样本量判断。用中文 Markdown 输出结论、证据、归因假设和改善动作,数据不足时明确标注。',
+    enabled: true,
+    variables: ['退款金额', '退款率', '商品趋势', '归因', '类目与时间范围'],
+    dataSources: ['DomesticReturnAnalysisComponent', 'DomesticAnalyticsAdapterService', '商品日明细'],
+  },
+  {
+    promptKey: 'returnDepth.mini.system',
+    name: '退货深度分析简版角色',
+    module: '退货深度分析',
+    scope: 'analysis',
+    description: '退货分析小卡片的短洞察系统提示词。',
+    template: '你是国内电商退款经营分析师。请用 1-2 句中文给出当前卡片的核心洞察和可落地动作,必须引用输入中的金额、比例或样本量,总字数 80 字以内;数据不足时直接说明。',
+    enabled: true,
+    variables: ['局部退款指标', '商品/类目/时间维度'],
+    dataSources: ['DomesticReturnAnalysisComponent', 'DomesticAnalyticsAdapterService'],
+  },
+  {
+    promptKey: 'productDevelopment.trendIdentification.system',
+    name: '趋势洞察分析角色',
+    module: '新品开发驱动 / 趋势洞察',
+    scope: 'analysis',
+    description: '微观趋势、季节趋势和蓝海识别使用的系统提示词。',
+    template: '你是资深的国内电商新品趋势分析师。请基于类目趋势、平台商品、竞品评论、季节性和需求信号,输出当前阶段与增长动能、值得验证的细分需求、进入时机与风险。要求引用原始数据,禁止编造,使用 Markdown 格式。',
+    enabled: true,
+    variables: ['类目趋势', '平台商品', '竞品评论', '季节趋势', '需求信号'],
+    dataSources: ['DomesticProductDevelopmentComponent trend section', 'DomesticDatasetService.dataset$', '商品日明细 / 竞品评论'],
+  },
+  {
+    promptKey: 'productDevelopment.requirementMining.system',
+    name: '需求挖掘分析角色',
+    module: '新品开发驱动 / 需求挖掘',
+    scope: 'analysis',
+    description: '需求挖掘页面使用的系统提示词。',
+    template: '你是国内电商新品需求研究专家。请基于真实用户评论、痛点频次、竞品弱项和本品经营数据提炼最高价值的产品需求;区分证据、判断和待验证假设,使用 Markdown 格式给出产品定义、验证动作与验收指标。',
+    enabled: true,
+    variables: ['用户评论', '痛点频次', '竞品弱项', '本品经营数据'],
+    dataSources: ['DomesticProductDevelopmentComponent requirement section', 'DomesticDatasetService.dataset$', '竞品评论 / 商品日明细'],
+  },
+  {
+    promptKey: 'actionSuggestion.detail.system',
+    name: '行动建议深层分析角色',
+    module: '行动建议 / 建议详情',
+    scope: 'analysis',
+    description: '单条行动建议的 AI 深层分析和落地方案提示词。',
+    template: '你是一名国内电商 VOC 运营行动顾问。请严格基于行动建议、领域证据和页面上下文,把经营数据、评论证据、问题根因和执行动作串成中文落地方案。结论必须具体、可执行并带验收指标;证据不足时明确指出缺少的数据。',
+    enabled: true,
+    variables: ['行动建议', '领域证据', '根因', '行动计划', '页面上下文'],
+    dataSources: ['ActionSuggestionComponent.buildAiPrompt()', 'AiEvidenceContextService.buildActionSuggestionContext()', 'PageDataContextService.buildFullPrompt()'],
+  },
+  {
+    promptKey: 'global.analysis.groundingInstruction',
+    name: '全局数据边界约束',
+    module: 'AI 请求层',
+    scope: 'safety',
+    description: '所有 AI 分析和追问附加的数据真实性约束。',
+    template: '【数据边界约束】\n请严格基于用户提供的上下文、表格、样本和报告作答。上下文没有提供的数据必须标注“当前上下文未提供,需要补充数据验证”,不得编造具体数值。经验性建议必须标注为“待验证假设”或“建议补充的数据字段”。',
+    enabled: true,
+    variables: ['system message', 'user message'],
+    dataSources: ['AiAnalysisService.withGlobalInstructions()', 'all streamAnalysis/streamChat messages'],
+  },
+  {
+    promptKey: 'global.analysis.outputStyleInstruction',
+    name: 'AI分析服务输出风格指令',
+    module: 'AI分析服务',
+    scope: 'safety',
+    description: '输出风格策略命中所选 AI 分析提示词时追加。',
+    template: '',
+    enabled: false,
+    variables: ['system message', 'output style preset', 'target promptKey'],
+    dataSources: ['AiAnalysisService.withGlobalInstructions()', 'AiPromptConfigService.outputStylePresets', 'AI 分析提示词列表'],
+    targetKey: 'shared.analysisPanel.defaultSystem',
+  },
+  {
+    promptKey: 'global.analysis.defaultModel',
+    name: 'AI 分析默认模型',
+    module: 'AI 请求层',
+    scope: 'safety',
+    description: '页面未显式指定模型时使用的默认模型。',
+    template: 'deepseek-v4-pro',
+    enabled: true,
+    variables: ['model'],
+    dataSources: ['AiAnalysisService.streamDirect()', 'AI 请求 model 字段'],
+    model: 'deepseek-v4-pro',
+  },
+  {
+    promptKey: 'chat.followup.defaultSystem',
+    name: '多轮追问默认角色',
+    module: '共享组件 / AI追问弹窗',
+    scope: 'followup',
+    description: 'AI 报告后续追问使用的默认系统角色。',
+    template: '你是一名专业的国内电商 VOC 经营分析师。用户已经获得一份 AI 分析报告,现在希望围绕报告继续追问。请保留原始数据口径,明确区分本品与竞品、评分与正文情绪、事实与待验证假设,只依据已有上下文和报告给出专业、具体的中文回答,使用 Markdown 格式。',
+    enabled: true,
+    variables: ['原始数据上下文', '已生成分析报告', '用户追问'],
+    dataSources: ['AiChatModalComponent.initChat()', 'contextPrompt', 'reportText', 'chatHistory'],
+  },
+  {
+    promptKey: 'chat.followup.questionWrapper',
+    name: '多轮追问问题包装模板',
+    module: '共享组件 / AI追问弹窗',
+    scope: 'followup',
+    description: '把用户问题、页面上下文和已生成报告组合为追问请求。',
+    template: '请回答下面的用户问题。必须优先使用随后提供的页面上下文作为依据;如果上下文中没有对应数据,请明确说明数据不足,不要编造。\n\n### 用户问题\n{{question}}\n\n{{contextBlock}}',
+    enabled: true,
+    variables: ['{{question}}', '{{contextBlock}}'],
+    dataSources: ['AiChatModalComponent.withRequestContext()', 'AiChatModalComponent.buildContextBlock()', 'page context + generated report'],
+  },
+];

+ 95 - 0
src/modules/ai-gateway/prompt-config.repository.ts

@@ -0,0 +1,95 @@
+import { ParseRestClient } from '../../db/parse-rest.client.js';
+import { VOC_PARSE_CLASSES } from '../../db/parse-rest.schema.js';
+
+type PromptConfigParseClient = Pick<ParseRestClient, 'findAll' | 'findOne' | 'create' | 'update'>;
+
+export interface AiPromptConfigRecord {
+  naturalKey?: string;
+  workspaceId?: string;
+  promptKey: string;
+  name?: string;
+  module?: string;
+  scope?: string;
+  description?: string;
+  template?: string;
+  enabled?: boolean;
+  variables?: string[];
+  dataSources?: string[];
+  targetKey?: string;
+  targetStylesJson?: string;
+  model?: string;
+  revision?: number;
+  updatedBy?: string;
+  objectId?: string;
+  createdAt?: string;
+  updatedAt?: string;
+}
+
+export interface AiPromptConfigStore {
+  list(): Promise<AiPromptConfigRecord[]>;
+  upsert(promptKey: string, value: AiPromptConfigRecord): Promise<AiPromptConfigRecord>;
+}
+
+export class ParseRestAiPromptConfigStore implements AiPromptConfigStore {
+  constructor(
+    private readonly client: PromptConfigParseClient,
+    private readonly workspaceId: string,
+  ) {}
+
+  async list(): Promise<AiPromptConfigRecord[]> {
+    return this.client.findAll<AiPromptConfigRecord>(VOC_PARSE_CLASSES.promptConfig, {
+      workspaceId: this.workspaceId,
+    });
+  }
+
+  async upsert(promptKey: string, value: AiPromptConfigRecord): Promise<AiPromptConfigRecord> {
+    const naturalKey = this.naturalKey(promptKey);
+    const existing = await this.client.findOne<AiPromptConfigRecord>(VOC_PARSE_CLASSES.promptConfig, {
+      naturalKey,
+    });
+    const payload = {
+      naturalKey,
+      workspaceId: this.workspaceId,
+      promptKey,
+      name: value.name,
+      module: value.module,
+      scope: value.scope,
+      description: value.description,
+      template: value.template,
+      enabled: value.enabled,
+      variables: value.variables ?? [],
+      dataSources: value.dataSources ?? [],
+      targetKey: value.targetKey,
+      targetStylesJson: value.targetStylesJson,
+      model: value.model ?? (promptKey === 'global.analysis.defaultModel' ? value.template : undefined),
+      revision: (existing?.revision ?? 0) + 1,
+      updatedBy: value.updatedBy ?? 'api',
+    };
+    const cleanPayload = Object.fromEntries(
+      Object.entries(payload).filter((entry) => entry[1] !== undefined),
+    ) as Record<string, unknown>;
+
+    if (existing) {
+      const result = await this.client.update(VOC_PARSE_CLASSES.promptConfig, existing.objectId, cleanPayload);
+      return { ...existing, ...cleanPayload, updatedAt: result.updatedAt } as AiPromptConfigRecord;
+    }
+
+    const created = await this.client.create(VOC_PARSE_CLASSES.promptConfig, cleanPayload);
+    return { ...cleanPayload, ...created } as AiPromptConfigRecord;
+  }
+
+  async ensureDefaults(defaults: AiPromptConfigRecord[]): Promise<{ created: number; existing: number }> {
+    const existing = new Set((await this.list()).map((record) => record.promptKey));
+    let created = 0;
+    for (const record of defaults) {
+      if (existing.has(record.promptKey)) continue;
+      await this.upsert(record.promptKey, { ...record, updatedBy: 'system:bootstrap' });
+      created += 1;
+    }
+    return { created, existing: defaults.length - created };
+  }
+
+  private naturalKey(promptKey: string): string {
+    return `${this.workspaceId}::${promptKey}`;
+  }
+}

+ 193 - 0
src/modules/ai-gateway/routes.ts

@@ -0,0 +1,193 @@
+import { Router } from 'express';
+import { z } from 'zod';
+import { AiGatewayNotConfiguredError, FmodeAiClient } from './client.js';
+import type { AiPromptConfigStore } from './prompt-config.repository.js';
+
+const messageSchema = z.object({
+  role: z.enum(['system', 'user', 'assistant', 'developer']),
+  content: z.string().min(1).max(100_000),
+}).strict();
+
+const completionSchema = z.object({
+  messages: z.array(messageSchema).min(1).max(100),
+  model: z.string().min(1).max(120).optional(),
+  stream: z.boolean().default(true),
+  temperature: z.number().min(0).max(2).optional(),
+  presence_penalty: z.number().min(-2).max(2).optional(),
+  frequency_penalty: z.number().min(-2).max(2).optional(),
+  max_tokens: z.number().int().min(1).max(32_000).optional(),
+  response_format: z.object({ type: z.enum(['text', 'json_object']) }).strict().optional(),
+  thinking: z.object({ type: z.enum(['enabled', 'disabled']) }).strict().optional(),
+  websearch: z.boolean().optional(),
+}).strict();
+
+const promptConfigSchema = z.object({
+  promptKey: z.string().min(1).max(160),
+  name: z.string().max(200).optional(),
+  module: z.string().max(200).optional(),
+  scope: z.enum(['analysis', 'followup', 'safety']).optional(),
+  description: z.string().max(2_000).optional(),
+  template: z.string().max(100_000).optional(),
+  enabled: z.boolean().optional(),
+  variables: z.array(z.string().max(200)).max(200).optional(),
+  dataSources: z.array(z.string().max(500)).max(200).optional(),
+  targetKey: z.string().max(200).optional(),
+  targetStylesJson: z.string().max(200_000).optional(),
+  updatedAt: z.string().optional(),
+}).strict();
+
+function publicBaseUrl(value: string): string {
+  try {
+    return new URL(value).origin;
+  } catch {
+    return '';
+  }
+}
+
+async function readUpstreamBody(response: Response): Promise<Uint8Array> {
+  return new Uint8Array(await response.arrayBuffer());
+}
+
+function copyResponseHeaders(upstream: Response, response: import('express').Response): void {
+  const contentType = upstream.headers.get('content-type');
+  if (contentType) response.setHeader('content-type', contentType);
+  response.setHeader('cache-control', 'no-store');
+  response.setHeader('x-content-type-options', 'nosniff');
+}
+
+export function createAiGatewayRouter(client: FmodeAiClient, promptConfigs?: AiPromptConfigStore): Router {
+  const router = Router();
+
+  router.get('/status', (_request, response) => {
+    response.json({
+      service: 'Fmode AI',
+      configured: client.configured,
+      baseUrl: publicBaseUrl(client.config.baseUrl),
+      defaultModel: client.config.defaultModel,
+      proxyEndpoint: '/api/ai/chat/completions',
+    });
+  });
+
+  router.post('/test', async (_request, response, next) => {
+    const startedAt = Date.now();
+    try {
+      const upstream = await client.createChatCompletion({
+        messages: [{ role: 'user', content: '只回复 OK' }],
+        model: client.config.defaultModel,
+        stream: false,
+        max_tokens: 32,
+      });
+      const payload = await upstream.json().catch(() => null) as any;
+      if (!upstream.ok) {
+        response.status(502).json({
+          ok: false,
+          status: upstream.status,
+          latencyMs: Date.now() - startedAt,
+          error: payload?.error?.message || 'AI upstream request failed',
+        });
+        return;
+      }
+      response.json({
+        ok: true,
+        status: upstream.status,
+        latencyMs: Date.now() - startedAt,
+        model: String(payload?.model || client.config.defaultModel),
+        message: String(payload?.choices?.[0]?.message?.content || payload?.choices?.[0]?.message?.reasoning_content || 'OK').slice(0, 120),
+      });
+    } catch (error) {
+      if (error instanceof AiGatewayNotConfiguredError) {
+        response.status(503).json({ ok: false, error: 'ai_gateway_not_configured' });
+        return;
+      }
+      next(error);
+    }
+  });
+
+  router.get('/prompts', async (_request, response, next) => {
+    try {
+      response.json({ items: promptConfigs ? await promptConfigs.list() : [] });
+    } catch (error) {
+      next(error);
+    }
+  });
+
+  router.put('/prompts/:promptKey', async (request, response, next) => {
+    try {
+      const payload = promptConfigSchema.parse(request.body);
+      const promptKey = z.string().min(1).max(160).parse(request.params['promptKey']);
+      if (payload.promptKey !== promptKey) {
+        response.status(400).json({ error: 'prompt_key_mismatch' });
+        return;
+      }
+      if (!promptConfigs) {
+        response.json({ ...payload, persisted: false });
+        return;
+      }
+      const prompt = Object.fromEntries(
+        Object.entries(payload).filter((entry) => entry[1] !== undefined),
+      );
+      response.json(await promptConfigs.upsert(promptKey, prompt as { promptKey: string }));
+    } catch (error) {
+      next(error);
+    }
+  });
+
+  router.post('/chat/completions', async (request, response, next) => {
+    const controller = new AbortController();
+    request.once('aborted', () => controller.abort());
+
+    try {
+      const body = completionSchema.parse(request.body);
+      const upstream = await client.createChatCompletion(body, controller.signal);
+      copyResponseHeaders(upstream, response);
+
+      if (!upstream.ok || body.stream === false) {
+        const payload = await readUpstreamBody(upstream);
+        response.status(upstream.status).send(Buffer.from(payload));
+        return;
+      }
+
+      if (!upstream.body) {
+        response.status(502).json({ error: { message: 'AI upstream returned an empty stream' } });
+        return;
+      }
+
+      response.status(upstream.status);
+      response.flushHeaders();
+      for await (const chunk of upstream.body) {
+        response.write(chunk);
+      }
+      response.end();
+    } catch (error) {
+      if (error instanceof z.ZodError) {
+        response.status(400).json({
+          error: 'invalid_request',
+          issues: error.issues.map((issue) => ({ path: issue.path.join('.'), message: issue.message })),
+        });
+        return;
+      }
+      if (error instanceof AiGatewayNotConfiguredError) {
+        response.status(503).json({ error: { message: 'AI 服务尚未配置,请联系管理员' } });
+        return;
+      }
+      if (error instanceof DOMException && error.name === 'TimeoutError') {
+        response.status(504).json({ error: { message: 'AI 服务响应超时,请重试' } });
+        return;
+      }
+      if (error instanceof DOMException && error.name === 'AbortError') {
+        if (!response.headersSent) {
+          response.status(499).json({ error: { message: 'AI 请求已取消' } });
+        }
+        return;
+      }
+      if (error instanceof TypeError) {
+        console.error('[ai-gateway] upstream transport failed', error.message);
+        response.status(502).json({ error: { message: 'AI 上游连接失败,请稍后重试' } });
+        return;
+      }
+      next(error);
+    }
+  });
+
+  return router;
+}

+ 125 - 0
src/modules/domestic-voc/adapters/jd-search.adapter.ts

@@ -0,0 +1,125 @@
+import type { DomesticMetricSummary, DomesticProduct } from '../../../types/domestic-dataset.js';
+import { makeProductKey } from '../domain/identity.js';
+import {
+  firstNumber,
+  firstString,
+  firstValue,
+  isRecord,
+  parseDate,
+  unwrapGatewayPayload,
+  walkRecords,
+  type JsonRecord,
+} from './jd-response.js';
+
+export const JD_PRODUCT_SEARCH_PATH = 'jd/search-item-list/v1';
+
+export interface JdSearchContext {
+  brand: string;
+  category: string;
+  keyword: string;
+  collectedAt: string;
+}
+
+const EMPTY_SUMMARY: DomesticMetricSummary = {
+  gmv: 0,
+  soldUnits: 0,
+  transactionOrders: 0,
+  transactionCustomers: 0,
+  impressions: 0,
+  clicks: 0,
+  views: 0,
+  visitors: 0,
+  cartUnits: 0,
+  orderAmount: 0,
+  orderUnits: 0,
+  orderCount: 0,
+  refundAmount: 0,
+  refundUnits: 0,
+  refundOrders: 0,
+  conversionRate: 0,
+  clickThroughRate: 0,
+  averageUnitPrice: 0,
+  refundToGmvRate: 0,
+};
+
+export function adaptJdSearchResponse(response: unknown, context: JdSearchContext): DomesticProduct[] {
+  const payload = unwrapGatewayPayload(response);
+  return findProductRows(payload).flatMap((row): DomesticProduct[] => {
+    const productId = firstString(row, ['id', 'itemId', 'skuId', 'productId']);
+    const title = firstString(row, ['title', 'itemName', 'skuName', 'name']);
+    if (!productId || !title) return [];
+    const imageUrl = normalizeImageUrl(firstString(row, ['imageUrl', 'imageurl', 'mainImage']));
+    const price = firstNumber(row, ['price', 'jdPrice', 'currentPrice']) ?? 0;
+    const shopId = firstString(row, ['shopId', 'sid']);
+    const onShelvesAt = parseDate(firstValue(row, ['onShelvesTime', 'saleDate', 'onlineDate'])) ?? '';
+    return [{
+      platform: 'jd',
+      productId,
+      productKey: makeProductKey('jd', productId),
+      asin: productId,
+      role: 'competitor',
+      brand: context.brand,
+      title,
+      model: firstString(row, ['model', 'shortTitle']),
+      category1: '',
+      category2: context.category,
+      category3: context.category,
+      source: 'fmode_gateway',
+      relationCount: 0,
+      summary: { ...EMPTY_SUMMARY, averageUnitPrice: price },
+      trend: [],
+      detail: {
+        imageUrl,
+        images: imageUrl ? [imageUrl] : [],
+        color: '',
+        specification: firstString(row, ['shortTitle', 'specName']),
+        origin: '',
+        weightKg: '',
+        dimensionsMm: { length: '', width: '', height: '' },
+        saleDate: onShelvesAt,
+        shopId,
+        sellerId: firstString(row, ['venderId', 'sellerId']),
+        skuStatus: '',
+        jdExpress: Boolean(row.zy || row.jxzy),
+        localDelivery: false,
+        sellPoint: firstString(row, ['sellPoint', 'slogan', 'copywriting']),
+        collectedAt: context.collectedAt,
+      },
+      market: {
+        currentPrice: price,
+        salesText: firstString(row, ['sales']),
+        monthSalesText: firstString(row, ['monthSales']),
+        shopName: firstString(row, ['shopName']),
+        shopId,
+        searchKeyword: context.keyword,
+        onShelvesAt,
+        collectedAt: context.collectedAt,
+      },
+    }];
+  });
+}
+
+function findProductRows(payload: unknown): JsonRecord[] {
+  if (Array.isArray(payload)) return payload.filter(isProductRow);
+  for (const record of walkRecords(payload, 4)) {
+    const products = record.products;
+    if (!Array.isArray(products)) continue;
+    const rows = products.filter(isProductRow);
+    if (rows.length) return rows;
+  }
+  return [];
+}
+
+function isProductRow(value: unknown): value is JsonRecord {
+  return isRecord(value)
+    && Boolean(firstString(value, ['id', 'itemId', 'skuId', 'productId']))
+    && Boolean(firstString(value, ['title', 'itemName', 'skuName', 'name']));
+}
+
+function normalizeImageUrl(value: string): string {
+  const image = value.trim();
+  if (!image) return '';
+  if (/^https?:\/\//i.test(image)) return image;
+  if (image.startsWith('//')) return `https:${image}`;
+  return `https://img30.360buyimg.com/sku/${image.replace(/^\/+/, '')}`;
+}

+ 25 - 46
src/modules/domestic-voc/services/parse-rest-dataset-import.service.ts

@@ -35,24 +35,10 @@ const EMPTY_SUMMARY: DomesticMetricSummary = {
   refundToGmvRate: 0,
 };
 
-const DATA_CLASSES = [
-  VOC_PARSE_CLASSES.dailyMetric,
-  VOC_PARSE_CLASSES.review,
-  VOC_PARSE_CLASSES.productRelation,
-  VOC_PARSE_CLASSES.product,
-  VOC_PARSE_CLASSES.importBatch,
-] as const;
-
 function naturalKey(...parts: string[]): string {
   return parts.map((part) => encodeURIComponent(part)).join('|');
 }
 
-function chunks<T>(values: T[], size = 50): T[][] {
-  const output: T[][] = [];
-  for (let index = 0; index < values.length; index += size) output.push(values.slice(index, index + size));
-  return output;
-}
-
 function payloadChunks(values: Array<Record<string, unknown>>, maxBytes = 60_000): Array<Array<Record<string, unknown>>> {
   const output: Array<Array<Record<string, unknown>>> = [];
   let batch: Array<Record<string, unknown>> = [];
@@ -97,19 +83,17 @@ export class ParseRestDatasetImportService {
       status: 'completed',
     });
     if (existing) {
-      const counts = await this.counts(workspaceId, dataset.platform);
-      if (
-        counts.products === importedProducts.length
-        && counts.metrics === records.metrics.length
-        && counts.relations === records.relations.length
-        && counts.reviews === records.reviews.length
-      ) {
-        return { batchId: existing.publicId, ...counts, skipped: true };
-      }
+      return {
+        batchId: existing.publicId,
+        products: importedProducts.length,
+        metrics: records.metrics.length,
+        relations: records.relations.length,
+        reviews: records.reviews.length,
+        skipped: true,
+      };
     }
 
     await this.ensureWorkspace(workspaceId, dataset.caseName, principal);
-    await this.purgeDataset(workspaceId, dataset.platform);
 
     const batchId = randomUUID();
     const totalRows = importedProducts.length + records.metrics.length + records.relations.length + records.reviews.length;
@@ -141,7 +125,7 @@ export class ParseRestDatasetImportService {
       for (const relation of records.relations) {
         relationCount.set(relation.ownProductId, (relationCount.get(relation.ownProductId) ?? 0) + 1);
       }
-      await this.createMany(VOC_PARSE_CLASSES.product, importedProducts.map((product) => {
+      await this.upsertMany(VOC_PARSE_CLASSES.product, importedProducts.map((product) => {
         const sourceProduct = datasetProductById.get(product.productId);
         return {
           naturalKey: naturalKey(workspaceId, product.platform, product.productId),
@@ -163,7 +147,7 @@ export class ParseRestDatasetImportService {
         };
       }));
 
-      await this.createMany(VOC_PARSE_CLASSES.dailyMetric, records.metrics.map((metric) => ({
+      await this.upsertMany(VOC_PARSE_CLASSES.dailyMetric, records.metrics.map((metric) => ({
         naturalKey: naturalKey(workspaceId, metric.platform, metric.productId, metric.date, metric.source),
         workspaceId,
         platform: metric.platform,
@@ -188,7 +172,7 @@ export class ParseRestDatasetImportService {
       })));
 
       const productById = new Map(importedProducts.map((product) => [product.productId, product]));
-      await this.createMany(VOC_PARSE_CLASSES.productRelation, records.relations.map((relation) => {
+      await this.upsertMany(VOC_PARSE_CLASSES.productRelation, records.relations.map((relation) => {
         const own = productById.get(relation.ownProductId);
         return {
           naturalKey: naturalKey(workspaceId, dataset.platform, relation.relationKey),
@@ -208,7 +192,7 @@ export class ParseRestDatasetImportService {
         };
       }));
 
-      await this.createMany(VOC_PARSE_CLASSES.review, records.reviews.map((review) => ({
+      await this.upsertMany(VOC_PARSE_CLASSES.review, records.reviews.map((review) => ({
         naturalKey: naturalKey(workspaceId, dataset.platform, review.productId, review.reviewId),
         workspaceId,
         platform: dataset.platform,
@@ -298,25 +282,20 @@ export class ParseRestDatasetImportService {
     else await this.client.create(VOC_PARSE_CLASSES.sourceConnection, sourceBody);
   }
 
-  private async purgeDataset(workspaceId: string, platform: string): Promise<void> {
-    for (const className of DATA_CLASSES) {
-      const objects = await this.client.findAll(className, { workspaceId, platform });
-      for (const batch of chunks(objects)) {
-        await this.client.batch(batch.map((object) => ({
-          method: 'DELETE',
-          path: `/classes/${className}/${object.objectId}`,
-        })));
-      }
-    }
-  }
-
-  private async createMany(className: string, objects: Array<Record<string, unknown>>): Promise<void> {
+  private async upsertMany(className: string, objects: Array<Record<string, unknown>>): Promise<void> {
     for (const batch of payloadChunks(objects)) {
-      await this.client.batch(batch.map((body) => ({
-        method: 'POST',
-        path: `/classes/${className}`,
-        body,
-      })));
+      const naturalKeys = batch.map((body) => String(body.naturalKey || ''));
+      if (naturalKeys.some((value) => !value)) throw new Error(`${className} import row is missing naturalKey`);
+      const existing = await this.client.findAll<{ naturalKey: string }>(className, {
+        naturalKey: { $in: naturalKeys },
+      });
+      const byNaturalKey = new Map(existing.map((item) => [item.naturalKey, item]));
+      await this.client.batch(batch.map((body) => {
+        const current = byNaturalKey.get(String(body.naturalKey));
+        return current
+          ? { method: 'PUT' as const, path: `/classes/${className}/${current.objectId}`, body }
+          : { method: 'POST' as const, path: `/classes/${className}`, body };
+      }));
     }
   }
 }

+ 168 - 0
src/modules/product-knowledge/local-product-knowledge.store.ts

@@ -0,0 +1,168 @@
+import { mkdir, readFile, writeFile } from 'node:fs/promises';
+import { dirname } from 'node:path';
+import type { DomesticDataset } from '../../types/domestic-dataset.js';
+import type {
+  ProductKnowledgePage,
+  ProductKnowledgeRecord,
+  ProductKnowledgeRole,
+  ProductKnowledgeStatus,
+  ProductKnowledgeStore,
+} from './product-knowledge.store.js';
+
+interface LocalKnowledgeFile {
+  schemaVersion: 1;
+  items: ProductKnowledgeRecord[];
+}
+
+export class LocalProductKnowledgeStore implements ProductKnowledgeStore {
+  private readonly records = new Map<string, ProductKnowledgeRecord>();
+
+  constructor(
+    dataset: DomesticDataset,
+    private readonly workspaceId: string,
+    initialItems: ProductKnowledgeRecord[] = [],
+    private readonly persistencePath = '',
+    private readonly now: () => Date = () => new Date(),
+  ) {
+    for (const item of initialItems) {
+      if (item.workspaceId === workspaceId) this.records.set(item.productKey, { ...item, tags: [...item.tags] });
+    }
+    if (!this.records.size) this.seedTopProducts(dataset);
+  }
+
+  static async open(input: {
+    dataset: DomesticDataset;
+    workspaceId: string;
+    persistencePath: string;
+  }): Promise<LocalProductKnowledgeStore> {
+    let items: ProductKnowledgeRecord[] = [];
+    try {
+      const parsed = JSON.parse(await readFile(input.persistencePath, 'utf8')) as Partial<LocalKnowledgeFile>;
+      if (parsed.schemaVersion === 1 && Array.isArray(parsed.items)) items = parsed.items;
+    } catch (error) {
+      if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
+    }
+    const store = new LocalProductKnowledgeStore(
+      input.dataset,
+      input.workspaceId,
+      items,
+      input.persistencePath,
+    );
+    await store.persist();
+    return store;
+  }
+
+  async list(input: {
+    workspaceId: string;
+    limit: number;
+    cursor: string | null;
+    status?: ProductKnowledgeStatus;
+    productRole?: ProductKnowledgeRole;
+    featured?: boolean;
+  }): Promise<ProductKnowledgePage> {
+    if (input.workspaceId !== this.workspaceId) return { items: [], nextCursor: null };
+    const items = [...this.records.values()]
+      .filter((item) => !input.status || item.status === input.status)
+      .filter((item) => !input.productRole || item.productRole === input.productRole)
+      .filter((item) => input.featured === undefined || item.featured === input.featured)
+      .sort((left, right) => left.productKey.localeCompare(right.productKey));
+    const cursorIndex = input.cursor ? items.findIndex((item) => item.productKey === input.cursor) : -1;
+    const start = cursorIndex + 1;
+    const page = items.slice(start, start + input.limit + 1);
+    const hasMore = page.length > input.limit;
+    const visible = page.slice(0, input.limit).map((item) => ({ ...item, tags: [...item.tags] }));
+    return {
+      items: visible,
+      nextCursor: hasMore ? visible.at(-1)?.productKey ?? null : null,
+    };
+  }
+
+  async upsert(input: {
+    workspaceId: string;
+    productKey: string;
+    productId: string;
+    productRole: ProductKnowledgeRole;
+    featured: boolean;
+    status: ProductKnowledgeStatus;
+    tags: string[];
+    note: string;
+    ownerUserId: string;
+    actorUserId: string;
+  }): Promise<ProductKnowledgeRecord> {
+    const existing = this.records.get(input.productKey);
+    const timestamp = this.now().toISOString();
+    const item: ProductKnowledgeRecord = {
+      id: existing?.id ?? `local-${input.productKey.replace(/[^a-zA-Z0-9_-]/g, '-')}`,
+      workspaceId: input.workspaceId,
+      productKey: input.productKey,
+      productId: input.productId,
+      productRole: input.productRole,
+      featured: input.featured,
+      status: input.status,
+      tags: [...new Set(input.tags.map((tag) => tag.trim()).filter(Boolean))],
+      note: input.note.trim(),
+      ownerUserId: input.ownerUserId.trim(),
+      createdBy: existing?.createdBy ?? input.actorUserId,
+      updatedBy: input.actorUserId,
+      createdAt: existing?.createdAt ?? timestamp,
+      updatedAt: timestamp,
+    };
+    this.records.set(input.productKey, item);
+    await this.persist();
+    return { ...item, tags: [...item.tags] };
+  }
+
+  async archive(workspaceId: string, productKey: string, actorUserId: string): Promise<ProductKnowledgeRecord | null> {
+    if (workspaceId !== this.workspaceId) return null;
+    const existing = this.records.get(productKey);
+    if (!existing) return null;
+    const item: ProductKnowledgeRecord = {
+      ...existing,
+      featured: false,
+      status: 'archived',
+      updatedBy: actorUserId,
+      updatedAt: this.now().toISOString(),
+    };
+    this.records.set(productKey, item);
+    await this.persist();
+    return { ...item, tags: [...item.tags] };
+  }
+
+  private seedTopProducts(dataset: DomesticDataset): void {
+    const timestamp = this.now().toISOString();
+    const candidates = [...dataset.products]
+      .filter((product) => product.role === 'own')
+      .sort((left, right) => right.summary.gmv - left.summary.gmv || right.summary.soldUnits - left.summary.soldUnits)
+      .slice(0, 8);
+    for (const product of candidates) {
+      const category = product.category3 || product.category2 || product.category1;
+      const item: ProductKnowledgeRecord = {
+        id: `local-${product.productKey.replace(/[^a-zA-Z0-9_-]/g, '-')}`,
+        workspaceId: this.workspaceId,
+        productKey: product.productKey,
+        productId: product.productId,
+        productRole: product.role,
+        featured: true,
+        status: 'active',
+        tags: ['经营TOP', ...(category ? [category] : [])],
+        note: '按成交金额初始化的重点商品,可在知识库内补充关注事项。',
+        ownerUserId: '',
+        createdBy: 'local-bootstrap',
+        updatedBy: 'local-bootstrap',
+        createdAt: timestamp,
+        updatedAt: timestamp,
+      };
+      this.records.set(product.productKey, item);
+    }
+  }
+
+  private async persist(): Promise<void> {
+    if (!this.persistencePath) return;
+    await mkdir(dirname(this.persistencePath), { recursive: true });
+    const payload: LocalKnowledgeFile = {
+      schemaVersion: 1,
+      items: [...this.records.values()].sort((left, right) => left.productKey.localeCompare(right.productKey)),
+    };
+    await writeFile(this.persistencePath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
+  }
+}

+ 170 - 0
src/modules/product-knowledge/product-knowledge.store.ts

@@ -0,0 +1,170 @@
+import { ParseRestClient, type ParseObject } from '../../db/parse-rest.client.js';
+import { VOC_PARSE_CLASSES } from '../../db/parse-rest.schema.js';
+
+export type ProductKnowledgeRole = 'own' | 'competitor';
+export type ProductKnowledgeStatus = 'active' | 'archived';
+
+export interface ProductKnowledgeRecord {
+  id: string;
+  workspaceId: string;
+  productKey: string;
+  productId: string;
+  productRole: ProductKnowledgeRole;
+  featured: boolean;
+  status: ProductKnowledgeStatus;
+  tags: string[];
+  note: string;
+  ownerUserId: string;
+  createdBy: string;
+  updatedBy: string;
+  createdAt: string;
+  updatedAt: string;
+}
+
+export interface ProductKnowledgePage {
+  items: ProductKnowledgeRecord[];
+  nextCursor: string | null;
+}
+
+export interface ProductKnowledgeStore {
+  list(input: {
+    workspaceId: string;
+    limit: number;
+    cursor: string | null;
+    status?: ProductKnowledgeStatus;
+    productRole?: ProductKnowledgeRole;
+    featured?: boolean;
+  }): Promise<ProductKnowledgePage>;
+  upsert(input: {
+    workspaceId: string;
+    productKey: string;
+    productId: string;
+    productRole: ProductKnowledgeRole;
+    featured: boolean;
+    status: ProductKnowledgeStatus;
+    tags: string[];
+    note: string;
+    ownerUserId: string;
+    actorUserId: string;
+  }): Promise<ProductKnowledgeRecord>;
+  archive(workspaceId: string, productKey: string, actorUserId: string): Promise<ProductKnowledgeRecord | null>;
+}
+
+interface ProductKnowledgeObject {
+  naturalKey: string;
+  workspaceId: string;
+  productKey: string;
+  productId: string;
+  productRole: ProductKnowledgeRole;
+  featured: boolean;
+  status: ProductKnowledgeStatus;
+  tags: string[];
+  note?: string;
+  ownerUserId?: string;
+  createdBy: string;
+  updatedBy: string;
+}
+
+type ProductKnowledgeParseClient = Pick<ParseRestClient, 'find' | 'findOne' | 'create' | 'update'>;
+
+export class ParseRestProductKnowledgeStore implements ProductKnowledgeStore {
+  constructor(private readonly client: ProductKnowledgeParseClient) {}
+
+  async list(input: {
+    workspaceId: string;
+    limit: number;
+    cursor: string | null;
+    status?: ProductKnowledgeStatus;
+    productRole?: ProductKnowledgeRole;
+    featured?: boolean;
+  }): Promise<ProductKnowledgePage> {
+    const where: Record<string, unknown> = { workspaceId: input.workspaceId };
+    if (input.cursor) where.objectId = { $gt: input.cursor };
+    if (input.status) where.status = input.status;
+    if (input.productRole) where.productRole = input.productRole;
+    if (input.featured !== undefined) where.featured = input.featured;
+    const response = await this.client.find<ProductKnowledgeObject>(VOC_PARSE_CLASSES.productKnowledge, {
+      where,
+      order: 'objectId',
+      limit: input.limit + 1,
+    });
+    const hasMore = response.results.length > input.limit;
+    const page = response.results.slice(0, input.limit);
+    return {
+      items: page.map(mapRecord),
+      nextCursor: hasMore ? page.at(-1)?.objectId ?? null : null,
+    };
+  }
+
+  async upsert(input: {
+    workspaceId: string;
+    productKey: string;
+    productId: string;
+    productRole: ProductKnowledgeRole;
+    featured: boolean;
+    status: ProductKnowledgeStatus;
+    tags: string[];
+    note: string;
+    ownerUserId: string;
+    actorUserId: string;
+  }): Promise<ProductKnowledgeRecord> {
+    const naturalKey = this.naturalKey(input.workspaceId, input.productKey);
+    const existing = await this.client.findOne<ProductKnowledgeObject>(VOC_PARSE_CLASSES.productKnowledge, { naturalKey });
+    const payload = {
+      naturalKey,
+      workspaceId: input.workspaceId,
+      productKey: input.productKey,
+      productId: input.productId,
+      productRole: input.productRole,
+      featured: input.featured,
+      status: input.status,
+      tags: [...new Set(input.tags.map((tag) => tag.trim()).filter(Boolean))],
+      note: input.note.trim(),
+      ownerUserId: input.ownerUserId.trim(),
+      createdBy: existing?.createdBy ?? input.actorUserId,
+      updatedBy: input.actorUserId,
+    };
+    if (existing) {
+      const result = await this.client.update(VOC_PARSE_CLASSES.productKnowledge, existing.objectId, payload);
+      return mapRecord({ ...existing, ...payload, updatedAt: result.updatedAt });
+    }
+    const result = await this.client.create(VOC_PARSE_CLASSES.productKnowledge, payload);
+    return mapRecord({ ...payload, ...result });
+  }
+
+  async archive(workspaceId: string, productKey: string, actorUserId: string): Promise<ProductKnowledgeRecord | null> {
+    const existing = await this.client.findOne<ProductKnowledgeObject>(VOC_PARSE_CLASSES.productKnowledge, {
+      naturalKey: this.naturalKey(workspaceId, productKey),
+    });
+    if (!existing) return null;
+    const result = await this.client.update(VOC_PARSE_CLASSES.productKnowledge, existing.objectId, {
+      status: 'archived',
+      featured: false,
+      updatedBy: actorUserId,
+    });
+    return mapRecord({ ...existing, status: 'archived', featured: false, updatedBy: actorUserId, updatedAt: result.updatedAt });
+  }
+
+  private naturalKey(workspaceId: string, productKey: string): string {
+    return `${workspaceId}:${productKey}`;
+  }
+}
+
+function mapRecord(item: ProductKnowledgeObject & ParseObject): ProductKnowledgeRecord {
+  return {
+    id: item.objectId,
+    workspaceId: item.workspaceId,
+    productKey: item.productKey,
+    productId: item.productId,
+    productRole: item.productRole,
+    featured: Boolean(item.featured),
+    status: item.status,
+    tags: item.tags ?? [],
+    note: item.note ?? '',
+    ownerUserId: item.ownerUserId ?? '',
+    createdBy: item.createdBy,
+    updatedBy: item.updatedBy,
+    createdAt: item.createdAt,
+    updatedAt: item.updatedAt,
+  };
+}

+ 109 - 0
src/modules/product-knowledge/routes.ts

@@ -0,0 +1,109 @@
+import { Router } from 'express';
+import { z } from 'zod';
+import { ApiError } from '../../http/api-error.js';
+import { getPrincipal, type WorkspaceAccessService } from '../saas-platform/auth.js';
+import type { PlatformRepository } from '../saas-platform/domain.js';
+import type { ProductKnowledgeStore } from './product-knowledge.store.js';
+
+const querySchema = z.object({
+  workspaceId: z.string().min(1).optional(),
+  limit: z.coerce.number().int().min(1).max(100).default(100),
+  cursor: z.string().max(200).optional(),
+  status: z.enum(['active', 'archived']).optional(),
+  productRole: z.enum(['own', 'competitor']).optional(),
+  featured: z.enum(['true', 'false']).transform((value) => value === 'true').optional(),
+});
+
+const upsertSchema = z.object({
+  workspaceId: z.string().min(1).optional(),
+  productKey: z.string().min(1).max(300),
+  productId: z.string().min(1).max(200),
+  productRole: z.enum(['own', 'competitor']),
+  featured: z.boolean().default(false),
+  status: z.enum(['active', 'archived']).default('active'),
+  tags: z.array(z.string().trim().min(1).max(50)).max(20).default([]),
+  note: z.string().max(5_000).default(''),
+  ownerUserId: z.string().max(200).default(''),
+});
+
+export function createProductKnowledgeRouter(input: {
+  store: ProductKnowledgeStore;
+  repository: PlatformRepository;
+  access: WorkspaceAccessService;
+  defaultWorkspaceId: string;
+}): Router {
+  const router = Router();
+
+  router.get('/products', async (request, response, next) => {
+    try {
+      const query = querySchema.parse(request.query);
+      const workspaceId = query.workspaceId ?? input.defaultWorkspaceId;
+      await input.access.require(request, workspaceId, 'workspace:read');
+      response.json(await input.store.list({
+        workspaceId,
+        limit: query.limit,
+        cursor: query.cursor ?? null,
+        ...(query.status ? { status: query.status } : {}),
+        ...(query.productRole ? { productRole: query.productRole } : {}),
+        ...(query.featured !== undefined ? { featured: query.featured } : {}),
+      }));
+    } catch (error) { next(error); }
+  });
+
+  router.put('/products', async (request, response, next) => {
+    try {
+      const body = upsertSchema.parse(request.body);
+      const workspaceId = body.workspaceId ?? input.defaultWorkspaceId;
+      await input.access.require(request, workspaceId, 'action:write');
+      const platform = body.productKey.split(':', 1)[0] || 'jd';
+      const product = await input.repository.getProduct(workspaceId, platform, body.productId);
+      if (!product || product.productKey !== body.productKey || product.role !== body.productRole) {
+        throw new ApiError(404, 'knowledge_product_not_found');
+      }
+      const principal = getPrincipal(request);
+      const item = await input.store.upsert({
+        workspaceId,
+        productKey: body.productKey,
+        productId: body.productId,
+        productRole: body.productRole,
+        featured: body.featured,
+        status: body.status,
+        tags: body.tags,
+        note: body.note,
+        ownerUserId: body.ownerUserId,
+        actorUserId: principal.userId,
+      });
+      await input.repository.appendAudit({
+        workspaceId,
+        actorUserId: principal.userId,
+        action: 'product_knowledge.upserted',
+        entityType: 'product_knowledge',
+        entityId: item.id,
+        metadata: { productKey: item.productKey, featured: item.featured, status: item.status },
+      });
+      response.json({ item });
+    } catch (error) { next(error); }
+  });
+
+  router.delete('/products/:productKey', async (request, response, next) => {
+    try {
+      const workspaceId = z.string().min(1).parse(request.query.workspaceId ?? input.defaultWorkspaceId);
+      const productKey = z.string().min(1).max(300).parse(request.params.productKey);
+      await input.access.require(request, workspaceId, 'action:write');
+      const principal = getPrincipal(request);
+      const item = await input.store.archive(workspaceId, productKey, principal.userId);
+      if (!item) throw new ApiError(404, 'product_knowledge_not_found');
+      await input.repository.appendAudit({
+        workspaceId,
+        actorUserId: principal.userId,
+        action: 'product_knowledge.archived',
+        entityType: 'product_knowledge',
+        entityId: item.id,
+        metadata: { productKey },
+      });
+      response.json({ item });
+    } catch (error) { next(error); }
+  });
+
+  return router;
+}

+ 359 - 2
src/modules/saas-platform/domain.ts

@@ -1,3 +1,4 @@
+import { ApiError } from '../../http/api-error.js';
 import type { DomesticDataset, DomesticProduct, DomesticProductRelation, DomesticReview } from '../../types/domestic-dataset.js';
 import type { SyncJobRecord } from '../domestic-voc/repositories/sync-job.repository.js';
 
@@ -75,7 +76,7 @@ export type AnalysisRunStatus = 'pending' | 'processing' | 'completed' | 'partia
 export interface AnalysisRun {
   id: string;
   workspaceId: string;
-  analysisType: 'voice' | 'pain_point' | 'feature' | 'scenario' | 'risk' | 'report';
+  analysisType: 'voice' | 'pain_point' | 'feature' | 'scenario' | 'risk' | 'report' | 'voc_insight';
   targetKind: 'workspace' | 'category' | 'product';
   targetKey: string;
   status: AnalysisRunStatus;
@@ -89,9 +90,315 @@ export interface AnalysisRun {
   completedAt: string | null;
 }
 
+export type AnalysisRunPatch = Partial<Pick<AnalysisRun, 'status' | 'result' | 'evidenceCount' | 'errorSummary' | 'startedAt' | 'completedAt'>>;
+
+const TERMINAL_ANALYSIS_STATUSES: ReadonlySet<AnalysisRunStatus> = new Set([
+  'completed', 'partial', 'failed', 'cancelled',
+]);
+
+const ALLOWED_ANALYSIS_TRANSITIONS: Record<AnalysisRunStatus, ReadonlySet<AnalysisRunStatus>> = {
+  pending: new Set(['processing', 'cancelled', 'failed']),
+  processing: new Set(['completed', 'partial', 'failed', 'cancelled']),
+  completed: new Set(),
+  partial: new Set(),
+  failed: new Set(),
+  cancelled: new Set(),
+};
+
+export function prepareAnalysisRunPatch(
+  current: AnalysisRun,
+  patch: AnalysisRunPatch,
+  now = new Date().toISOString(),
+): AnalysisRunPatch {
+  if (Object.keys(patch).length === 0) throw new ApiError(400, 'analysis_patch_required');
+  if (TERMINAL_ANALYSIS_STATUSES.has(current.status)) {
+    throw new ApiError(409, 'analysis_terminal');
+  }
+
+  const nextStatus = patch.status ?? current.status;
+  if (patch.status && patch.status !== current.status && !ALLOWED_ANALYSIS_TRANSITIONS[current.status].has(nextStatus)) {
+    throw new ApiError(409, 'analysis_invalid_transition');
+  }
+
+  const result = Object.hasOwn(patch, 'result') ? patch.result : current.result;
+  const errorSummary = Object.hasOwn(patch, 'errorSummary') ? patch.errorSummary : current.errorSummary;
+  if ((nextStatus === 'completed' || nextStatus === 'partial') && !result) {
+    throw new ApiError(400, 'analysis_result_required');
+  }
+  if (nextStatus === 'failed' && !errorSummary?.trim()) {
+    throw new ApiError(400, 'analysis_error_summary_required');
+  }
+
+  const normalized: AnalysisRunPatch = { ...patch };
+  if (nextStatus === 'processing' && !current.startedAt && !Object.hasOwn(patch, 'startedAt')) {
+    normalized.startedAt = now;
+  }
+  if (TERMINAL_ANALYSIS_STATUSES.has(nextStatus) && !current.completedAt && !Object.hasOwn(patch, 'completedAt')) {
+    normalized.completedAt = now;
+  }
+  return normalized;
+}
+
+export type InsightDecisionValue = 'confirmed' | 'rejected' | 'needs_more_evidence';
+
+export interface InsightDecision {
+  id: string;
+  workspaceId: string;
+  sourceAnalysisId: string;
+  sourceInsightId: string;
+  decision: InsightDecisionValue;
+  reviewedEvidenceIds: string[];
+  comment: string;
+  decidedBy: string;
+  decidedAt: string;
+  version: number;
+  supersedesId: string | null;
+  isCurrent: boolean;
+  createdAt: string;
+  updatedAt: string;
+}
+
+export type InsightDecisionCreateInput = Omit<
+  InsightDecision,
+  'decidedAt' | 'version' | 'supersedesId' | 'isCurrent' | 'createdAt' | 'updatedAt'
+>;
+
+export function prepareInsightDecision(
+  input: Pick<
+    InsightDecisionCreateInput,
+    'workspaceId' | 'sourceInsightId' | 'decision' | 'reviewedEvidenceIds' | 'comment'
+  >,
+  analysis: AnalysisRun | null,
+): Pick<InsightDecisionCreateInput, 'reviewedEvidenceIds' | 'comment'> {
+  if (!analysis || analysis.workspaceId !== input.workspaceId) {
+    throw new ApiError(400, 'insight_decision_analysis_not_found');
+  }
+  if (analysis.analysisType !== 'voc_insight') {
+    throw new ApiError(400, 'insight_decision_analysis_not_voc_insight');
+  }
+  if (analysis.status !== 'completed' && analysis.status !== 'partial') {
+    throw new ApiError(400, 'insight_decision_analysis_not_ready');
+  }
+
+  const insights = analysis.result?.insights;
+  const insight = Array.isArray(insights)
+    ? insights.find((item) => (
+      typeof item === 'object'
+      && item !== null
+      && (item as Record<string, unknown>).id === input.sourceInsightId
+    )) as Record<string, unknown> | undefined
+    : undefined;
+  if (!insight) {
+    throw new ApiError(400, 'insight_decision_insight_not_found');
+  }
+
+  if (input.reviewedEvidenceIds.length > 100) {
+    throw new ApiError(400, 'insight_decision_evidence_limit_exceeded');
+  }
+  const reviewedEvidenceIds = [...new Set(input.reviewedEvidenceIds.map((id) => id.trim()))];
+  if (reviewedEvidenceIds.length === 0 || reviewedEvidenceIds.some((id) => !id)) {
+    throw new ApiError(400, 'insight_decision_evidence_required');
+  }
+  const insightEvidenceIds = new Set(
+    Array.isArray(insight.evidenceIds)
+      ? insight.evidenceIds.filter((id): id is string => typeof id === 'string')
+      : [],
+  );
+  if (reviewedEvidenceIds.some((id) => !insightEvidenceIds.has(id))) {
+    throw new ApiError(400, 'insight_decision_evidence_not_in_insight');
+  }
+  if ([...insightEvidenceIds].some((id) => !reviewedEvidenceIds.includes(id))) {
+    throw new ApiError(400, 'insight_decision_evidence_incomplete');
+  }
+
+  const comment = input.comment.trim();
+  if (input.decision !== 'confirmed' && !comment) {
+    throw new ApiError(400, 'insight_decision_comment_required');
+  }
+  if (analysis.result?.mode === 'deterministic' && input.decision !== 'needs_more_evidence') {
+    throw new ApiError(400, 'insight_decision_deterministic_requires_more_evidence');
+  }
+
+  return { reviewedEvidenceIds, comment };
+}
+
+export interface ActionSourceReference {
+  workspaceId: string;
+  sourceAnalysisId: string | null;
+  sourceInsightId: string | null;
+  sourceDecisionId?: string | null;
+  sourceKind?: string;
+  creationKey?: string;
+  evidenceIds: string[];
+}
+
+export type ActionSourceKind = 'insight' | 'raw_feedback' | 'rule_action';
+
+export function resolveActionSourceKind(source: {
+  sourceAnalysisId: string | null;
+  sourceInsightId: string | null;
+  sourceDecisionId?: string | null | undefined;
+  sourceKind?: string | undefined;
+}): ActionSourceKind {
+  const hasInsightReference = Boolean(
+    source.sourceAnalysisId || source.sourceInsightId || source.sourceDecisionId,
+  );
+  if (hasInsightReference) {
+    if (source.sourceKind && source.sourceKind !== 'insight') {
+      throw new ApiError(400, 'insight_source_requires_insight_kind');
+    }
+    return 'insight';
+  }
+  if (!source.sourceKind) return 'rule_action';
+  if (
+    source.sourceKind === 'insight'
+    || source.sourceKind === 'raw_feedback'
+    || source.sourceKind === 'rule_action'
+  ) {
+    return source.sourceKind;
+  }
+  throw new ApiError(400, 'invalid_action_source_kind');
+}
+
+export interface ActionDecisionReference extends ActionSourceReference {
+  sourceDecisionId: string | null;
+  sourceKind: ActionSourceKind;
+  actionType: ActionItem['actionType'];
+  validationMetric: string;
+}
+
+export function assertValidActionDecisionSource(
+  source: ActionDecisionReference,
+  decision: InsightDecision | null,
+): void {
+  if (source.sourceKind !== 'insight') {
+    if (source.sourceAnalysisId || source.sourceInsightId || source.sourceDecisionId) {
+      throw new ApiError(400, 'insight_source_requires_insight_kind');
+    }
+    return;
+  }
+  if (!source.sourceAnalysisId || !source.sourceInsightId) {
+    throw new ApiError(400, 'source_decision_requires_insight_source');
+  }
+  if (!source.sourceDecisionId) {
+    throw new ApiError(400, 'source_decision_required');
+  }
+  if (!decision || decision.workspaceId !== source.workspaceId) {
+    throw new ApiError(400, 'source_decision_not_found');
+  }
+  if (
+    decision.sourceAnalysisId !== source.sourceAnalysisId
+    || decision.sourceInsightId !== source.sourceInsightId
+  ) {
+    throw new ApiError(400, 'source_decision_mismatch');
+  }
+  if (!decision.isCurrent) {
+    throw new ApiError(409, 'source_decision_superseded');
+  }
+  const reviewedEvidenceIds = new Set(decision.reviewedEvidenceIds);
+  if (source.evidenceIds.some((id) => !reviewedEvidenceIds.has(id))) {
+    throw new ApiError(400, 'source_evidence_not_reviewed');
+  }
+  if (decision.decision === 'rejected') {
+    throw new ApiError(409, 'source_decision_rejected');
+  }
+  if (decision.decision === 'needs_more_evidence') {
+    if (source.actionType !== 'data_quality') {
+      throw new ApiError(400, 'source_decision_requires_data_quality_action');
+    }
+    if (!source.validationMetric.trim()) {
+      throw new ApiError(400, 'source_decision_validation_metric_required');
+    }
+    return;
+  }
+  if (source.actionType === 'data_quality') {
+    throw new ApiError(400, 'confirmed_decision_requires_formal_action');
+  }
+}
+
+export function buildActionCreationKey(source: Pick<
+  ActionDecisionReference,
+  'workspaceId' | 'sourceAnalysisId' | 'sourceInsightId' | 'sourceDecisionId' | 'sourceKind' | 'creationKey'
+>): string {
+  if (
+    source.sourceKind === 'insight'
+    && source.sourceAnalysisId
+    && source.sourceInsightId
+    && source.sourceDecisionId
+  ) {
+    return [
+      source.workspaceId,
+      source.sourceAnalysisId,
+      source.sourceInsightId,
+      source.sourceDecisionId,
+    ].join('|');
+  }
+  return source.creationKey?.trim() || '';
+}
+
+export function assertValidActionSource(
+  source: ActionSourceReference,
+  analysis: AnalysisRun | null,
+): void {
+  if (!source.sourceAnalysisId) {
+    if (source.sourceInsightId) throw new ApiError(400, 'source_insight_orphan');
+    return;
+  }
+  if (!analysis || analysis.workspaceId !== source.workspaceId) {
+    throw new ApiError(400, 'source_analysis_not_found');
+  }
+  if (analysis.analysisType !== 'voc_insight') {
+    throw new ApiError(400, 'source_analysis_not_voc_insight');
+  }
+  if (analysis.status !== 'completed' && analysis.status !== 'partial') {
+    throw new ApiError(400, 'source_analysis_not_ready');
+  }
+  if (!source.sourceInsightId) {
+    throw new ApiError(400, 'source_insight_required');
+  }
+
+  const insights = analysis.result?.insights;
+  const insight = Array.isArray(insights)
+    ? insights.find((item) => (
+      typeof item === 'object'
+      && item !== null
+      && (item as Record<string, unknown>).id === source.sourceInsightId
+    )) as Record<string, unknown> | undefined
+    : undefined;
+  if (!insight) {
+    throw new ApiError(400, 'source_insight_not_found');
+  }
+  if (source.evidenceIds.length === 0) {
+    throw new ApiError(400, 'source_evidence_required');
+  }
+
+  const insightEvidenceIds = new Set(
+    Array.isArray(insight.evidenceIds)
+      ? insight.evidenceIds.filter((id): id is string => typeof id === 'string')
+      : [],
+  );
+  if (source.evidenceIds.some((id) => !insightEvidenceIds.has(id))) {
+    throw new ApiError(400, 'source_evidence_not_in_insight');
+  }
+}
+
+export function normalizeActionEvidenceIds(evidenceIds: string[]): string[] {
+  if (evidenceIds.length > 100) {
+    throw new ApiError(400, 'action_evidence_limit_exceeded');
+  }
+  return [...new Set(evidenceIds)];
+}
+
 export interface ActionItem {
   id: string;
   workspaceId: string;
+  sourceAnalysisId: string | null;
+  sourceInsightId: string | null;
+  sourceDecisionId: string | null;
+  sourceKind: ActionSourceKind;
+  creationKey: string;
+  evidenceIds: string[];
+  validationMetric: string;
   actionType: 'general' | 'experience' | 'product' | 'strategy' | 'data_quality';
   title: string;
   description: string;
@@ -106,6 +413,43 @@ export interface ActionItem {
   updatedAt: string;
 }
 
+export type ActionCreationIdentity = Pick<
+  ActionItem,
+  | 'workspaceId'
+  | 'sourceAnalysisId'
+  | 'sourceInsightId'
+  | 'sourceDecisionId'
+  | 'sourceKind'
+  | 'creationKey'
+  | 'evidenceIds'
+  | 'validationMetric'
+  | 'actionType'
+  | 'productKey'
+>;
+
+export function assertMatchingActionCreation(
+  existing: ActionItem,
+  requested: ActionCreationIdentity,
+): void {
+  const evidenceKey = (ids: string[]) => [...new Set(ids)].sort().join('\u0000');
+  const matches = existing.workspaceId === requested.workspaceId
+    && existing.sourceAnalysisId === requested.sourceAnalysisId
+    && existing.sourceInsightId === requested.sourceInsightId
+    && existing.sourceDecisionId === requested.sourceDecisionId
+    && existing.sourceKind === requested.sourceKind
+    && existing.creationKey === requested.creationKey
+    && evidenceKey(existing.evidenceIds) === evidenceKey(requested.evidenceIds)
+    && existing.validationMetric.trim() === requested.validationMetric.trim()
+    && existing.actionType === requested.actionType
+    && existing.productKey === requested.productKey;
+  if (!matches) throw new ApiError(409, 'action_creation_key_conflict');
+}
+
+export type ActionItemCreateInput = Omit<
+  ActionItem,
+  'sourceDecisionId' | 'sourceKind' | 'creationKey' | 'completedAt' | 'createdAt' | 'updatedAt'
+> & Partial<Pick<ActionItem, 'sourceDecisionId' | 'sourceKind' | 'creationKey'>>;
+
 export interface AlertItem {
   id: string;
   workspaceId: string;
@@ -164,9 +508,22 @@ export interface PlatformRepository {
   listDataSources(workspaceId: string): Promise<DataSourceSummary[]>;
   listImports(input: { workspaceId: string; limit: number; cursor: string | null }): Promise<CursorPage<ImportBatchSummary>>;
   listAnalyses(input: { workspaceId: string; limit: number; cursor: string | null; status: string }): Promise<CursorPage<AnalysisRun>>;
+  getAnalysis(workspaceId: string, id: string): Promise<AnalysisRun | null>;
   createAnalysis(input: Omit<AnalysisRun, 'status' | 'result' | 'evidenceCount' | 'errorSummary' | 'requestedAt' | 'startedAt' | 'completedAt'>): Promise<AnalysisRun>;
+  updateAnalysis(workspaceId: string, id: string, patch: AnalysisRunPatch): Promise<AnalysisRun | null>;
+  listInsightDecisions(input: {
+    workspaceId: string;
+    limit: number;
+    cursor: string | null;
+    sourceAnalysisId?: string;
+    sourceInsightId?: string;
+    currentOnly?: boolean;
+  }): Promise<CursorPage<InsightDecision>>;
+  getInsightDecision(workspaceId: string, id: string): Promise<InsightDecision | null>;
+  createInsightDecision(input: InsightDecisionCreateInput): Promise<InsightDecision>;
   listActions(input: { workspaceId: string; limit: number; cursor: string | null; status: string }): Promise<CursorPage<ActionItem>>;
-  createAction(input: Omit<ActionItem, 'completedAt' | 'createdAt' | 'updatedAt'>): Promise<ActionItem>;
+  getActionByCreationKey(workspaceId: string, creationKey: string): Promise<ActionItem | null>;
+  createAction(input: ActionItemCreateInput): Promise<ActionItem>;
   updateAction(workspaceId: string, id: string, patch: Partial<Pick<ActionItem, 'title' | 'description' | 'priority' | 'status' | 'assigneeUserId' | 'dueAt'>>): Promise<ActionItem | null>;
   listAlerts(input: { workspaceId: string; limit: number; cursor: string | null; status: string }): Promise<CursorPage<AlertItem>>;
   createAlert(input: Omit<AlertItem, 'detectedAt' | 'acknowledgedBy' | 'acknowledgedAt' | 'resolvedAt'>): Promise<AlertItem>;

+ 149 - 2
src/modules/saas-platform/local-platform.repository.ts

@@ -4,6 +4,7 @@ import type { DomesticDataset, DomesticProduct } from '../../types/domestic-data
 import type { LocalSyncJobStore } from '../domestic-voc/local/local-sync-job.store.js';
 import type {
   ActionItem,
+  ActionItemCreateInput,
   AlertItem,
   AnalysisRun,
   AuditEntry,
@@ -11,11 +12,23 @@ import type {
   DataSourceSummary,
   DomesticProductDetail,
   ImportBatchSummary,
+  InsightDecision,
+  InsightDecisionCreateInput,
   PlatformRepository,
   WorkspaceMember,
   WorkspaceRole,
   WorkspaceSummary,
 } from './domain.js';
+import {
+  assertMatchingActionCreation,
+  assertValidActionDecisionSource,
+  assertValidActionSource,
+  buildActionCreationKey,
+  normalizeActionEvidenceIds,
+  prepareAnalysisRunPatch,
+  prepareInsightDecision,
+  resolveActionSourceKind,
+} from './domain.js';
 import { decodeCursor, pageFromSortedItems } from './pagination.js';
 
 function paginate<T>(items: T[], limit: number, cursor: string | null, getId: (item: T) => string): CursorPage<T> {
@@ -30,6 +43,7 @@ function paginate<T>(items: T[], limit: number, cursor: string | null, getId: (i
 export class LocalPlatformRepository implements PlatformRepository {
   private readonly members = new Map<string, WorkspaceMember>();
   private readonly analyses: AnalysisRun[] = [];
+  private readonly insightDecisions: InsightDecision[] = [];
   private readonly actions: ActionItem[] = [];
   private readonly alerts: AlertItem[] = [];
   private readonly audit: AuditEntry[] = [];
@@ -181,7 +195,7 @@ export class LocalPlatformRepository implements PlatformRepository {
       platform: this.dataset.platform,
       kind: 'fmode_gateway',
       status: 'configured',
-      lastCheckedAt: null,
+      lastCheckedAt: this.dataset.generatedAt,
       credentialStorage: 'environment',
     }];
   }
@@ -211,6 +225,10 @@ export class LocalPlatformRepository implements PlatformRepository {
     return paginate(items, input.limit, input.cursor, (item) => item.id);
   }
 
+  async getAnalysis(workspaceId: string, id: string): Promise<AnalysisRun | null> {
+    return this.analyses.find((item) => item.workspaceId === workspaceId && item.id === id) ?? null;
+  }
+
   async createAnalysis(input: Omit<AnalysisRun, 'status' | 'result' | 'evidenceCount' | 'errorSummary' | 'requestedAt' | 'startedAt' | 'completedAt'>): Promise<AnalysisRun> {
     const run: AnalysisRun = {
       ...input,
@@ -226,6 +244,69 @@ export class LocalPlatformRepository implements PlatformRepository {
     return run;
   }
 
+  async updateAnalysis(workspaceId: string, id: string, patch: Parameters<typeof prepareAnalysisRunPatch>[1]): Promise<AnalysisRun | null> {
+    const index = this.analyses.findIndex((item) => item.workspaceId === workspaceId && item.id === id);
+    if (index < 0) return null;
+    const current = this.analyses[index]!;
+    const normalized = prepareAnalysisRunPatch(current, patch, this.now().toISOString());
+    const updated: AnalysisRun = { ...current, ...normalized };
+    this.analyses[index] = updated;
+    return updated;
+  }
+
+  async listInsightDecisions(input: {
+    workspaceId: string;
+    limit: number;
+    cursor: string | null;
+    sourceAnalysisId?: string;
+    sourceInsightId?: string;
+    currentOnly?: boolean;
+  }): Promise<CursorPage<InsightDecision>> {
+    const items = this.insightDecisions
+      .filter((item) => item.workspaceId === input.workspaceId)
+      .filter((item) => !input.sourceAnalysisId || item.sourceAnalysisId === input.sourceAnalysisId)
+      .filter((item) => !input.sourceInsightId || item.sourceInsightId === input.sourceInsightId)
+      .filter((item) => !input.currentOnly || item.isCurrent)
+      .sort((left, right) => (
+        right.decidedAt.localeCompare(left.decidedAt)
+        || right.version - left.version
+        || right.id.localeCompare(left.id)
+      ));
+    return paginate(items, input.limit, input.cursor, (item) => item.id);
+  }
+
+  async getInsightDecision(workspaceId: string, id: string): Promise<InsightDecision | null> {
+    return this.insightDecisions.find((item) => item.workspaceId === workspaceId && item.id === id) ?? null;
+  }
+
+  async createInsightDecision(input: InsightDecisionCreateInput): Promise<InsightDecision> {
+    const analysis = await this.getAnalysis(input.workspaceId, input.sourceAnalysisId);
+    const normalized = prepareInsightDecision(input, analysis);
+    const currentIndex = this.insightDecisions.findIndex((item) => (
+      item.workspaceId === input.workspaceId
+      && item.sourceAnalysisId === input.sourceAnalysisId
+      && item.sourceInsightId === input.sourceInsightId
+      && item.isCurrent
+    ));
+    const current = currentIndex >= 0 ? this.insightDecisions[currentIndex]! : null;
+    const timestamp = this.now().toISOString();
+    if (current) {
+      this.insightDecisions[currentIndex] = { ...current, isCurrent: false, updatedAt: timestamp };
+    }
+    const decision: InsightDecision = {
+      ...input,
+      ...normalized,
+      decidedAt: timestamp,
+      version: (current?.version ?? 0) + 1,
+      supersedesId: current?.id ?? null,
+      isCurrent: true,
+      createdAt: timestamp,
+      updatedAt: timestamp,
+    };
+    this.insightDecisions.unshift(decision);
+    return decision;
+  }
+
   async listActions(input: { workspaceId: string; limit: number; cursor: string | null; status: string }) {
     const items = this.actions
       .filter((item) => item.workspaceId === input.workspaceId && (!input.status || item.status === input.status))
@@ -233,10 +314,76 @@ export class LocalPlatformRepository implements PlatformRepository {
     return paginate(items, input.limit, input.cursor, (item) => item.id);
   }
 
-  async createAction(input: Omit<ActionItem, 'completedAt' | 'createdAt' | 'updatedAt'>): Promise<ActionItem> {
+  async getActionByCreationKey(workspaceId: string, creationKey: string): Promise<ActionItem | null> {
+    if (!creationKey) return null;
+    return this.actions.find((item) => (
+      item.workspaceId === workspaceId && item.creationKey === creationKey
+    )) ?? null;
+  }
+
+  async createAction(input: ActionItemCreateInput): Promise<ActionItem> {
+    const evidenceIds = normalizeActionEvidenceIds(input.evidenceIds);
+    const sourceAnalysis = input.sourceAnalysisId
+      ? await this.getAnalysis(input.workspaceId, input.sourceAnalysisId)
+      : null;
+    const sourceDecisionId = input.sourceDecisionId ?? null;
+    const sourceKind = resolveActionSourceKind({
+      sourceAnalysisId: input.sourceAnalysisId,
+      sourceInsightId: input.sourceInsightId,
+      sourceDecisionId,
+      sourceKind: input.sourceKind,
+    });
+    const creationKey = buildActionCreationKey({
+      workspaceId: input.workspaceId,
+      sourceAnalysisId: input.sourceAnalysisId,
+      sourceInsightId: input.sourceInsightId,
+      sourceDecisionId,
+      sourceKind,
+      creationKey: input.creationKey ?? '',
+    }) || input.id;
+    assertValidActionSource({
+      workspaceId: input.workspaceId,
+      sourceAnalysisId: input.sourceAnalysisId,
+      sourceInsightId: input.sourceInsightId,
+      sourceDecisionId,
+      sourceKind,
+      creationKey,
+      evidenceIds,
+    }, sourceAnalysis);
+    const sourceDecision = sourceDecisionId
+      ? await this.getInsightDecision(input.workspaceId, sourceDecisionId)
+      : null;
+    assertValidActionDecisionSource({
+      workspaceId: input.workspaceId,
+      sourceAnalysisId: input.sourceAnalysisId,
+      sourceInsightId: input.sourceInsightId,
+      sourceDecisionId,
+      sourceKind,
+      creationKey,
+      evidenceIds,
+      actionType: input.actionType,
+      validationMetric: input.validationMetric,
+    }, sourceDecision);
+    const existing = this.actions.find((item) => (
+      item.workspaceId === input.workspaceId && item.creationKey === creationKey
+    ));
+    if (existing) {
+      assertMatchingActionCreation(existing, {
+        ...input,
+        sourceDecisionId,
+        sourceKind,
+        creationKey,
+        evidenceIds,
+      });
+      return existing;
+    }
     const timestamp = this.now().toISOString();
     const action: ActionItem = {
       ...input,
+      sourceDecisionId,
+      sourceKind,
+      creationKey,
+      evidenceIds,
       completedAt: input.status === 'completed' ? timestamp : null,
       createdAt: timestamp,
       updatedAt: timestamp,

+ 294 - 19
src/modules/saas-platform/parse-rest-voc.repository.ts

@@ -14,6 +14,8 @@ import type { SyncJobFinalStatus, SyncPersistence } from '../domestic-voc/reposi
 import { ParseRestSnapshotService } from '../domestic-voc/services/parse-rest-snapshot.service.js';
 import type {
   ActionItem,
+  ActionItemCreateInput,
+  ActionSourceKind,
   AlertItem,
   AnalysisRun,
   AuditEntry,
@@ -21,13 +23,46 @@ import type {
   DataSourceSummary,
   DomesticProductDetail,
   ImportBatchSummary,
+  InsightDecision,
+  InsightDecisionCreateInput,
+  InsightDecisionValue,
   PlatformRepository,
   WorkspaceMember,
   WorkspaceRole,
   WorkspaceSummary,
 } from './domain.js';
+import {
+  assertMatchingActionCreation,
+  assertValidActionDecisionSource,
+  assertValidActionSource,
+  buildActionCreationKey,
+  normalizeActionEvidenceIds,
+  prepareAnalysisRunPatch,
+  resolveActionSourceKind,
+} from './domain.js';
 import { decodeCursor, pageFromSortedItems } from './pagination.js';
 
+type PersistedInsightDecision = InsightDecision;
+
+export interface ListInsightDecisionsInput {
+  workspaceId: string;
+  limit: number;
+  cursor: string | null;
+  sourceAnalysisId?: string;
+  sourceInsightId?: string;
+  currentOnly?: boolean;
+}
+
+type CreateInsightDecisionInput = InsightDecisionCreateInput;
+
+type ActionPersistenceFields = {
+  sourceDecisionId: string | null;
+  sourceKind: ActionSourceKind;
+  creationKey: string;
+};
+type PersistedActionItem = ActionItem;
+type CreateActionInput = ActionItemCreateInput;
+
 interface WorkspaceObject {
   publicId: string;
   name: string;
@@ -130,18 +165,40 @@ interface AnalysisObject extends ParseRecordFields {
   targetKey: string;
   status: AnalysisRun['status'];
   input: Record<string, unknown>;
-  result?: Record<string, unknown>;
+  result?: Record<string, unknown> | null;
   evidenceCount: number;
   requestedBy: string;
-  errorSummary?: string;
+  errorSummary?: string | null;
   requestedAt: unknown;
   startedAt?: unknown;
   completedAt?: unknown;
 }
 
+interface InsightDecisionObject extends ParseRecordFields {
+  publicId: string;
+  workspaceId: string;
+  sourceAnalysisId: string;
+  sourceInsightId: string;
+  decision: InsightDecisionValue;
+  reviewedEvidenceIds: string[];
+  comment: string;
+  decidedBy: string;
+  decidedAt: unknown;
+  version: number;
+  supersedesId?: string | null;
+  isCurrent: boolean;
+}
+
 interface ActionObject extends ParseRecordFields {
   publicId: string;
   workspaceId: string;
+  sourceAnalysisId?: string | null;
+  sourceInsightId?: string | null;
+  sourceDecisionId?: string | null;
+  sourceKind?: ActionSourceKind;
+  creationKey?: string;
+  evidenceIds?: string[];
+  validationMetric?: string;
   actionType: ActionItem['actionType'];
   title: string;
   description: string;
@@ -244,11 +301,27 @@ function emptyProductSummary(): DomesticProduct['summary'] {
 
 export class ParseRestVocRepository implements PlatformRepository, SyncJobStore, SyncPersistence {
   private readonly snapshot: ParseRestSnapshotService;
+  private readonly actionCreationLocks = new Map<string, Promise<void>>();
 
   constructor(private readonly client: ParseRestClient) {
     this.snapshot = new ParseRestSnapshotService(client);
   }
 
+  private async withActionCreationLock<T>(key: string, operation: () => Promise<T>): Promise<T> {
+    const previous = this.actionCreationLocks.get(key) ?? Promise.resolve();
+    let release!: () => void;
+    const gate = new Promise<void>((resolve) => { release = resolve; });
+    const current = previous.then(() => gate);
+    this.actionCreationLocks.set(key, current);
+    await previous;
+    try {
+      return await operation();
+    } finally {
+      release();
+      if (this.actionCreationLocks.get(key) === current) this.actionCreationLocks.delete(key);
+    }
+  }
+
   async health(): Promise<{ ready: boolean; missingClasses: string[] }> {
     if (!await this.client.health()) return { ready: false, missingClasses: [] };
     const names = (await this.client.schemas()).map((schema) => schema.className);
@@ -518,6 +591,15 @@ export class ParseRestVocRepository implements PlatformRepository, SyncJobStore,
     return paginate(items, input.limit, input.cursor, (item) => item.id);
   }
 
+  async getAnalysis(workspaceId: string, id: string): Promise<AnalysisRun | null> {
+    if (!id) return null;
+    const item = await this.client.findOne<AnalysisObject>(VOC_PARSE_CLASSES.analysisRun, {
+      workspaceId,
+      publicId: id,
+    });
+    return item ? this.mapAnalysis(item) : null;
+  }
+
   async createAnalysis(input: Omit<AnalysisRun, 'status' | 'result' | 'evidenceCount' | 'errorSummary' | 'requestedAt' | 'startedAt' | 'completedAt'>): Promise<AnalysisRun> {
     const requestedAt = new Date();
     const body = {
@@ -536,6 +618,96 @@ export class ParseRestVocRepository implements PlatformRepository, SyncJobStore,
     return this.mapAnalysis({ ...body, ...created });
   }
 
+  async updateAnalysis(workspaceId: string, id: string, patch: Parameters<typeof prepareAnalysisRunPatch>[1]): Promise<AnalysisRun | null> {
+    const analysis = await this.client.findOne<AnalysisObject>(VOC_PARSE_CLASSES.analysisRun, { workspaceId, publicId: id });
+    if (!analysis) return null;
+    const current = this.mapAnalysis(analysis);
+    const normalized = prepareAnalysisRunPatch(current, patch, new Date().toISOString());
+    const body: Record<string, unknown> = {
+      ...patch,
+      ...normalized,
+    };
+    if ('result' in body) body.result = body.result === undefined ? current.result : body.result;
+    if ('errorSummary' in body) body.errorSummary = body.errorSummary === undefined ? current.errorSummary : body.errorSummary;
+    if ('startedAt' in body) body.startedAt = body.startedAt ? parseDate(body.startedAt as string) : null;
+    if ('completedAt' in body) body.completedAt = body.completedAt ? parseDate(body.completedAt as string) : null;
+    const result = await this.client.update(VOC_PARSE_CLASSES.analysisRun, analysis.objectId, body);
+    return this.mapAnalysis({
+      ...analysis,
+      ...body,
+      updatedAt: result.updatedAt,
+    } as AnalysisObject & { updatedAt: string });
+  }
+
+  async listInsightDecisions(input: ListInsightDecisionsInput): Promise<CursorPage<PersistedInsightDecision>> {
+    const items = (await this.client.findAll<InsightDecisionObject>(VOC_PARSE_CLASSES.insightDecision, {
+      workspaceId: input.workspaceId,
+    }))
+      .filter((item) => !input.sourceAnalysisId || item.sourceAnalysisId === input.sourceAnalysisId)
+      .filter((item) => !input.sourceInsightId || item.sourceInsightId === input.sourceInsightId)
+      .filter((item) => !input.currentOnly || item.isCurrent)
+      .map((item) => this.mapInsightDecision(item))
+      .sort((left, right) => (
+        right.decidedAt.localeCompare(left.decidedAt)
+        || right.version - left.version
+        || right.id.localeCompare(left.id)
+      ));
+    return paginate(items, input.limit, input.cursor, (item) => item.id);
+  }
+
+  async getInsightDecision(workspaceId: string, id: string): Promise<PersistedInsightDecision | null> {
+    if (!id) return null;
+    const item = await this.client.findOne<InsightDecisionObject>(VOC_PARSE_CLASSES.insightDecision, {
+      workspaceId,
+      publicId: id,
+    });
+    return item ? this.mapInsightDecision(item) : null;
+  }
+
+  async createInsightDecision(input: CreateInsightDecisionInput): Promise<PersistedInsightDecision> {
+    const existing = await this.getInsightDecision(input.workspaceId, input.id);
+    if (existing) return existing;
+
+    const previous = (await this.client.findAll<InsightDecisionObject>(VOC_PARSE_CLASSES.insightDecision, {
+      workspaceId: input.workspaceId,
+      sourceAnalysisId: input.sourceAnalysisId,
+      sourceInsightId: input.sourceInsightId,
+    })).sort((left, right) => (
+      Number(right.version ?? 0) - Number(left.version ?? 0)
+      || right.createdAt.localeCompare(left.createdAt)
+    ));
+    const previousLatest = previous[0] ?? null;
+    const decidedAt = new Date();
+    const body = {
+      publicId: input.id,
+      workspaceId: input.workspaceId,
+      sourceAnalysisId: input.sourceAnalysisId,
+      sourceInsightId: input.sourceInsightId,
+      decision: input.decision,
+      reviewedEvidenceIds: [...new Set(input.reviewedEvidenceIds)],
+      comment: input.comment,
+      decidedBy: input.decidedBy,
+      decidedAt: parseDate(decidedAt),
+      version: Number(previousLatest?.version ?? 0) + 1,
+      supersedesId: previousLatest?.publicId ?? null,
+      isCurrent: true,
+    };
+    const created = await this.client.create(VOC_PARSE_CLASSES.insightDecision, body);
+
+    const currentRecords = previous.filter((item) => item.isCurrent);
+    try {
+      await Promise.all(currentRecords.map((item) => this.client.update(
+        VOC_PARSE_CLASSES.insightDecision,
+        item.objectId,
+        { isCurrent: false },
+      )));
+    } catch (error) {
+      await this.client.update(VOC_PARSE_CLASSES.insightDecision, created.objectId, { isCurrent: false });
+      throw error;
+    }
+    return this.mapInsightDecision({ ...body, ...created });
+  }
+
   async listActions(input: { workspaceId: string; limit: number; cursor: string | null; status: string }) {
     const items = (await this.client.findAll<ActionObject>(VOC_PARSE_CLASSES.actionItem, { workspaceId: input.workspaceId }))
       .filter((item) => !input.status || item.status === input.status)
@@ -544,23 +716,96 @@ export class ParseRestVocRepository implements PlatformRepository, SyncJobStore,
     return paginate(items, input.limit, input.cursor, (item) => item.id);
   }
 
-  async createAction(input: Omit<ActionItem, 'completedAt' | 'createdAt' | 'updatedAt'>): Promise<ActionItem> {
-    const body = {
-      publicId: input.id,
+  async getActionByCreationKey(workspaceId: string, creationKey: string): Promise<PersistedActionItem | null> {
+    if (!creationKey) return null;
+    const item = await this.client.findOne<ActionObject>(VOC_PARSE_CLASSES.actionItem, {
+      workspaceId,
+      creationKey,
+    });
+    return item ? this.mapAction(item) : null;
+  }
+
+  async createAction(input: CreateActionInput): Promise<PersistedActionItem> {
+    const sourceDecisionId = input.sourceDecisionId ?? null;
+    const sourceKind = resolveActionSourceKind({
+      sourceAnalysisId: input.sourceAnalysisId,
+      sourceInsightId: input.sourceInsightId,
+      sourceDecisionId,
+      sourceKind: input.sourceKind,
+    });
+    const creationKey = buildActionCreationKey({
       workspaceId: input.workspaceId,
-      actionType: input.actionType,
-      title: input.title,
-      description: input.description,
-      priority: input.priority,
-      status: input.status,
-      productKey: input.productKey,
-      assigneeUserId: input.assigneeUserId,
-      dueAt: input.dueAt ? parseDate(input.dueAt) : null,
-      createdBy: input.createdBy,
-      completedAt: input.status === 'completed' ? parseDate(new Date()) : null,
-    };
-    const created = await this.client.create(VOC_PARSE_CLASSES.actionItem, body);
-    return this.mapAction({ ...body, ...created });
+      sourceAnalysisId: input.sourceAnalysisId,
+      sourceInsightId: input.sourceInsightId,
+      sourceDecisionId,
+      sourceKind,
+      creationKey: input.creationKey ?? '',
+    }) || input.id;
+    const lockKey = `${input.workspaceId}\u0000${creationKey}`;
+    return this.withActionCreationLock(lockKey, async () => {
+      const evidenceIds = normalizeActionEvidenceIds(input.evidenceIds);
+      const sourceAnalysis = input.sourceAnalysisId
+        ? await this.getAnalysis(input.workspaceId, input.sourceAnalysisId)
+        : null;
+      assertValidActionSource({
+        workspaceId: input.workspaceId,
+        sourceAnalysisId: input.sourceAnalysisId,
+        sourceInsightId: input.sourceInsightId,
+        sourceDecisionId,
+        sourceKind,
+        creationKey,
+        evidenceIds,
+      }, sourceAnalysis);
+      const sourceDecision = sourceDecisionId
+        ? await this.getInsightDecision(input.workspaceId, sourceDecisionId)
+        : null;
+      assertValidActionDecisionSource({
+        workspaceId: input.workspaceId,
+        sourceAnalysisId: input.sourceAnalysisId,
+        sourceInsightId: input.sourceInsightId,
+        sourceDecisionId,
+        sourceKind,
+        creationKey,
+        evidenceIds,
+        actionType: input.actionType,
+        validationMetric: input.validationMetric,
+      }, sourceDecision);
+      const existing = await this.getActionByCreationKey(input.workspaceId, creationKey);
+      if (existing) {
+        assertMatchingActionCreation(existing, {
+          ...input,
+          sourceDecisionId,
+          sourceKind,
+          creationKey,
+          evidenceIds,
+        });
+        return existing;
+      }
+
+      const body = {
+        publicId: input.id,
+        workspaceId: input.workspaceId,
+        sourceAnalysisId: input.sourceAnalysisId,
+        sourceInsightId: input.sourceInsightId,
+        sourceDecisionId,
+        sourceKind,
+        creationKey,
+        evidenceIds,
+        validationMetric: input.validationMetric,
+        actionType: input.actionType,
+        title: input.title,
+        description: input.description,
+        priority: input.priority,
+        status: input.status,
+        productKey: input.productKey,
+        assigneeUserId: input.assigneeUserId,
+        dueAt: input.dueAt ? parseDate(input.dueAt) : null,
+        createdBy: input.createdBy,
+        completedAt: input.status === 'completed' ? parseDate(new Date()) : null,
+      };
+      const created = await this.client.create(VOC_PARSE_CLASSES.actionItem, body);
+      return this.mapAction({ ...body, ...created });
+    });
   }
 
   async updateAction(workspaceId: string, id: string, patch: Partial<Pick<ActionItem, 'title' | 'description' | 'priority' | 'status' | 'assigneeUserId' | 'dueAt'>>): Promise<ActionItem | null> {
@@ -897,10 +1142,40 @@ export class ParseRestVocRepository implements PlatformRepository, SyncJobStore,
     };
   }
 
-  private mapAction(item: ActionObject): ActionItem {
+  private mapInsightDecision(item: InsightDecisionObject): PersistedInsightDecision {
+    return {
+      id: item.publicId,
+      workspaceId: item.workspaceId,
+      sourceAnalysisId: item.sourceAnalysisId,
+      sourceInsightId: item.sourceInsightId,
+      decision: item.decision,
+      reviewedEvidenceIds: Array.isArray(item.reviewedEvidenceIds)
+        ? [...new Set(item.reviewedEvidenceIds.filter((id): id is string => typeof id === 'string'))]
+        : [],
+      comment: item.comment ?? '',
+      decidedBy: item.decidedBy,
+      decidedAt: requiredDate(item.decidedAt, item.createdAt),
+      version: Number(item.version ?? 1),
+      supersedesId: item.supersedesId ?? null,
+      isCurrent: Boolean(item.isCurrent),
+      createdAt: item.createdAt,
+      updatedAt: item.updatedAt ?? item.createdAt,
+    };
+  }
+
+  private mapAction(item: ActionObject): PersistedActionItem {
     return {
       id: item.publicId,
       workspaceId: item.workspaceId,
+      sourceAnalysisId: item.sourceAnalysisId ?? null,
+      sourceInsightId: item.sourceInsightId ?? null,
+      sourceDecisionId: item.sourceDecisionId ?? null,
+      sourceKind: item.sourceKind ?? (item.sourceAnalysisId ? 'insight' : 'rule_action'),
+      creationKey: item.creationKey ?? item.publicId,
+      evidenceIds: Array.isArray(item.evidenceIds)
+        ? [...new Set(item.evidenceIds.filter((id): id is string => typeof id === 'string'))]
+        : [],
+      validationMetric: item.validationMetric ?? '',
       actionType: item.actionType,
       title: item.title,
       description: item.description,

+ 374 - 14
src/modules/saas-platform/postgres-platform.repository.ts

@@ -4,6 +4,8 @@ import type { DomesticDataset, DomesticMetricSummary, DomesticProduct } from '..
 import { SnapshotService } from '../domestic-voc/services/snapshot.service.js';
 import type {
   ActionItem,
+  ActionItemCreateInput,
+  ActionSourceKind,
   AlertItem,
   AnalysisRun,
   AuditEntry,
@@ -11,13 +13,46 @@ import type {
   DataSourceSummary,
   DomesticProductDetail,
   ImportBatchSummary,
+  InsightDecision,
+  InsightDecisionCreateInput,
+  InsightDecisionValue,
   PlatformRepository,
   WorkspaceMember,
   WorkspaceRole,
   WorkspaceSummary,
 } from './domain.js';
+import {
+  assertMatchingActionCreation,
+  assertValidActionDecisionSource,
+  assertValidActionSource,
+  buildActionCreationKey,
+  normalizeActionEvidenceIds,
+  prepareAnalysisRunPatch,
+  resolveActionSourceKind,
+} from './domain.js';
 import { decodeCursor, encodeCursor } from './pagination.js';
 
+type PersistedInsightDecision = InsightDecision;
+
+interface ListInsightDecisionsInput {
+  workspaceId: string;
+  limit: number;
+  cursor: string | null;
+  sourceAnalysisId?: string;
+  sourceInsightId?: string;
+  currentOnly?: boolean;
+}
+
+type CreateInsightDecisionInput = InsightDecisionCreateInput;
+
+type ActionPersistenceFields = {
+  sourceDecisionId: string | null;
+  sourceKind: ActionSourceKind;
+  creationKey: string;
+};
+type PersistedActionItem = ActionItem;
+type CreateActionInput = ActionItemCreateInput;
+
 function numberValue(value: unknown): number {
   const parsed = Number(value ?? 0);
   return Number.isFinite(parsed) ? parsed : 0;
@@ -472,6 +507,16 @@ export class PostgresPlatformRepository implements PlatformRepository {
     return this.mappedPage(result.rows, input.limit, (row) => this.mapAnalysis(row));
   }
 
+  async getAnalysis(workspaceId: string, id: string): Promise<AnalysisRun | null> {
+    if (!id) return null;
+    const result = await this.database.query<any>(`
+      SELECT run.*, workspace.public_id AS workspace_public_id
+      FROM voc.analysis_run run JOIN voc.workspace workspace ON workspace.id = run.workspace_id
+      WHERE workspace.public_id = $1 AND run.public_id = $2
+    `, [workspaceId, id]);
+    return result.rows[0] ? this.mapAnalysis(result.rows[0]) : null;
+  }
+
   async createAnalysis(input: Omit<AnalysisRun, 'status' | 'result' | 'evidenceCount' | 'errorSummary' | 'requestedAt' | 'startedAt' | 'completedAt'>): Promise<AnalysisRun> {
     const result = await this.database.query<any>(`
       INSERT INTO voc.analysis_run (public_id, workspace_id, analysis_type, target_kind, target_key, input, requested_by_external_id)
@@ -483,10 +528,168 @@ export class PostgresPlatformRepository implements PlatformRepository {
     return this.mapAnalysis(row);
   }
 
+  async updateAnalysis(workspaceId: string, id: string, patch: Parameters<typeof prepareAnalysisRunPatch>[1]): Promise<AnalysisRun | null> {
+    const currentResult = await this.database.query<any>(`
+      SELECT run.*, workspace.public_id AS workspace_public_id
+      FROM voc.analysis_run run JOIN voc.workspace workspace ON workspace.id = run.workspace_id
+      WHERE workspace.public_id = $1 AND run.public_id = $2
+    `, [workspaceId, id]);
+    const currentRow = currentResult.rows[0];
+    if (!currentRow) return null;
+
+    const current = this.mapAnalysis(currentRow);
+    const normalized = prepareAnalysisRunPatch(current, patch, new Date().toISOString());
+    const updated = { ...current, ...normalized };
+    const result = await this.database.query<any>(`
+      UPDATE voc.analysis_run run SET
+        status = $3,
+        result = $4::jsonb,
+        evidence_count = $5,
+        error_summary = $6,
+        started_at = $7::timestamptz,
+        completed_at = $8::timestamptz,
+        updated_at = now()
+      FROM voc.workspace workspace
+      WHERE run.workspace_id = workspace.id AND workspace.public_id = $1 AND run.public_id = $2
+      RETURNING run.*, workspace.public_id AS workspace_public_id, run.id::text AS cursor_id
+    `, [
+      workspaceId,
+      id,
+      updated.status,
+      updated.result === null ? null : JSON.stringify(updated.result),
+      updated.evidenceCount,
+      updated.errorSummary,
+      updated.startedAt,
+      updated.completedAt,
+    ]);
+    return result.rows[0] ? this.mapAnalysis(result.rows[0]) : null;
+  }
+
+  async listInsightDecisions(input: ListInsightDecisionsInput): Promise<CursorPage<PersistedInsightDecision>> {
+    const cursorId = numericCursor(input.cursor, '9223372036854775807');
+    const result = await this.database.query<any>(`
+      SELECT decision.id::text AS cursor_id, decision.*,
+             workspace.public_id AS workspace_public_id,
+             analysis.public_id AS source_analysis_public_id,
+             supersedes.public_id AS supersedes_public_id
+      FROM voc.insight_decision decision
+      JOIN voc.workspace workspace ON workspace.id = decision.workspace_id
+      JOIN voc.analysis_run analysis ON analysis.id = decision.source_analysis_id
+      LEFT JOIN voc.insight_decision supersedes ON supersedes.id = decision.supersedes_id
+      WHERE workspace.public_id = $1
+        AND decision.id < $2::bigint
+        AND ($3::text = '' OR analysis.public_id = $3)
+        AND ($4::text = '' OR decision.source_insight_id = $4)
+        AND (NOT $5::boolean OR decision.is_current)
+      ORDER BY decision.id DESC
+      LIMIT $6
+    `, [
+      input.workspaceId,
+      cursorId,
+      input.sourceAnalysisId ?? '',
+      input.sourceInsightId ?? '',
+      input.currentOnly ?? false,
+      input.limit + 1,
+    ]);
+    return this.mappedPage(result.rows, input.limit, (row) => this.mapInsightDecision(row));
+  }
+
+  async getInsightDecision(workspaceId: string, id: string): Promise<PersistedInsightDecision | null> {
+    if (!id) return null;
+    const result = await this.database.query<any>(`
+      SELECT decision.*, workspace.public_id AS workspace_public_id,
+             analysis.public_id AS source_analysis_public_id,
+             supersedes.public_id AS supersedes_public_id
+      FROM voc.insight_decision decision
+      JOIN voc.workspace workspace ON workspace.id = decision.workspace_id
+      JOIN voc.analysis_run analysis ON analysis.id = decision.source_analysis_id
+      LEFT JOIN voc.insight_decision supersedes ON supersedes.id = decision.supersedes_id
+      WHERE workspace.public_id = $1 AND decision.public_id = $2
+    `, [workspaceId, id]);
+    return result.rows[0] ? this.mapInsightDecision(result.rows[0]) : null;
+  }
+
+  async createInsightDecision(input: CreateInsightDecisionInput): Promise<PersistedInsightDecision> {
+    const reviewedEvidenceIds = [...new Set(input.reviewedEvidenceIds)];
+    for (let attempt = 0; ; attempt += 1) {
+      try {
+        const result = await this.database.query<any>(`
+      WITH target AS (
+        SELECT workspace.id AS workspace_id, analysis.id AS analysis_id
+        FROM voc.workspace workspace
+        JOIN voc.analysis_run analysis ON analysis.workspace_id = workspace.id
+        WHERE workspace.public_id = $1 AND analysis.public_id = $3
+        FOR UPDATE OF analysis
+      ),
+      previous AS (
+        SELECT decision.id, decision.public_id, decision.version
+        FROM voc.insight_decision decision
+        JOIN target ON target.workspace_id = decision.workspace_id
+                   AND target.analysis_id = decision.source_analysis_id
+        WHERE decision.source_insight_id = $4
+        ORDER BY decision.version DESC, decision.id DESC
+        LIMIT 1
+        FOR UPDATE
+      ),
+      retired AS (
+        UPDATE voc.insight_decision decision
+        SET is_current = false, updated_at = now()
+        FROM target
+        WHERE decision.workspace_id = target.workspace_id
+          AND decision.source_analysis_id = target.analysis_id
+          AND decision.source_insight_id = $4
+          AND decision.is_current
+        RETURNING decision.id
+      ),
+      inserted AS (
+        INSERT INTO voc.insight_decision (
+          public_id, workspace_id, source_analysis_id, source_insight_id, decision,
+          reviewed_evidence_ids, comment, decided_by_external_id, decided_at,
+          version, supersedes_id, is_current
+        )
+        SELECT $2, target.workspace_id, target.analysis_id, $4, $5, $6::jsonb, $7, $8, now(),
+               COALESCE((SELECT version FROM previous), 0) + 1,
+               (SELECT id FROM previous), true
+        FROM target
+        CROSS JOIN (SELECT count(*) FROM retired) retirement
+        RETURNING *
+      )
+      SELECT inserted.*, workspace.public_id AS workspace_public_id,
+             analysis.public_id AS source_analysis_public_id,
+             supersedes.public_id AS supersedes_public_id,
+             inserted.id::text AS cursor_id
+      FROM inserted
+      JOIN voc.workspace workspace ON workspace.id = inserted.workspace_id
+      JOIN voc.analysis_run analysis ON analysis.id = inserted.source_analysis_id
+      LEFT JOIN voc.insight_decision supersedes ON supersedes.id = inserted.supersedes_id
+    `, [
+      input.workspaceId,
+      input.id,
+      input.sourceAnalysisId,
+      input.sourceInsightId,
+      input.decision,
+      JSON.stringify(reviewedEvidenceIds),
+      input.comment,
+      input.decidedBy,
+        ]);
+        const row = result.rows[0];
+        if (!row) throw new Error('Workspace or source analysis not found while creating insight decision');
+        return this.mapInsightDecision(row);
+      } catch (error) {
+        if ((error as { code?: string }).code !== '23505') throw error;
+        const existing = await this.getInsightDecision(input.workspaceId, input.id);
+        if (existing) return existing;
+        if (attempt >= 1) throw error;
+      }
+    }
+  }
+
   async listActions(input: { workspaceId: string; limit: number; cursor: string | null; status: string }) {
     const cursorId = numericCursor(input.cursor, '9223372036854775807');
     const result = await this.database.query<any>(`
-      SELECT action.id::text AS cursor_id, action.*, workspace.public_id AS workspace_public_id
+      SELECT action.id::text AS cursor_id, action.*, workspace.public_id AS workspace_public_id,
+             (SELECT public_id FROM voc.analysis_run WHERE id = action.source_analysis_id) AS source_analysis_public_id,
+             (SELECT public_id FROM voc.insight_decision WHERE id = action.source_decision_id) AS source_decision_public_id
       FROM voc.action_item action JOIN voc.workspace workspace ON workspace.id = action.workspace_id
       WHERE workspace.public_id = $1 AND action.id < $2::bigint AND ($3::text = '' OR action.status = $3)
       ORDER BY action.id DESC LIMIT $4
@@ -494,21 +697,144 @@ export class PostgresPlatformRepository implements PlatformRepository {
     return this.mappedPage(result.rows, input.limit, (row) => this.mapAction(row));
   }
 
-  async createAction(input: Omit<ActionItem, 'completedAt' | 'createdAt' | 'updatedAt'>): Promise<ActionItem> {
+  async getActionByCreationKey(workspaceId: string, creationKey: string): Promise<PersistedActionItem | null> {
+    if (!creationKey) return null;
+    const result = await this.database.query<any>(`
+      SELECT action.*, workspace.public_id AS workspace_public_id,
+             (SELECT public_id FROM voc.analysis_run WHERE id = action.source_analysis_id) AS source_analysis_public_id,
+             (SELECT public_id FROM voc.insight_decision WHERE id = action.source_decision_id) AS source_decision_public_id
+      FROM voc.action_item action
+      JOIN voc.workspace workspace ON workspace.id = action.workspace_id
+      WHERE workspace.public_id = $1 AND action.creation_key = $2
+    `, [workspaceId, creationKey]);
+    return result.rows[0] ? this.mapAction(result.rows[0]) : null;
+  }
+
+  async createAction(input: CreateActionInput): Promise<PersistedActionItem> {
+    const sourceDecisionId = input.sourceDecisionId ?? null;
+    const sourceKind = resolveActionSourceKind({
+      sourceAnalysisId: input.sourceAnalysisId,
+      sourceInsightId: input.sourceInsightId,
+      sourceDecisionId,
+      sourceKind: input.sourceKind,
+    });
+    const creationKey = buildActionCreationKey({
+      workspaceId: input.workspaceId,
+      sourceAnalysisId: input.sourceAnalysisId,
+      sourceInsightId: input.sourceInsightId,
+      sourceDecisionId,
+      sourceKind,
+      creationKey: input.creationKey ?? '',
+    }) || input.id;
+    const evidenceIds = normalizeActionEvidenceIds(input.evidenceIds);
+    const sourceAnalysis = input.sourceAnalysisId
+      ? await this.getAnalysis(input.workspaceId, input.sourceAnalysisId)
+      : null;
+    assertValidActionSource({
+      workspaceId: input.workspaceId,
+      sourceAnalysisId: input.sourceAnalysisId,
+      sourceInsightId: input.sourceInsightId,
+      sourceDecisionId,
+      sourceKind,
+      creationKey,
+      evidenceIds,
+    }, sourceAnalysis);
+    const sourceDecision = sourceDecisionId
+      ? await this.getInsightDecision(input.workspaceId, sourceDecisionId)
+      : null;
+    assertValidActionDecisionSource({
+      workspaceId: input.workspaceId,
+      sourceAnalysisId: input.sourceAnalysisId,
+      sourceInsightId: input.sourceInsightId,
+      sourceDecisionId,
+      sourceKind,
+      creationKey,
+      evidenceIds,
+      actionType: input.actionType,
+      validationMetric: input.validationMetric,
+    }, sourceDecision);
+    const existing = await this.getActionByCreationKey(input.workspaceId, creationKey);
+    if (existing) {
+      assertMatchingActionCreation(existing, {
+        ...input,
+        sourceDecisionId,
+        sourceKind,
+        creationKey,
+        evidenceIds,
+      });
+      return existing;
+    }
     const result = await this.database.query<any>(`
-      INSERT INTO voc.action_item (
-        public_id, workspace_id, action_type, title, description, priority, status,
-        product_key, assignee_external_id, due_at, created_by_external_id, completed_at
+      WITH workspace_target AS (
+        SELECT id FROM voc.workspace WHERE public_id = $1
+      ),
+      inserted AS (
+        INSERT INTO voc.action_item (
+          public_id, workspace_id, source_analysis_id, source_insight_id, source_decision_id,
+          source_kind, creation_key, evidence_ids, validation_metric,
+          action_type, title, description, priority, status,
+          product_key, assignee_external_id, due_at, created_by_external_id, completed_at
+        )
+        SELECT $2, workspace_target.id,
+               (SELECT id FROM voc.analysis_run WHERE public_id = $12 AND workspace_id = workspace_target.id),
+               $13,
+               (SELECT id FROM voc.insight_decision WHERE public_id = $16 AND workspace_id = workspace_target.id),
+               $17, $18, $14::jsonb, $15, $3, $4, $5, $6, $7, $8, $9, $10, $11,
+               CASE WHEN $7 = 'completed' THEN now() ELSE NULL END
+        FROM workspace_target
+        WHERE $16::text IS NULL OR EXISTS (
+          SELECT 1
+          FROM voc.insight_decision source_decision
+          WHERE source_decision.public_id = $16
+            AND source_decision.workspace_id = workspace_target.id
+        )
+        ON CONFLICT (workspace_id, creation_key) DO NOTHING
+        RETURNING *
+      ),
+      selected AS (
+        SELECT * FROM inserted
+        UNION ALL
+        SELECT action.*
+        FROM voc.action_item action
+        JOIN workspace_target ON workspace_target.id = action.workspace_id
+        WHERE action.creation_key = $18 AND NOT EXISTS (SELECT 1 FROM inserted)
+        LIMIT 1
       )
-      SELECT $2, workspace.id, $3, $4, $5, $6, $7, $8, $9, $10, $11,
-             CASE WHEN $7 = 'completed' THEN now() ELSE NULL END
-      FROM voc.workspace workspace WHERE workspace.public_id = $1
-      RETURNING *, $1::text AS workspace_public_id
+      SELECT selected.*, workspace.public_id AS workspace_public_id,
+             analysis.public_id AS source_analysis_public_id,
+             source_decision.public_id AS source_decision_public_id
+      FROM selected
+      JOIN voc.workspace workspace ON workspace.id = selected.workspace_id
+      LEFT JOIN voc.analysis_run analysis ON analysis.id = selected.source_analysis_id
+      LEFT JOIN voc.insight_decision source_decision ON source_decision.id = selected.source_decision_id
     `, [input.workspaceId, input.id, input.actionType, input.title, input.description, input.priority,
-      input.status, input.productKey, input.assigneeUserId, input.dueAt, input.createdBy]);
+      input.status, input.productKey, input.assigneeUserId, input.dueAt, input.createdBy,
+      input.sourceAnalysisId, input.sourceInsightId, JSON.stringify(evidenceIds), input.validationMetric,
+      sourceDecisionId, sourceKind, creationKey]);
     const row = result.rows[0];
-    if (!row) throw new Error('Workspace not found while creating action');
-    return this.mapAction(row);
+    if (!row) {
+      const concurrentlyCreated = await this.getActionByCreationKey(input.workspaceId, creationKey);
+      if (concurrentlyCreated) {
+        assertMatchingActionCreation(concurrentlyCreated, {
+          ...input,
+          sourceDecisionId,
+          sourceKind,
+          creationKey,
+          evidenceIds,
+        });
+        return concurrentlyCreated;
+      }
+      throw new Error('Workspace or action source decision not found while creating action');
+    }
+    const action = this.mapAction(row);
+    assertMatchingActionCreation(action, {
+      ...input,
+      sourceDecisionId,
+      sourceKind,
+      creationKey,
+      evidenceIds,
+    });
+    return action;
   }
 
   async updateAction(workspaceId: string, id: string, patch: Partial<Pick<ActionItem, 'title' | 'description' | 'priority' | 'status' | 'assigneeUserId' | 'dueAt'>>): Promise<ActionItem | null> {
@@ -523,7 +849,9 @@ export class PostgresPlatformRepository implements PlatformRepository {
         updated_at = now()
       FROM voc.workspace workspace
       WHERE action.workspace_id = workspace.id AND workspace.public_id = $1 AND action.public_id = $2
-      RETURNING action.*, workspace.public_id AS workspace_public_id, action.id::text AS cursor_id
+      RETURNING action.*, workspace.public_id AS workspace_public_id, action.id::text AS cursor_id,
+                (SELECT public_id FROM voc.analysis_run WHERE id = action.source_analysis_id) AS source_analysis_public_id,
+                (SELECT public_id FROM voc.insight_decision WHERE id = action.source_decision_id) AS source_decision_public_id
     `, [workspaceId, id, patch.title ?? null, patch.description ?? null, patch.priority ?? null, patch.status ?? null,
       Object.hasOwn(patch, 'assigneeUserId'), patch.assigneeUserId ?? null,
       Object.hasOwn(patch, 'dueAt'), patch.dueAt ?? null]);
@@ -609,9 +937,41 @@ export class PostgresPlatformRepository implements PlatformRepository {
     };
   }
 
-  private mapAction(row: any): ActionItem {
+  private mapInsightDecision(row: any): PersistedInsightDecision {
+    return {
+      id: row.public_id,
+      workspaceId: row.workspace_public_id,
+      sourceAnalysisId: row.source_analysis_public_id,
+      sourceInsightId: row.source_insight_id,
+      decision: row.decision,
+      reviewedEvidenceIds: Array.isArray(row.reviewed_evidence_ids)
+        ? [...new Set<string>((row.reviewed_evidence_ids as unknown[])
+          .filter((id: unknown): id is string => typeof id === 'string'))]
+        : [],
+      comment: row.comment ?? '',
+      decidedBy: row.decided_by_external_id,
+      decidedAt: new Date(row.decided_at).toISOString(),
+      version: Number(row.version),
+      supersedesId: row.supersedes_public_id ?? null,
+      isCurrent: Boolean(row.is_current),
+      createdAt: new Date(row.created_at).toISOString(),
+      updatedAt: new Date(row.updated_at).toISOString(),
+    };
+  }
+
+  private mapAction(row: any): PersistedActionItem {
     return {
       id: row.public_id, workspaceId: row.workspace_public_id, actionType: row.action_type,
+      sourceAnalysisId: row.source_analysis_public_id ?? null,
+      sourceInsightId: row.source_insight_id ?? null,
+      sourceDecisionId: row.source_decision_public_id ?? null,
+      sourceKind: row.source_kind ?? (row.source_analysis_id ? 'insight' : 'rule_action'),
+      creationKey: row.creation_key ?? row.public_id,
+      evidenceIds: Array.isArray(row.evidence_ids)
+        ? [...new Set<string>((row.evidence_ids as unknown[])
+          .filter((id: unknown): id is string => typeof id === 'string'))]
+        : [],
+      validationMetric: row.validation_metric ?? '',
       title: row.title, description: row.description, priority: row.priority, status: row.status,
       productKey: row.product_key, assigneeUserId: row.assignee_external_id, dueAt: iso(row.due_at),
       createdBy: row.created_by_external_id, completedAt: iso(row.completed_at),

+ 148 - 5
src/modules/saas-platform/routes.ts

@@ -2,7 +2,8 @@ import { randomUUID } from 'node:crypto';
 import { Router } from 'express';
 import { z } from 'zod';
 import { ApiError } from '../../http/api-error.js';
-import type { ActionItem, AlertItem, PlatformRepository, WorkspaceMember } from './domain.js';
+import type { ActionItem, AlertItem, AnalysisRunPatch, PlatformRepository, WorkspaceMember } from './domain.js';
+import { prepareInsightDecision, resolveActionSourceKind } from './domain.js';
 import { getPrincipal, WorkspaceAccessService } from './auth.js';
 
 const pageQuerySchema = z.object({
@@ -14,6 +15,12 @@ const analysisPageQuerySchema = pageQuerySchema.extend({
   status: z.enum(['pending', 'processing', 'completed', 'partial', 'failed', 'cancelled']).or(z.literal('')).default(''),
 });
 
+const insightDecisionPageQuerySchema = pageQuerySchema.extend({
+  analysisId: z.string().trim().max(200).default(''),
+  insightId: z.string().trim().max(300).default(''),
+  currentOnly: z.enum(['true', 'false']).default('false').transform((value) => value === 'true'),
+});
+
 const actionPageQuerySchema = pageQuerySchema.extend({
   status: z.enum(['open', 'planned', 'in_progress', 'blocked', 'completed', 'cancelled']).or(z.literal('')).default(''),
 });
@@ -30,7 +37,7 @@ const memberSchema = z.object({
 });
 
 const analysisSchema = z.object({
-  analysisType: z.enum(['voice', 'pain_point', 'feature', 'scenario', 'risk', 'report']),
+  analysisType: z.enum(['voice', 'pain_point', 'feature', 'scenario', 'risk', 'report', 'voc_insight']),
   targetKind: z.enum(['workspace', 'category', 'product']).default('workspace'),
   targetKey: z.string().max(300).default(''),
   input: z.record(z.string(), z.unknown()).default({}),
@@ -44,7 +51,33 @@ const analysisSchema = z.object({
   }
 });
 
+const analysisPatchSchema = z.object({
+  status: z.enum(['pending', 'processing', 'completed', 'partial', 'failed', 'cancelled']).optional(),
+  result: z.record(z.string(), z.unknown()).nullable().optional(),
+  evidenceCount: z.number().int().min(0).max(1_000_000).optional(),
+  errorSummary: z.string().max(10_000).nullable().optional(),
+  startedAt: z.iso.datetime().nullable().optional(),
+  completedAt: z.iso.datetime().nullable().optional(),
+}).refine((value) => Object.keys(value).length > 0, 'At least one field is required');
+
+const insightDecisionCreateSchema = z.object({
+  sourceAnalysisId: z.string().trim().min(1).max(200),
+  sourceInsightId: z.string().trim().min(1).max(300),
+  decision: z.enum(['confirmed', 'rejected', 'needs_more_evidence']),
+  reviewedEvidenceIds: z.array(z.string().trim().min(1).max(300)).min(1).max(100)
+    .transform((ids) => [...new Set(ids)]),
+  comment: z.string().max(10_000).default(''),
+});
+
 const actionCreateSchema = z.object({
+  sourceAnalysisId: z.string().trim().min(1).max(200).nullable().default(null),
+  sourceInsightId: z.string().trim().min(1).max(300).nullable().default(null),
+  sourceDecisionId: z.uuid().nullable().default(null),
+  sourceKind: z.enum(['insight', 'raw_feedback', 'rule_action']).optional(),
+  creationKey: z.string().trim().min(1).max(1_000).optional(),
+  evidenceIds: z.array(z.string().trim().min(1).max(300)).max(100).default([])
+    .transform((ids) => [...new Set(ids)]),
+  validationMetric: z.string().trim().max(10_000).default(''),
   actionType: z.enum(['general', 'experience', 'product', 'strategy', 'data_quality']).default('general'),
   title: z.string().min(1).max(300),
   description: z.string().max(10_000).default(''),
@@ -98,6 +131,7 @@ export function createSaasPlatformRouter(input: {
           syncJobRecovery: true,
           analysisRuns: true,
           analysisExecution: false,
+          insightDecisions: true,
           actionWorkflow: true,
           alerts: true,
           auditLog: true,
@@ -189,6 +223,79 @@ export function createSaasPlatformRouter(input: {
     } catch (error) { next(error); }
   });
 
+  router.patch('/workspaces/:workspaceId/analyses/:id', async (request, response, next) => {
+    try {
+      const workspaceId = z.string().min(1).parse(request.params.workspaceId);
+      const id = z.uuid().parse(request.params.id);
+      const body = analysisPatchSchema.parse(request.body);
+      await input.access.require(request, workspaceId, 'analysis:run');
+      const analysis = await input.repository.updateAnalysis(workspaceId, id, body as AnalysisRunPatch);
+      if (!analysis) throw new ApiError(404, 'analysis_not_found');
+      await audit(input.repository, request, workspaceId, 'analysis.updated', 'analysis_run', id, {
+        fields: Object.keys(body), status: analysis.status,
+      });
+      response.json({ analysis });
+    } catch (error) { next(error); }
+  });
+
+  router.get('/workspaces/:workspaceId/insight-decisions', async (request, response, next) => {
+    try {
+      const workspaceId = z.string().min(1).parse(request.params.workspaceId);
+      const page = insightDecisionPageQuerySchema.parse(request.query);
+      await input.access.require(request, workspaceId, 'workspace:read');
+      const repository = requireInsightDecisionRepository(input.repository);
+      response.json(await repository.listInsightDecisions({
+        workspaceId,
+        limit: page.limit,
+        cursor: page.cursor ?? null,
+        sourceAnalysisId: page.analysisId,
+        sourceInsightId: page.insightId,
+        currentOnly: page.currentOnly,
+      }));
+    } catch (error) { next(error); }
+  });
+
+  router.get('/workspaces/:workspaceId/insight-decisions/:id', async (request, response, next) => {
+    try {
+      const workspaceId = z.string().min(1).parse(request.params.workspaceId);
+      const id = z.uuid().parse(request.params.id);
+      await input.access.require(request, workspaceId, 'workspace:read');
+      const repository = requireInsightDecisionRepository(input.repository);
+      const decision = await repository.getInsightDecision(workspaceId, id);
+      if (!decision) throw new ApiError(404, 'insight_decision_not_found');
+      response.json({ decision });
+    } catch (error) { next(error); }
+  });
+
+  router.post('/workspaces/:workspaceId/insight-decisions', async (request, response, next) => {
+    try {
+      const workspaceId = z.string().min(1).parse(request.params.workspaceId);
+      const body = insightDecisionCreateSchema.parse(request.body);
+      await input.access.require(request, workspaceId, 'analysis:run');
+      const repository = requireInsightDecisionRepository(input.repository);
+      const principal = getPrincipal(request);
+      const analysis = await input.repository.getAnalysis(workspaceId, body.sourceAnalysisId);
+      const normalized = prepareInsightDecision({ workspaceId, ...body }, analysis);
+      const decision = await repository.createInsightDecision({
+        id: randomUUID(),
+        workspaceId,
+        ...body,
+        ...normalized,
+        decidedBy: principal.userId,
+      });
+      await audit(input.repository, request, workspaceId, 'decision.created', 'insight_decision', decision.id, {
+        sourceAnalysisId: decision.sourceAnalysisId,
+        sourceInsightId: decision.sourceInsightId,
+        decision: decision.decision,
+        reviewedEvidenceIds: decision.reviewedEvidenceIds,
+        reviewedEvidenceCount: decision.reviewedEvidenceIds.length,
+        version: decision.version,
+        supersedesId: decision.supersedesId,
+      });
+      response.status(201).json({ decision });
+    } catch (error) { next(error); }
+  });
+
   router.get('/workspaces/:workspaceId/actions', async (request, response, next) => {
     try {
       const workspaceId = z.string().min(1).parse(request.params.workspaceId);
@@ -205,9 +312,29 @@ export function createSaasPlatformRouter(input: {
       await input.access.require(request, workspaceId, 'action:write');
       await requireActiveAssignee(input.repository, workspaceId, body.assigneeUserId);
       const principal = getPrincipal(request);
-      const action = await input.repository.createAction({ id: randomUUID(), workspaceId, ...body, createdBy: principal.userId });
-      await audit(input.repository, request, workspaceId, 'action.created', 'action_item', action.id, { priority: action.priority });
-      response.status(201).json({ action });
+      const requestedId = randomUUID();
+      const sourceKind = resolveActionSourceKind(body);
+      const action = await input.repository.createAction({
+        id: requestedId,
+        workspaceId,
+        ...body,
+        sourceKind,
+        creationKey: body.creationKey ?? requestedId,
+        createdBy: principal.userId,
+      });
+      const idempotent = action.id !== requestedId;
+      await audit(input.repository, request, workspaceId, idempotent ? 'action.reused' : 'action.created', 'action_item', action.id, {
+        priority: action.priority,
+        sourceAnalysisId: action.sourceAnalysisId,
+        sourceInsightId: action.sourceInsightId,
+        sourceDecisionId: action.sourceDecisionId,
+        sourceKind: action.sourceKind,
+        creationKey: action.creationKey,
+        evidenceIds: action.evidenceIds,
+        evidenceCount: action.evidenceIds.length,
+        validationMetric: action.validationMetric,
+      });
+      response.status(idempotent ? 200 : 201).json({ action, idempotent });
     } catch (error) { next(error); }
   });
 
@@ -307,3 +434,19 @@ async function requireActiveAssignee(
     throw new ApiError(400, 'assignee_not_workspace_member');
   }
 }
+
+type InsightDecisionRepository = PlatformRepository & Required<Pick<
+  PlatformRepository,
+  'listInsightDecisions' | 'getInsightDecision' | 'createInsightDecision'
+>>;
+
+function requireInsightDecisionRepository(repository: PlatformRepository): InsightDecisionRepository {
+  if (
+    !repository.listInsightDecisions
+    || !repository.getInsightDecision
+    || !repository.createInsightDecision
+  ) {
+    throw new ApiError(501, 'insight_decisions_not_supported');
+  }
+  return repository as InsightDecisionRepository;
+}

+ 8 - 0
src/server.ts

@@ -12,8 +12,11 @@ import { startSyncWorker } from './modules/domestic-voc/jobs/sync-worker.js';
 import { VocIngestionRepository } from './modules/domestic-voc/repositories/voc-ingestion.repository.js';
 import { JdSyncService } from './modules/domestic-voc/services/jd-sync.service.js';
 import { FmodeVocEcommerceClient } from './modules/domestic-voc/upstream/fmode-client.js';
+import { ParseRestAiPromptConfigStore } from './modules/ai-gateway/prompt-config.repository.js';
+import { DEFAULT_DOMESTIC_AI_PROMPT_CONFIGS } from './modules/ai-gateway/default-prompt-configs.js';
 import { ParseRestVocRepository } from './modules/saas-platform/parse-rest-voc.repository.js';
 import { PostgresPlatformRepository } from './modules/saas-platform/postgres-platform.repository.js';
+import { ParseRestProductKnowledgeStore } from './modules/product-knowledge/product-knowledge.store.js';
 
 async function main(): Promise<void> {
   const config = loadConfig();
@@ -33,6 +36,9 @@ async function main(): Promise<void> {
     });
     await ensureVocParseSchemas(client);
     const repository = new ParseRestVocRepository(client);
+    const promptConfigs = new ParseRestAiPromptConfigStore(client, config.auth.defaultWorkspaceId);
+    const productKnowledge = new ParseRestProductKnowledgeStore(client);
+    await promptConfigs.ensureDefaults(DEFAULT_DOMESTIC_AI_PROMPT_CONFIGS);
     if (bootstrapUserId) {
       await repository.bootstrapAdmin(config.auth.defaultWorkspaceId, {
         userId: bootstrapUserId,
@@ -51,6 +57,8 @@ async function main(): Promise<void> {
         const health = await repository.health();
         return { ready: health.ready, missingObjects: health.missingClasses };
       },
+      aiPromptConfigs: promptConfigs,
+      productKnowledge,
     });
     const processor = new JdSyncService(gateway, repository, config.worker.reviewMaxPages);
     worker = config.worker.enabled

+ 24 - 0
src/types/domestic-dataset.ts

@@ -46,6 +46,17 @@ export interface DomesticProductProfile {
   collectedAt: string;
 }
 
+export interface DomesticProductMarketSnapshot {
+  currentPrice: number;
+  salesText: string;
+  monthSalesText: string;
+  shopName: string;
+  shopId: string;
+  searchKeyword: string;
+  onShelvesAt: string;
+  collectedAt: string;
+}
+
 export interface DomesticProduct {
   platform: string;
   productId: string;
@@ -63,6 +74,7 @@ export interface DomesticProduct {
   summary: DomesticMetricSummary;
   trend: Array<{ date: string; gmv: number; soldUnits: number; transactionOrders: number }>;
   detail?: DomesticProductProfile;
+  market?: DomesticProductMarketSnapshot;
 }
 
 export interface DomesticReview {
@@ -81,6 +93,9 @@ export interface DomesticProductRelation {
   competitorProductId: string;
   competitorBrand: string;
   category: string;
+  discoverySource?: 'workbook' | 'brand_category_search';
+  searchKeyword?: string;
+  discoveredAt?: string;
 }
 
 export interface DomesticMappingGroup {
@@ -117,6 +132,15 @@ export interface DomesticDataset {
   mappingGroups: DomesticMappingGroup[];
   relations: DomesticProductRelation[];
   reviews: DomesticReview[];
+  enrichment?: {
+    status: 'complete' | 'partial';
+    queryCount: number;
+    successfulQueries: number;
+    discoveredProducts: number;
+    reviewedProducts: number;
+    collectedReviews: number;
+    collectedAt: string;
+  };
   quality: {
     orphanMappings: Array<{ ownProductId: string; model: string; category: string }>;
     mappingsWithoutCompetitor: Array<{ ownProductId: string; model: string; category: string }>;

+ 354 - 0
test/action-item-source.repository.test.ts

@@ -0,0 +1,354 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import type { ParseRestClient } from '../src/db/parse-rest.client.js';
+import type { Queryable } from '../src/db/types.js';
+import { ApiError } from '../src/http/api-error.js';
+import type { ActionItem } from '../src/modules/saas-platform/domain.js';
+import { ParseRestVocRepository } from '../src/modules/saas-platform/parse-rest-voc.repository.js';
+import { PostgresPlatformRepository } from '../src/modules/saas-platform/postgres-platform.repository.js';
+
+const timestamp = '2026-08-06T08:00:00.000Z';
+
+const actionInput: Omit<ActionItem, 'completedAt' | 'createdAt' | 'updatedAt'> & {
+  sourceDecisionId: string;
+  sourceKind: 'insight';
+  creationKey: string;
+} = {
+  id: 'action-1',
+  workspaceId: 'workspace-1',
+  sourceAnalysisId: 'analysis-1',
+  sourceInsightId: 'insight-1',
+  sourceDecisionId: 'decision-1',
+  sourceKind: 'insight',
+  creationKey: 'workspace-1|analysis-1|insight-1|decision-1',
+  evidenceIds: ['evidence-1', 'evidence-1', 'evidence-2'],
+  validationMetric: 'Evidence coverage >= 80%',
+  actionType: 'experience',
+  title: 'Improve evidence traceability',
+  description: 'Keep insight evidence attached to delivery.',
+  priority: 'high',
+  status: 'open',
+  productKey: null,
+  assigneeUserId: null,
+  dueAt: null,
+  createdBy: 'user-1',
+};
+
+const analysisRow = {
+  public_id: 'analysis-1',
+  workspace_public_id: 'workspace-1',
+  analysis_type: 'voc_insight',
+  target_kind: 'workspace',
+  target_key: '',
+  status: 'completed',
+  input: {},
+  result: { insights: [{ id: 'insight-1', evidenceIds: ['evidence-1', 'evidence-2'] }] },
+  evidence_count: 2,
+  requested_by_external_id: 'user-1',
+  error_summary: null,
+  requested_at: timestamp,
+  started_at: timestamp,
+  completed_at: timestamp,
+};
+
+const decisionRow = {
+  public_id: 'decision-1',
+  workspace_public_id: 'workspace-1',
+  source_analysis_public_id: 'analysis-1',
+  source_insight_id: 'insight-1',
+  decision: 'confirmed',
+  reviewed_evidence_ids: ['evidence-1', 'evidence-2'],
+  comment: 'Evidence reviewed.',
+  decided_by_external_id: 'user-1',
+  decided_at: timestamp,
+  version: 1,
+  supersedes_public_id: null,
+  is_current: true,
+  created_at: timestamp,
+  updated_at: timestamp,
+};
+
+test('Parse REST action repository writes and maps insight provenance', async () => {
+  let createdBody: Record<string, unknown> | null = null;
+  const client = {
+    async findOne(className: string, where: Record<string, unknown>) {
+      if (className === 'VocActionItem') {
+        assert.equal(where.workspaceId, 'workspace-1');
+        return null;
+      }
+      if (className === 'VocInsightDecision') {
+        assert.deepEqual(where, { workspaceId: 'workspace-1', publicId: 'decision-1' });
+        return {
+          objectId: 'parse-decision-1',
+          publicId: 'decision-1',
+          workspaceId: 'workspace-1',
+          sourceAnalysisId: 'analysis-1',
+          sourceInsightId: 'insight-1',
+          decision: 'confirmed',
+          reviewedEvidenceIds: ['evidence-1', 'evidence-2'],
+          comment: 'Evidence reviewed.',
+          decidedBy: 'user-1',
+          decidedAt: timestamp,
+          version: 1,
+          supersedesId: null,
+          isCurrent: true,
+          createdAt: timestamp,
+          updatedAt: timestamp,
+        };
+      }
+      assert.equal(className, 'VocAnalysisRun');
+      assert.deepEqual(where, { workspaceId: 'workspace-1', publicId: 'analysis-1' });
+      return {
+        objectId: 'parse-analysis-1',
+        createdAt: timestamp,
+        publicId: 'analysis-1',
+        workspaceId: 'workspace-1',
+        analysisType: 'voc_insight',
+        targetKind: 'workspace',
+        targetKey: '',
+        status: 'completed',
+        input: {},
+        result: { insights: [{ id: 'insight-1', evidenceIds: ['evidence-1', 'evidence-2'] }] },
+        evidenceCount: 2,
+        requestedBy: 'user-1',
+        requestedAt: timestamp,
+        startedAt: timestamp,
+        completedAt: timestamp,
+      };
+    },
+    async create(className: string, body: Record<string, unknown>) {
+      assert.equal(className, 'VocActionItem');
+      createdBody = body;
+      return { objectId: 'parse-action-1', createdAt: timestamp };
+    },
+  } as unknown as ParseRestClient;
+
+  const repository = new ParseRestVocRepository(client);
+  const action = await repository.createAction(actionInput);
+
+  assert.deepEqual((createdBody as unknown as { evidenceIds: string[] }).evidenceIds, ['evidence-1', 'evidence-2']);
+  assert.equal((createdBody as unknown as { sourceAnalysisId: string }).sourceAnalysisId, 'analysis-1');
+  assert.equal((createdBody as unknown as { sourceDecisionId: string }).sourceDecisionId, 'decision-1');
+  assert.equal((createdBody as unknown as { sourceKind: string }).sourceKind, 'insight');
+  assert.equal(
+    (createdBody as unknown as { creationKey: string }).creationKey,
+    'workspace-1|analysis-1|insight-1|decision-1',
+  );
+  assert.equal(action.sourceInsightId, 'insight-1');
+  assert.equal(action.sourceDecisionId, 'decision-1');
+  assert.deepEqual(action.evidenceIds, ['evidence-1', 'evidence-2']);
+  assert.equal(action.validationMetric, 'Evidence coverage >= 80%');
+
+  await assert.rejects(
+    repository.createAction({ ...actionInput, sourceInsightId: 'insight-unknown' }),
+    (error) => error instanceof ApiError && error.code === 'source_insight_not_found',
+  );
+  await assert.rejects(
+    repository.createAction({ ...actionInput, sourceAnalysisId: null }),
+    (error) => error instanceof ApiError && error.code === 'source_insight_orphan',
+  );
+});
+
+test('Postgres action repository converts the source public ID and maps provenance', async () => {
+  const calls: Array<{ text: string; values: readonly unknown[] }> = [];
+  const database = {
+    async query(text: string, values: readonly unknown[] = []) {
+      calls.push({ text, values });
+      if (text.includes('action.creation_key = $2')) {
+        return { rows: [], rowCount: 0 };
+      }
+      if (text.includes('FROM voc.analysis_run run')) {
+        return { rows: [analysisRow], rowCount: 1 };
+      }
+      if (text.includes('FROM voc.insight_decision decision')) {
+        return { rows: [decisionRow], rowCount: 1 };
+      }
+      assert.match(text, /INSERT INTO voc\.action_item/);
+      assert.match(text, /source_analysis_id/);
+      assert.match(text, /source_insight_id/);
+      assert.match(text, /evidence_ids/);
+      assert.match(text, /validation_metric/);
+      return {
+        rows: [{
+          public_id: 'action-1',
+          workspace_public_id: 'workspace-1',
+          source_analysis_public_id: 'analysis-1',
+          source_insight_id: 'insight-1',
+          source_decision_id: 12,
+          source_decision_public_id: 'decision-1',
+          source_kind: 'insight',
+          creation_key: 'workspace-1|analysis-1|insight-1|decision-1',
+          evidence_ids: ['evidence-1', 'evidence-2'],
+          validation_metric: 'Evidence coverage >= 80%',
+          action_type: 'experience',
+          title: 'Improve evidence traceability',
+          description: 'Keep insight evidence attached to delivery.',
+          priority: 'high',
+          status: 'open',
+          product_key: null,
+          assignee_external_id: null,
+          due_at: null,
+          created_by_external_id: 'user-1',
+          completed_at: null,
+          created_at: timestamp,
+          updated_at: timestamp,
+        }],
+        rowCount: 1,
+      };
+    },
+  } as unknown as Queryable;
+
+  const repository = new PostgresPlatformRepository(database);
+  const action = await repository.createAction(actionInput);
+
+  assert.equal(calls.length, 4);
+  assert.equal(calls[3]!.values[11], 'analysis-1');
+  assert.equal(calls[3]!.values[12], 'insight-1');
+  assert.equal(calls[3]!.values[13], JSON.stringify(['evidence-1', 'evidence-2']));
+  assert.equal(calls[3]!.values[14], 'Evidence coverage >= 80%');
+  assert.equal(calls[3]!.values[15], 'decision-1');
+  assert.equal(calls[3]!.values[16], 'insight');
+  assert.equal(calls[3]!.values[17], 'workspace-1|analysis-1|insight-1|decision-1');
+  assert.equal(action.sourceAnalysisId, 'analysis-1');
+  assert.equal(action.sourceDecisionId, 'decision-1');
+  assert.equal(action.sourceKind, 'insight');
+  assert.equal(action.creationKey, 'workspace-1|analysis-1|insight-1|decision-1');
+  assert.deepEqual(action.evidenceIds, ['evidence-1', 'evidence-2']);
+
+  await assert.rejects(
+    repository.createAction({ ...actionInput, evidenceIds: ['evidence-from-other-insight'] }),
+    (error) => error instanceof ApiError && error.code === 'source_evidence_not_in_insight',
+  );
+  await assert.rejects(
+    repository.createAction({ ...actionInput, sourceInsightId: null, evidenceIds: [] }),
+    (error) => error instanceof ApiError && error.code === 'source_insight_required',
+  );
+});
+
+test('action repositories return the existing creation key without creating another row', async () => {
+  let parseCreates = 0;
+  const parseClient = {
+    async findOne(className: string, where: Record<string, unknown>) {
+      if (className === 'VocAnalysisRun') {
+        assert.deepEqual(where, { workspaceId: 'workspace-1', publicId: 'analysis-1' });
+        return {
+          objectId: 'parse-analysis-1',
+          createdAt: timestamp,
+          publicId: 'analysis-1',
+          workspaceId: 'workspace-1',
+          analysisType: 'voc_insight',
+          targetKind: 'workspace',
+          targetKey: '',
+          status: 'completed',
+          input: {},
+          result: { insights: [{ id: 'insight-1', evidenceIds: ['evidence-1', 'evidence-2'] }] },
+          evidenceCount: 2,
+          requestedBy: 'user-1',
+          requestedAt: timestamp,
+          startedAt: timestamp,
+          completedAt: timestamp,
+        };
+      }
+      if (className === 'VocInsightDecision') {
+        assert.deepEqual(where, { workspaceId: 'workspace-1', publicId: 'decision-1' });
+        return {
+          objectId: 'parse-decision-1',
+          publicId: 'decision-1',
+          workspaceId: 'workspace-1',
+          sourceAnalysisId: 'analysis-1',
+          sourceInsightId: 'insight-1',
+          decision: 'confirmed',
+          reviewedEvidenceIds: ['evidence-1', 'evidence-2'],
+          comment: 'Evidence reviewed.',
+          decidedBy: 'user-1',
+          decidedAt: timestamp,
+          version: 1,
+          supersedesId: null,
+          isCurrent: true,
+          createdAt: timestamp,
+          updatedAt: timestamp,
+        };
+      }
+      assert.equal(className, 'VocActionItem');
+      assert.deepEqual(where, {
+        workspaceId: 'workspace-1',
+        creationKey: 'workspace-1|analysis-1|insight-1|decision-1',
+      });
+      return {
+        objectId: 'parse-action-1',
+        publicId: 'action-existing',
+        workspaceId: 'workspace-1',
+        sourceAnalysisId: 'analysis-1',
+        sourceInsightId: 'insight-1',
+        sourceDecisionId: 'decision-1',
+        sourceKind: 'insight',
+        creationKey: 'workspace-1|analysis-1|insight-1|decision-1',
+        evidenceIds: ['evidence-1', 'evidence-2'],
+        validationMetric: 'Evidence coverage >= 80%',
+        actionType: 'experience',
+        title: 'Existing action',
+        description: '',
+        priority: 'high',
+        status: 'open',
+        createdBy: 'user-1',
+        createdAt: timestamp,
+        updatedAt: timestamp,
+      };
+    },
+    async create() {
+      parseCreates += 1;
+      throw new Error('Parse create should not run for an existing creation key');
+    },
+  } as unknown as ParseRestClient;
+
+  const parseAction = await new ParseRestVocRepository(parseClient).createAction(actionInput);
+  assert.equal(parseAction.id, 'action-existing');
+  assert.equal(parseCreates, 0);
+
+  let postgresQueries = 0;
+  const database = {
+    async query(text: string, values: readonly unknown[] = []) {
+      postgresQueries += 1;
+      if (text.includes('FROM voc.analysis_run run')) {
+        return { rows: [analysisRow], rowCount: 1 };
+      }
+      if (text.includes('FROM voc.insight_decision decision')) {
+        return { rows: [decisionRow], rowCount: 1 };
+      }
+      assert.match(text, /action\.creation_key = \$2/);
+      assert.deepEqual(values, ['workspace-1', 'workspace-1|analysis-1|insight-1|decision-1']);
+      return {
+        rows: [{
+          public_id: 'action-existing',
+          workspace_public_id: 'workspace-1',
+          source_analysis_id: 10,
+          source_analysis_public_id: 'analysis-1',
+          source_insight_id: 'insight-1',
+          source_decision_id: 11,
+          source_decision_public_id: 'decision-1',
+          source_kind: 'insight',
+          creation_key: 'workspace-1|analysis-1|insight-1|decision-1',
+          evidence_ids: ['evidence-1', 'evidence-2'],
+          validation_metric: 'Evidence coverage >= 80%',
+          action_type: 'experience',
+          title: 'Existing action',
+          description: '',
+          priority: 'high',
+          status: 'open',
+          product_key: null,
+          assignee_external_id: null,
+          due_at: null,
+          created_by_external_id: 'user-1',
+          completed_at: null,
+          created_at: timestamp,
+          updated_at: timestamp,
+        }],
+        rowCount: 1,
+      };
+    },
+  } as unknown as Queryable;
+
+  const postgresAction = await new PostgresPlatformRepository(database).createAction(actionInput);
+  assert.equal(postgresAction.id, 'action-existing');
+  assert.equal(postgresQueries, 3);
+});

+ 183 - 0
test/ai-gateway.test.ts

@@ -0,0 +1,183 @@
+import assert from 'node:assert/strict';
+import type { AddressInfo } from 'node:net';
+import test from 'node:test';
+import express from 'express';
+import { FmodeAiClient, type AiGatewayFetch } from '../src/modules/ai-gateway/client.js';
+import { createAiGatewayRouter } from '../src/modules/ai-gateway/routes.js';
+import type { AiPromptConfigRecord, AiPromptConfigStore } from '../src/modules/ai-gateway/prompt-config.repository.js';
+
+const config = {
+  baseUrl: 'https://api.example.test/',
+  token: 'server-only-token',
+  defaultModel: 'deepseek-v4-pro',
+  timeoutMs: 5_000,
+};
+
+async function listen(fetchImpl: AiGatewayFetch, token = config.token, promptConfigs?: AiPromptConfigStore) {
+  const app = express();
+  app.use(express.json());
+  app.use('/api/ai', createAiGatewayRouter(new FmodeAiClient({ ...config, token }, fetchImpl), promptConfigs));
+  const server = await new Promise<ReturnType<typeof app.listen>>((resolve) => {
+    const listening = app.listen(0, '127.0.0.1', () => resolve(listening));
+  });
+  const address = server.address() as AddressInfo;
+  return {
+    baseUrl: `http://127.0.0.1:${address.port}`,
+    close: () => new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve())),
+  };
+}
+
+test('AI gateway reports only public configuration and keeps the token server-side', async () => {
+  const server = await listen(async () => new Response('{}'));
+  try {
+    const response = await fetch(`${server.baseUrl}/api/ai/status`);
+    assert.equal(response.status, 200);
+    const status = await response.json() as Record<string, unknown>;
+    assert.deepEqual(status, {
+      service: 'Fmode AI',
+      configured: true,
+      baseUrl: 'https://api.example.test',
+      defaultModel: 'deepseek-v4-pro',
+      proxyEndpoint: '/api/ai/chat/completions',
+    });
+    assert.equal(JSON.stringify(status).includes(config.token), false);
+  } finally {
+    await server.close();
+  }
+});
+
+test('AI prompt configuration is read and saved through the backend store', async () => {
+  const records = new Map<string, AiPromptConfigRecord>([
+    ['shared.analysisPanel.defaultSystem', {
+      promptKey: 'shared.analysisPanel.defaultSystem',
+      name: '默认分析提示词',
+      scope: 'analysis',
+      template: '仅使用真实数据',
+    }],
+  ]);
+  const promptConfigs: AiPromptConfigStore = {
+    async list() {
+      return [...records.values()];
+    },
+    async upsert(promptKey, value) {
+      const saved = { ...value, promptKey };
+      records.set(promptKey, saved);
+      return saved;
+    },
+  };
+  const server = await listen(async () => new Response('{}'), config.token, promptConfigs);
+  try {
+    const listResponse = await fetch(`${server.baseUrl}/api/ai/prompts`);
+    assert.equal(listResponse.status, 200);
+    const list = await listResponse.json() as { items: AiPromptConfigRecord[] };
+    assert.equal(list.items[0]?.template, '仅使用真实数据');
+
+    const promptKey = 'shared.analysisPanel.defaultSystem';
+    const saveResponse = await fetch(`${server.baseUrl}/api/ai/prompts/${promptKey}`, {
+      method: 'PUT',
+      headers: { 'content-type': 'application/json' },
+      body: JSON.stringify({
+        promptKey,
+        name: '默认分析提示词',
+        scope: 'analysis',
+        template: '只输出可追溯结论',
+      }),
+    });
+    assert.equal(saveResponse.status, 200);
+    assert.equal(records.get(promptKey)?.template, '只输出可追溯结论');
+  } finally {
+    await server.close();
+  }
+});
+
+test('AI gateway validates requests and injects upstream authorization', async () => {
+  let upstreamUrl = '';
+  let upstreamAuthorization = '';
+  let upstreamBody: Record<string, unknown> = {};
+  const fetchImpl: AiGatewayFetch = async (input, init) => {
+    upstreamUrl = String(input);
+    upstreamAuthorization = new Headers(init?.headers).get('authorization') || '';
+    upstreamBody = JSON.parse(String(init?.body || '{}')) as Record<string, unknown>;
+    return new Response(JSON.stringify({
+      model: upstreamBody['model'],
+      choices: [{ message: { role: 'assistant', content: '分析完成' }, finish_reason: 'stop' }],
+    }), { status: 200, headers: { 'content-type': 'application/json' } });
+  };
+  const server = await listen(fetchImpl);
+  try {
+    const invalid = await fetch(`${server.baseUrl}/api/ai/chat/completions`, {
+      method: 'POST',
+      headers: { 'content-type': 'application/json' },
+      body: JSON.stringify({ messages: [{ role: 'user', content: 'test' }], token: 'browser-token' }),
+    });
+    assert.equal(invalid.status, 400);
+
+    const response = await fetch(`${server.baseUrl}/api/ai/chat/completions`, {
+      method: 'POST',
+      headers: { 'content-type': 'application/json' },
+      body: JSON.stringify({ messages: [{ role: 'user', content: '分析真实评论' }], stream: false }),
+    });
+    assert.equal(response.status, 200);
+    assert.equal(upstreamUrl, 'https://api.example.test/v1/chat/completions');
+    assert.equal(upstreamAuthorization, 'Bearer server-only-token');
+    assert.equal(upstreamBody['model'], 'deepseek-v4-pro');
+    assert.equal(Object.prototype.hasOwnProperty.call(upstreamBody, 'token'), false);
+  } finally {
+    await server.close();
+  }
+});
+
+test('AI gateway streams SSE responses without buffering', async () => {
+  const fetchImpl: AiGatewayFetch = async () => new Response(
+    'data: {"choices":[{"delta":{"content":"洞察"}}]}\n\ndata: [DONE]\n\n',
+    { status: 200, headers: { 'content-type': 'text/event-stream' } },
+  );
+  const server = await listen(fetchImpl);
+  try {
+    const response = await fetch(`${server.baseUrl}/api/ai/chat/completions`, {
+      method: 'POST',
+      headers: { 'content-type': 'application/json' },
+      body: JSON.stringify({ messages: [{ role: 'user', content: 'test' }], stream: true }),
+    });
+    assert.equal(response.status, 200);
+    assert.match(response.headers.get('content-type') || '', /text\/event-stream/);
+    assert.match(await response.text(), /洞察/);
+  } finally {
+    await server.close();
+  }
+});
+
+test('AI gateway returns a controlled error when the server token is missing', async () => {
+  const server = await listen(async () => new Response('{}'), '');
+  try {
+    const response = await fetch(`${server.baseUrl}/api/ai/chat/completions`, {
+      method: 'POST',
+      headers: { 'content-type': 'application/json' },
+      body: JSON.stringify({ messages: [{ role: 'user', content: 'test' }], stream: false }),
+    });
+    assert.equal(response.status, 503);
+    assert.equal((await response.json() as any).error.message, 'AI 服务尚未配置,请联系管理员');
+  } finally {
+    await server.close();
+  }
+});
+
+test('AI gateway maps upstream transport failures without exposing internals', async () => {
+  const server = await listen(async () => {
+    throw new TypeError('socket closed while connecting to upstream');
+  });
+  try {
+    const response = await fetch(`${server.baseUrl}/api/ai/chat/completions`, {
+      method: 'POST',
+      headers: { 'content-type': 'application/json' },
+      body: JSON.stringify({ messages: [{ role: 'user', content: 'test' }], stream: false }),
+    });
+    assert.equal(response.status, 502);
+    const payload = await response.json() as { error: { message: string } };
+    assert.equal(payload.error.message, 'AI 上游连接失败,请稍后重试');
+    assert.equal(JSON.stringify(payload).includes(config.token), false);
+    assert.equal(JSON.stringify(payload).includes('socket closed'), false);
+  } finally {
+    await server.close();
+  }
+});

+ 100 - 0
test/ai-prompt-config.repository.test.ts

@@ -0,0 +1,100 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import type { ParseObject } from '../src/db/parse-rest.client.js';
+import { VOC_PARSE_CLASSES } from '../src/db/parse-rest.schema.js';
+import {
+  ParseRestAiPromptConfigStore,
+  type AiPromptConfigRecord,
+} from '../src/modules/ai-gateway/prompt-config.repository.js';
+
+type StoredPrompt = AiPromptConfigRecord & ParseObject;
+
+class FakePromptClient {
+  readonly records: StoredPrompt[] = [];
+  private nextId = 1;
+
+  async findAll<T>(className: string, where: Record<string, unknown> = {}): Promise<Array<T & ParseObject>> {
+    assert.equal(className, VOC_PARSE_CLASSES.promptConfig);
+    return this.records.filter((record) => matches(record, where)) as unknown as Array<T & ParseObject>;
+  }
+
+  async findOne<T>(className: string, where: Record<string, unknown>): Promise<(T & ParseObject) | null> {
+    assert.equal(className, VOC_PARSE_CLASSES.promptConfig);
+    return (this.records.find((record) => matches(record, where)) ?? null) as (T & ParseObject) | null;
+  }
+
+  async create<T extends Record<string, unknown>>(className: string, object: T): Promise<ParseObject> {
+    assert.equal(className, VOC_PARSE_CLASSES.promptConfig);
+    const now = '2026-07-27T00:00:00.000Z';
+    const stored = {
+      ...object,
+      objectId: `prompt-${this.nextId++}`,
+      createdAt: now,
+      updatedAt: now,
+    } as unknown as StoredPrompt;
+    this.records.push(stored);
+    return stored;
+  }
+
+  async update<T extends Record<string, unknown>>(
+    className: string,
+    objectId: string,
+    patch: T,
+  ): Promise<{ updatedAt: string }> {
+    assert.equal(className, VOC_PARSE_CLASSES.promptConfig);
+    const record = this.records.find((item) => item.objectId === objectId);
+    assert.ok(record);
+    const updatedAt = '2026-07-27T01:00:00.000Z';
+    Object.assign(record, patch, { updatedAt });
+    return { updatedAt };
+  }
+}
+
+function matches(record: StoredPrompt, where: Record<string, unknown>): boolean {
+  return Object.entries(where).every(([key, value]) => record[key as keyof StoredPrompt] === value);
+}
+
+function prompt(promptKey: string, template: string): AiPromptConfigRecord {
+  return {
+    promptKey,
+    name: promptKey,
+    module: 'test',
+    scope: 'analysis',
+    template,
+    enabled: true,
+    variables: [],
+    dataSources: [],
+  };
+}
+
+test('AI prompt store isolates workspaces and seeds defaults idempotently', async () => {
+  const client = new FakePromptClient();
+  const otherWorkspace = new ParseRestAiPromptConfigStore(client, 'other');
+  const demashi = new ParseRestAiPromptConfigStore(client, 'demashi');
+  await otherWorkspace.upsert('shared.analysisPanel.defaultSystem', prompt('shared.analysisPanel.defaultSystem', 'other'));
+
+  const first = await demashi.ensureDefaults([
+    prompt('shared.analysisPanel.defaultSystem', 'demashi default'),
+    prompt('global.analysis.defaultModel', 'deepseek-v4-pro'),
+  ]);
+  const second = await demashi.ensureDefaults([
+    prompt('shared.analysisPanel.defaultSystem', 'must not overwrite'),
+    prompt('global.analysis.defaultModel', 'must not overwrite'),
+  ]);
+
+  assert.deepEqual(first, { created: 2, existing: 0 });
+  assert.deepEqual(second, { created: 0, existing: 2 });
+  assert.equal((await demashi.list()).length, 2);
+  assert.equal((await otherWorkspace.list())[0]?.template, 'other');
+  assert.equal((await demashi.list())[0]?.workspaceId, 'demashi');
+  assert.equal((await demashi.list())[0]?.revision, 1);
+  assert.equal((await demashi.list())[1]?.model, 'deepseek-v4-pro');
+
+  const updated = await demashi.upsert(
+    'shared.analysisPanel.defaultSystem',
+    prompt('shared.analysisPanel.defaultSystem', 'customized'),
+  );
+  assert.equal(updated.template, 'customized');
+  assert.equal(updated.revision, 2);
+  assert.equal(client.records.length, 3);
+});

+ 261 - 0
test/analysis-run.test.ts

@@ -0,0 +1,261 @@
+import assert from 'node:assert/strict';
+import type { AddressInfo } from 'node:net';
+import test from 'node:test';
+import express, { type ErrorRequestHandler } from 'express';
+import { ZodError } from 'zod';
+import { ApiError } from '../src/http/api-error.js';
+import type { DomesticDataset, DomesticMetricSummary, DomesticProduct } from '../src/types/domestic-dataset.js';
+import { LocalSyncJobStore } from '../src/modules/domestic-voc/local/local-sync-job.store.js';
+import {
+  createAuthenticationMiddleware,
+  type RequestAuthenticator,
+  WorkspaceAccessService,
+} from '../src/modules/saas-platform/auth.js';
+import { prepareAnalysisRunPatch } from '../src/modules/saas-platform/domain.js';
+import { LocalPlatformRepository } from '../src/modules/saas-platform/local-platform.repository.js';
+import { createSaasPlatformRouter } from '../src/modules/saas-platform/routes.js';
+
+const emptyMetrics: DomesticMetricSummary = {
+  gmv: 0,
+  soldUnits: 0,
+  transactionOrders: 0,
+  transactionCustomers: 0,
+  impressions: 0,
+  clicks: 0,
+  views: 0,
+  visitors: 0,
+  cartUnits: 0,
+  orderAmount: 0,
+  orderUnits: 0,
+  orderCount: 0,
+  refundAmount: 0,
+  refundUnits: 0,
+  refundOrders: 0,
+  conversionRate: 0,
+  clickThroughRate: 0,
+  averageUnitPrice: 0,
+  refundToGmvRate: 0,
+};
+
+function product(productId: string, role: DomesticProduct['role']): DomesticProduct {
+  return {
+    platform: 'jd',
+    productId,
+    productKey: `jd:${productId}`,
+    asin: productId,
+    role,
+    brand: role === 'own' ? 'Demashi' : 'Competitor',
+    title: `Product ${productId}`,
+    model: `M-${productId}`,
+    category1: 'Commercial appliance',
+    category2: 'Kitchen equipment',
+    category3: 'Oven',
+    source: 'test',
+    relationCount: role === 'own' ? 1 : 0,
+    summary: emptyMetrics,
+    trend: [],
+  };
+}
+
+const dataset: DomesticDataset = {
+  schemaVersion: 1,
+  generatedAt: '2026-08-06T00:00:00.000Z',
+  caseName: 'Demashi',
+  platform: 'jd',
+  source: {
+    sourceFile: 'analysis-run.json',
+    sourceHash: 'test',
+    sheets: [],
+    dateRange: { start: '2026-08-01', end: '2026-08-06' },
+  },
+  summary: {
+    metricRows: 0,
+    metricProducts: 1,
+    mappingRows: 0,
+    relations: 0,
+    uniqueCompetitorProducts: 0,
+    category2Count: 1,
+    category3Count: 1,
+    reviewCount: 0,
+  },
+  dailyTotals: [],
+  products: [product('1001', 'own')],
+  mappingGroups: [],
+  relations: [],
+  reviews: [],
+  quality: { orphanMappings: [], mappingsWithoutCompetitor: [], brandWithoutProductId: [] },
+};
+
+async function listen(app: ReturnType<typeof express>) {
+  const server = await new Promise<ReturnType<typeof app.listen>>((resolve) => {
+    const listening = app.listen(0, '127.0.0.1', () => resolve(listening));
+  });
+  const address = server.address() as AddressInfo;
+  return {
+    baseUrl: `http://127.0.0.1:${address.port}`,
+    close: () => new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve())),
+  };
+}
+
+async function json(response: Response): Promise<Record<string, any>> {
+  return await response.json() as Record<string, any>;
+}
+
+test('analysis run patch helper enforces state machine and required payloads', () => {
+  const pending = {
+    id: 'analysis-1',
+    workspaceId: 'demashi',
+    analysisType: 'voc_insight' as const,
+    targetKind: 'workspace' as const,
+    targetKey: '',
+    status: 'pending' as const,
+    input: {},
+    result: null,
+    evidenceCount: 0,
+    requestedBy: 'local-admin',
+    errorSummary: null,
+    requestedAt: '2026-08-06T00:00:00.000Z',
+    startedAt: null,
+    completedAt: null,
+  };
+
+  const processingPatch = prepareAnalysisRunPatch(pending, { status: 'processing' });
+  assert.equal(typeof processingPatch.startedAt, 'string');
+
+  const cancelledPatch = prepareAnalysisRunPatch(pending, { status: 'cancelled' });
+  assert.equal(typeof cancelledPatch.completedAt, 'string');
+
+  assert.throws(() => prepareAnalysisRunPatch(pending, { status: 'completed' }), (error: unknown) => (
+    error instanceof ApiError && error.status === 409 && error.code === 'analysis_invalid_transition'
+  ));
+
+  const processing = { ...pending, status: 'processing' as const, startedAt: '2026-08-06T00:01:00.000Z' };
+
+  assert.throws(() => prepareAnalysisRunPatch(processing, { status: 'completed' }), (error: unknown) => (
+    error instanceof ApiError && error.status === 400 && error.code === 'analysis_result_required'
+  ));
+  assert.throws(() => prepareAnalysisRunPatch(processing, { status: 'partial' }), (error: unknown) => (
+    error instanceof ApiError && error.status === 400 && error.code === 'analysis_result_required'
+  ));
+  assert.throws(() => prepareAnalysisRunPatch(processing, { status: 'failed' }), (error: unknown) => (
+    error instanceof ApiError && error.status === 400 && error.code === 'analysis_error_summary_required'
+  ));
+
+  const partialPatch = prepareAnalysisRunPatch(processing, { status: 'partial', result: { insight: 'ok' } });
+  assert.equal(typeof partialPatch.completedAt, 'string');
+
+  const completed = { ...processing, status: 'completed' as const, result: { insight: 'ok' }, completedAt: '2026-08-06T00:02:00.000Z' };
+  assert.throws(() => prepareAnalysisRunPatch(completed, { status: 'failed', errorSummary: 'late' }), (error: unknown) => (
+    error instanceof ApiError && error.status === 409 && error.code === 'analysis_terminal'
+  ));
+});
+
+test('analysis run patch persists through local repository and router audit', async () => {
+  const jobs = new LocalSyncJobStore(dataset);
+  const repository = new LocalPlatformRepository(dataset, jobs, {
+    userId: 'local-admin',
+    email: 'local-admin@localhost',
+    displayName: 'Local Admin',
+  });
+  await repository.upsertMember({
+    workspaceId: 'demashi',
+    userId: 'viewer-user',
+    email: 'viewer@example.test',
+    displayName: 'Viewer',
+    role: 'viewer',
+    status: 'active',
+  });
+
+  const authenticator: RequestAuthenticator = {
+    async authenticate(request) {
+      const userId = request.header('X-Test-User') || 'local-admin';
+      return { userId, email: `${userId}@example.test`, displayName: userId, authMode: 'disabled' };
+    },
+  };
+  const app = express();
+  app.use(express.json());
+  app.use('/api', createAuthenticationMiddleware(authenticator));
+  app.use('/api/saas', createSaasPlatformRouter({
+    repository,
+    access: new WorkspaceAccessService(repository),
+  }));
+  const errorHandler: ErrorRequestHandler = (error, _request, response, _next) => {
+    if (error instanceof ApiError) {
+      response.status(error.status).json({ error: error.code });
+      return;
+    }
+    if (error instanceof ZodError) {
+      response.status(400).json({ error: 'invalid_request' });
+      return;
+    }
+    response.status(500).json({ error: 'internal_error' });
+  };
+  app.use(errorHandler);
+  const server = await listen(app);
+
+  try {
+    const ownerHeaders = { 'Content-Type': 'application/json', 'X-Test-User': 'local-admin' };
+    const viewerHeaders = { 'Content-Type': 'application/json', 'X-Test-User': 'viewer-user' };
+
+    const createResponse = await fetch(`${server.baseUrl}/api/saas/workspaces/demashi/analyses`, {
+      method: 'POST',
+      headers: ownerHeaders,
+      body: JSON.stringify({ analysisType: 'voc_insight', targetKind: 'workspace', input: { source: 'local' } }),
+    });
+    assert.equal(createResponse.status, 202);
+    const created = (await json(createResponse)).analysis as { id: string; status: string };
+    assert.equal(created.status, 'pending');
+
+    const processingResponse = await fetch(`${server.baseUrl}/api/saas/workspaces/demashi/analyses/${created.id}`, {
+      method: 'PATCH',
+      headers: ownerHeaders,
+      body: JSON.stringify({ status: 'processing', startedAt: '2026-08-06T00:10:00.000Z', evidenceCount: 2 }),
+    });
+    assert.equal(processingResponse.status, 200);
+    const processing = (await json(processingResponse)).analysis as {
+      status: string; startedAt: string | null; evidenceCount: number;
+    };
+    assert.equal(processing.status, 'processing');
+    assert.equal(processing.startedAt, '2026-08-06T00:10:00.000Z');
+    assert.equal(processing.evidenceCount, 2);
+
+    const completedResponse = await fetch(`${server.baseUrl}/api/saas/workspaces/demashi/analyses/${created.id}`, {
+      method: 'PATCH',
+      headers: ownerHeaders,
+      body: JSON.stringify({
+        status: 'completed',
+        result: { findings: ['Need more evidence links'] },
+        evidenceCount: 4,
+        completedAt: '2026-08-06T00:12:00.000Z',
+      }),
+    });
+    assert.equal(completedResponse.status, 200);
+    const completed = (await json(completedResponse)).analysis as {
+      status: string; result: Record<string, unknown> | null; completedAt: string | null;
+    };
+    assert.equal(completed.status, 'completed');
+    assert.deepEqual(completed.result, { findings: ['Need more evidence links'] });
+    assert.equal(completed.completedAt, '2026-08-06T00:12:00.000Z');
+
+    const deniedViewerPatch = await fetch(`${server.baseUrl}/api/saas/workspaces/demashi/analyses/${created.id}`, {
+      method: 'PATCH',
+      headers: viewerHeaders,
+      body: JSON.stringify({ status: 'cancelled' }),
+    });
+    assert.equal(deniedViewerPatch.status, 403);
+    assert.equal((await json(deniedViewerPatch)).error, 'workspace_permission_denied');
+
+    const auditResponse = await fetch(`${server.baseUrl}/api/saas/workspaces/demashi/audit?limit=20`, { headers: ownerHeaders });
+    assert.equal(auditResponse.status, 200);
+    const auditActions = (await json(auditResponse)).items.map((item: { action: string }) => item.action);
+    assert.ok(auditActions.includes('analysis.created'));
+    assert.ok(auditActions.includes('analysis.updated'));
+
+    const stored = await repository.listAnalyses({ workspaceId: 'demashi', limit: 10, cursor: null, status: '' });
+    assert.equal(stored.items[0]?.status, 'completed');
+    assert.deepEqual(stored.items[0]?.result, { findings: ['Need more evidence links'] });
+    assert.equal(stored.items[0]?.evidenceCount, 4);
+  } finally {
+    await server.close();
+  }
+});

+ 3 - 0
test/env.test.ts

@@ -46,6 +46,9 @@ test('loadConfig normalizes URLs and CORS origins', () => {
   });
 
   assert.equal(config.fmode.baseUrl, 'http://127.0.0.1:3000/api/voc-e-commerce');
+  assert.equal(config.ai.baseUrl, 'https://api.fmode.cn');
+  assert.equal(config.ai.defaultModel, 'deepseek-v4-pro');
+  assert.equal(config.ai.token, '');
   assert.equal(config.database.migrationUrl, validEnvironment.MIGRATION_DATABASE_URL);
   assert.equal(config.worker.enabled, true);
   assert.equal(config.worker.staleAfterMs, 900_000);

+ 273 - 0
test/insight-decision.repository.test.ts

@@ -0,0 +1,273 @@
+import assert from 'node:assert/strict';
+import { readFile } from 'node:fs/promises';
+import test from 'node:test';
+import type { ParseRestClient } from '../src/db/parse-rest.client.js';
+import { VOC_PARSE_CLASSES, VOC_PARSE_SCHEMAS } from '../src/db/parse-rest.schema.js';
+import type { Queryable } from '../src/db/types.js';
+import { ParseRestVocRepository } from '../src/modules/saas-platform/parse-rest-voc.repository.js';
+import { PostgresPlatformRepository } from '../src/modules/saas-platform/postgres-platform.repository.js';
+
+const timestamp = '2026-08-07T08:00:00.000Z';
+
+const decisionInput = {
+  id: 'decision-2',
+  workspaceId: 'workspace-1',
+  sourceAnalysisId: 'analysis-1',
+  sourceInsightId: 'insight-1',
+  decision: 'confirmed' as const,
+  reviewedEvidenceIds: ['evidence-1', 'evidence-1', 'evidence-2'],
+  comment: 'Evidence reviewed by the product owner.',
+  decidedBy: 'user-1',
+};
+
+const previousParseDecision = {
+  objectId: 'parse-decision-1',
+  publicId: 'decision-1',
+  workspaceId: 'workspace-1',
+  sourceAnalysisId: 'analysis-1',
+  sourceInsightId: 'insight-1',
+  decision: 'needs_more_evidence' as const,
+  reviewedEvidenceIds: ['evidence-1'],
+  comment: 'Review another sample.',
+  decidedBy: 'user-1',
+  decidedAt: { __type: 'Date', iso: timestamp },
+  version: 1,
+  supersedesId: null,
+  isCurrent: true,
+  createdAt: timestamp,
+  updatedAt: timestamp,
+};
+
+test('Parse REST insight decisions append a version and retire the prior current record', async () => {
+  let createdBody: Record<string, unknown> | null = null;
+  const updates: Array<{ objectId: string; patch: Record<string, unknown> }> = [];
+  const client = {
+    async findOne(className: string) {
+      assert.equal(className, VOC_PARSE_CLASSES.insightDecision);
+      return null;
+    },
+    async findAll(className: string, where: Record<string, unknown>) {
+      assert.equal(className, VOC_PARSE_CLASSES.insightDecision);
+      assert.deepEqual(where, {
+        workspaceId: 'workspace-1',
+        sourceAnalysisId: 'analysis-1',
+        sourceInsightId: 'insight-1',
+      });
+      return [previousParseDecision];
+    },
+    async create(className: string, body: Record<string, unknown>) {
+      assert.equal(className, VOC_PARSE_CLASSES.insightDecision);
+      createdBody = body;
+      return { objectId: 'parse-decision-2', createdAt: timestamp, updatedAt: timestamp };
+    },
+    async update(className: string, objectId: string, patch: Record<string, unknown>) {
+      assert.equal(className, VOC_PARSE_CLASSES.insightDecision);
+      updates.push({ objectId, patch });
+      return { updatedAt: timestamp };
+    },
+  } as unknown as ParseRestClient;
+
+  const decision = await new ParseRestVocRepository(client).createInsightDecision(decisionInput);
+
+  assert.equal((createdBody as unknown as { version: number }).version, 2);
+  assert.equal((createdBody as unknown as { supersedesId: string }).supersedesId, 'decision-1');
+  assert.deepEqual(
+    (createdBody as unknown as { reviewedEvidenceIds: string[] }).reviewedEvidenceIds,
+    ['evidence-1', 'evidence-2'],
+  );
+  assert.deepEqual(updates, [{ objectId: 'parse-decision-1', patch: { isCurrent: false } }]);
+  assert.equal(decision.version, 2);
+  assert.equal(decision.supersedesId, 'decision-1');
+  assert.equal(decision.isCurrent, true);
+});
+
+test('Parse REST insight decision create returns an existing public ID without appending', async () => {
+  let writes = 0;
+  const client = {
+    async findOne() {
+      return previousParseDecision;
+    },
+    async findAll() {
+      throw new Error('findAll should not run for an existing decision ID');
+    },
+    async create() {
+      writes += 1;
+      throw new Error('create should not run for an existing decision ID');
+    },
+  } as unknown as ParseRestClient;
+
+  const decision = await new ParseRestVocRepository(client).createInsightDecision({
+    ...decisionInput,
+    id: 'decision-1',
+  });
+
+  assert.equal(decision.id, 'decision-1');
+  assert.equal(writes, 0);
+});
+
+test('Parse REST insight decisions support filtered list and workspace-scoped get', async () => {
+  const client = {
+    async findAll(className: string, where: Record<string, unknown>) {
+      assert.equal(className, VOC_PARSE_CLASSES.insightDecision);
+      assert.deepEqual(where, { workspaceId: 'workspace-1' });
+      return [previousParseDecision, { ...previousParseDecision, publicId: 'decision-retired', isCurrent: false }];
+    },
+    async findOne(className: string, where: Record<string, unknown>) {
+      assert.equal(className, VOC_PARSE_CLASSES.insightDecision);
+      assert.deepEqual(where, { workspaceId: 'workspace-1', publicId: 'decision-1' });
+      return previousParseDecision;
+    },
+  } as unknown as ParseRestClient;
+  const repository = new ParseRestVocRepository(client);
+
+  const page = await repository.listInsightDecisions({
+    workspaceId: 'workspace-1',
+    limit: 10,
+    cursor: null,
+    sourceAnalysisId: 'analysis-1',
+    sourceInsightId: 'insight-1',
+    currentOnly: true,
+  });
+  const decision = await repository.getInsightDecision('workspace-1', 'decision-1');
+
+  assert.deepEqual(page.items.map((item) => item.id), ['decision-1']);
+  assert.equal(page.nextCursor, null);
+  assert.equal(decision?.decision, 'needs_more_evidence');
+});
+
+test('Postgres insight decision create uses one append-and-retire statement and maps the version chain', async () => {
+  const calls: Array<{ text: string; values: readonly unknown[] }> = [];
+  const database = {
+    async query(text: string, values: readonly unknown[] = []) {
+      calls.push({ text, values });
+      assert.match(text, /WITH target AS/);
+      assert.match(text, /FOR UPDATE OF analysis/);
+      assert.match(text, /SET is_current = false/);
+      assert.match(text, /CROSS JOIN \(SELECT count\(\*\) FROM retired\)/);
+      return {
+        rows: [{
+          public_id: 'decision-2',
+          workspace_public_id: 'workspace-1',
+          source_analysis_public_id: 'analysis-1',
+          source_insight_id: 'insight-1',
+          decision: 'confirmed',
+          reviewed_evidence_ids: ['evidence-1', 'evidence-2'],
+          comment: 'Evidence reviewed by the product owner.',
+          decided_by_external_id: 'user-1',
+          decided_at: timestamp,
+          version: 2,
+          supersedes_public_id: 'decision-1',
+          is_current: true,
+          created_at: timestamp,
+          updated_at: timestamp,
+        }],
+        rowCount: 1,
+      };
+    },
+  } as unknown as Queryable;
+
+  const decision = await new PostgresPlatformRepository(database).createInsightDecision(decisionInput);
+
+  assert.equal(calls.length, 1);
+  assert.deepEqual(calls[0]!.values, [
+    'workspace-1',
+    'decision-2',
+    'analysis-1',
+    'insight-1',
+    'confirmed',
+    JSON.stringify(['evidence-1', 'evidence-2']),
+    'Evidence reviewed by the product owner.',
+    'user-1',
+  ]);
+  assert.equal(decision.version, 2);
+  assert.equal(decision.supersedesId, 'decision-1');
+  assert.equal(decision.decidedAt, timestamp);
+});
+
+test('Postgres insight decisions support filtered list and workspace-scoped get', async () => {
+  const calls: Array<{ text: string; values: readonly unknown[] }> = [];
+  const row = {
+    cursor_id: '42',
+    public_id: 'decision-1',
+    workspace_public_id: 'workspace-1',
+    source_analysis_public_id: 'analysis-1',
+    source_insight_id: 'insight-1',
+    decision: 'needs_more_evidence',
+    reviewed_evidence_ids: ['evidence-1'],
+    comment: 'Review another sample.',
+    decided_by_external_id: 'user-1',
+    decided_at: timestamp,
+    version: 1,
+    supersedes_public_id: null,
+    is_current: true,
+    created_at: timestamp,
+    updated_at: timestamp,
+  };
+  const database = {
+    async query(text: string, values: readonly unknown[] = []) {
+      calls.push({ text, values });
+      return { rows: [row], rowCount: 1 };
+    },
+  } as unknown as Queryable;
+  const repository = new PostgresPlatformRepository(database);
+
+  const page = await repository.listInsightDecisions({
+    workspaceId: 'workspace-1',
+    limit: 10,
+    cursor: null,
+    sourceAnalysisId: 'analysis-1',
+    sourceInsightId: 'insight-1',
+    currentOnly: true,
+  });
+  const decision = await repository.getInsightDecision('workspace-1', 'decision-1');
+
+  assert.deepEqual(calls[0]!.values, [
+    'workspace-1',
+    '9223372036854775807',
+    'analysis-1',
+    'insight-1',
+    true,
+    11,
+  ]);
+  assert.deepEqual(calls[1]!.values, ['workspace-1', 'decision-1']);
+  assert.equal(page.items[0]?.id, 'decision-1');
+  assert.equal(decision?.sourceAnalysisId, 'analysis-1');
+});
+
+test('Parse schema and PostgreSQL migrations declare decision, idempotency, and source guards', async () => {
+  const decisionSchema = VOC_PARSE_SCHEMAS.find((schema) => schema.className === VOC_PARSE_CLASSES.insightDecision);
+  const actionSchema = VOC_PARSE_SCHEMAS.find((schema) => schema.className === VOC_PARSE_CLASSES.actionItem);
+
+  assert.ok(decisionSchema);
+  assert.equal(decisionSchema.fields.isCurrent?.type, 'Boolean');
+  assert.deepEqual(decisionSchema.indexes?.voc_insight_decision_source_current_idx, {
+    workspaceId: 1,
+    sourceAnalysisId: 1,
+    sourceInsightId: 1,
+    isCurrent: 1,
+  });
+  assert.equal(actionSchema?.fields.creationKey?.type, 'String');
+  assert.deepEqual(actionSchema?.indexes?.voc_action_workspace_creation_key_idx, {
+    workspaceId: 1,
+    creationKey: 1,
+  });
+
+  const migration = await readFile(
+    new URL('../migrations/005_insight_decision_action_idempotency.sql', import.meta.url),
+    'utf8',
+  );
+  assert.match(migration, /CREATE UNIQUE INDEX IF NOT EXISTS insight_decision_source_current_unique/);
+  assert.match(migration, /CREATE UNIQUE INDEX IF NOT EXISTS action_item_workspace_creation_key_unique/);
+  assert.match(migration, /CREATE TRIGGER insight_decision_append_only_guard/);
+  assert.match(migration, /CREATE TRIGGER action_item_source_decision_guard/);
+
+  const hardeningMigration = await readFile(
+    new URL('../migrations/006_insight_action_guard_hardening.sql', import.meta.url),
+    'utf8',
+  );
+  assert.match(hardeningMigration, /reviewed evidence must cover every evidence ID/);
+  assert.match(hardeningMigration, /non-insight actions cannot carry insight analysis, insight, or decision sources/);
+  assert.match(hardeningMigration, /insight actions require analysis, insight, and decision sources/);
+  assert.match(hardeningMigration, /UPDATE OF[\s\S]*action_type,[\s\S]*validation_metric,[\s\S]*evidence_ids/);
+  assert.match(hardeningMigration, /ADD CONSTRAINT action_item_source_decision_kind_check[\s\S]*NOT VALID/);
+});

+ 572 - 0
test/insight-decision.test.ts

@@ -0,0 +1,572 @@
+import assert from 'node:assert/strict';
+import type { AddressInfo } from 'node:net';
+import test from 'node:test';
+import express, { type ErrorRequestHandler } from 'express';
+import { ZodError } from 'zod';
+import { ApiError } from '../src/http/api-error.js';
+import { LocalSyncJobStore } from '../src/modules/domestic-voc/local/local-sync-job.store.js';
+import {
+  createAuthenticationMiddleware,
+  type RequestAuthenticator,
+  WorkspaceAccessService,
+} from '../src/modules/saas-platform/auth.js';
+import type {
+  ActionItemCreateInput,
+  AnalysisRun,
+  InsightDecisionCreateInput,
+} from '../src/modules/saas-platform/domain.js';
+import { assertValidActionDecisionSource } from '../src/modules/saas-platform/domain.js';
+import { LocalPlatformRepository } from '../src/modules/saas-platform/local-platform.repository.js';
+import { createSaasPlatformRouter } from '../src/modules/saas-platform/routes.js';
+import type { DomesticDataset } from '../src/types/domestic-dataset.js';
+
+const dataset: DomesticDataset = {
+  schemaVersion: 1,
+  generatedAt: '2026-08-07T00:00:00.000Z',
+  caseName: 'Insight decision test',
+  platform: 'jd',
+  source: {
+    sourceFile: 'insight-decision.json',
+    sourceHash: 'test',
+    sheets: [],
+    dateRange: { start: '2026-08-01', end: '2026-08-07' },
+  },
+  summary: {
+    metricRows: 0,
+    metricProducts: 0,
+    mappingRows: 0,
+    relations: 0,
+    uniqueCompetitorProducts: 0,
+    category2Count: 0,
+    category3Count: 0,
+    reviewCount: 0,
+  },
+  dailyTotals: [],
+  products: [],
+  mappingGroups: [],
+  relations: [],
+  reviews: [],
+  quality: { orphanMappings: [], mappingsWithoutCompetitor: [], brandWithoutProductId: [] },
+};
+
+function createRepository(): LocalPlatformRepository {
+  let tick = 0;
+  return new LocalPlatformRepository(
+    dataset,
+    new LocalSyncJobStore(dataset),
+    { userId: 'local-admin', email: 'local-admin@localhost', displayName: 'Local Admin' },
+    'demashi',
+    () => new Date(Date.parse('2026-08-07T00:00:00.000Z') + tick++ * 1_000),
+  );
+}
+
+async function createAnalysis(
+  repository: LocalPlatformRepository,
+  input: {
+    id: string;
+    analysisType?: AnalysisRun['analysisType'];
+    status?: 'pending' | 'completed' | 'partial';
+    result?: Record<string, unknown>;
+  },
+): Promise<AnalysisRun> {
+  const analysis = await repository.createAnalysis({
+    id: input.id,
+    workspaceId: 'demashi',
+    analysisType: input.analysisType ?? 'voc_insight',
+    targetKind: 'workspace',
+    targetKey: '',
+    input: {},
+    requestedBy: 'local-admin',
+  });
+  if (!input.status || input.status === 'pending') return analysis;
+  await repository.updateAnalysis('demashi', input.id, { status: 'processing' });
+  return (await repository.updateAnalysis('demashi', input.id, {
+    status: input.status,
+    result: input.result ?? {
+      mode: 'ai',
+      insights: [{ id: 'insight-1', evidenceIds: ['evidence-1', 'evidence-2'] }],
+    },
+    evidenceCount: 2,
+  }))!;
+}
+
+function decisionInput(overrides: Partial<InsightDecisionCreateInput> = {}): InsightDecisionCreateInput {
+  return {
+    id: 'decision-1',
+    workspaceId: 'demashi',
+    sourceAnalysisId: 'analysis-ai',
+    sourceInsightId: 'insight-1',
+    decision: 'confirmed',
+    reviewedEvidenceIds: ['evidence-1', 'evidence-2'],
+    comment: '',
+    decidedBy: 'local-admin',
+    ...overrides,
+  };
+}
+
+function rejectsWith(code: string): (error: unknown) => boolean {
+  return (error) => error instanceof ApiError && error.status === 400 && error.code === code;
+}
+
+function rejectsWithGate(...codes: string[]): (error: unknown) => boolean {
+  return (error) => error instanceof ApiError
+    && (error.status === 400 || error.status === 409)
+    && codes.includes(error.code);
+}
+
+test('local insight decisions validate sources and retain an append-only current version chain', async () => {
+  const repository = createRepository();
+  await createAnalysis(repository, { id: 'analysis-pending' });
+  await assert.rejects(
+    repository.createInsightDecision(decisionInput({ sourceAnalysisId: 'analysis-pending' })),
+    rejectsWith('insight_decision_analysis_not_ready'),
+  );
+
+  await createAnalysis(repository, { id: 'analysis-voice', analysisType: 'voice', status: 'completed' });
+  await assert.rejects(
+    repository.createInsightDecision(decisionInput({ sourceAnalysisId: 'analysis-voice' })),
+    rejectsWith('insight_decision_analysis_not_voc_insight'),
+  );
+
+  await createAnalysis(repository, { id: 'analysis-ai', status: 'completed' });
+  await assert.rejects(
+    repository.createInsightDecision(decisionInput({ sourceInsightId: 'missing-insight' })),
+    rejectsWith('insight_decision_insight_not_found'),
+  );
+  await assert.rejects(
+    repository.createInsightDecision(decisionInput({ reviewedEvidenceIds: [] })),
+    rejectsWith('insight_decision_evidence_required'),
+  );
+  await assert.rejects(
+    repository.createInsightDecision(decisionInput({ reviewedEvidenceIds: ['evidence-outside-insight'] })),
+    rejectsWith('insight_decision_evidence_not_in_insight'),
+  );
+  await assert.rejects(
+    repository.createInsightDecision(decisionInput({ reviewedEvidenceIds: ['evidence-1'] })),
+    rejectsWith('insight_decision_evidence_incomplete'),
+  );
+  await assert.rejects(
+    repository.createInsightDecision(decisionInput({ decision: 'rejected', comment: '   ' })),
+    rejectsWith('insight_decision_comment_required'),
+  );
+
+  const first = await repository.createInsightDecision(decisionInput({
+    reviewedEvidenceIds: ['evidence-1', 'evidence-1', 'evidence-2'],
+    comment: '  confirmed by reviewer  ',
+  }));
+  assert.equal(first.version, 1);
+  assert.equal(first.supersedesId, null);
+  assert.equal(first.isCurrent, true);
+  assert.deepEqual(first.reviewedEvidenceIds, ['evidence-1', 'evidence-2']);
+  assert.equal(first.comment, 'confirmed by reviewer');
+
+  const second = await repository.createInsightDecision(decisionInput({
+    id: 'decision-2',
+    decision: 'needs_more_evidence',
+    reviewedEvidenceIds: ['evidence-1', 'evidence-2'],
+    comment: 'Collect another review cycle.',
+  }));
+  assert.equal(second.version, 2);
+  assert.equal(second.supersedesId, first.id);
+  assert.equal(second.isCurrent, true);
+
+  const storedFirst = await repository.getInsightDecision('demashi', first.id);
+  assert.equal(storedFirst?.isCurrent, false);
+  assert.equal(storedFirst?.decision, 'confirmed');
+  assert.deepEqual(storedFirst?.reviewedEvidenceIds, ['evidence-1', 'evidence-2']);
+  assert.equal(storedFirst?.createdAt, first.createdAt);
+
+  const current = await repository.listInsightDecisions({
+    workspaceId: 'demashi', limit: 10, cursor: null,
+    sourceAnalysisId: 'analysis-ai', sourceInsightId: 'insight-1', currentOnly: true,
+  });
+  assert.deepEqual(current.items.map((item) => item.id), [second.id]);
+  const history = await repository.listInsightDecisions({
+    workspaceId: 'demashi', limit: 10, cursor: null,
+    sourceAnalysisId: 'analysis-ai', sourceInsightId: '', currentOnly: false,
+  });
+  assert.deepEqual(history.items.map((item) => item.version), [2, 1]);
+
+  await createAnalysis(repository, {
+    id: 'analysis-deterministic',
+    status: 'partial',
+    result: {
+      mode: 'deterministic',
+      insights: [{ id: 'insight-rule', evidenceIds: ['evidence-rule'] }],
+    },
+  });
+  await assert.rejects(
+    repository.createInsightDecision(decisionInput({
+      id: 'decision-rule-confirmed',
+      sourceAnalysisId: 'analysis-deterministic',
+      sourceInsightId: 'insight-rule',
+      reviewedEvidenceIds: ['evidence-rule'],
+    })),
+    rejectsWith('insight_decision_deterministic_requires_more_evidence'),
+  );
+  const deterministic = await repository.createInsightDecision(decisionInput({
+    id: 'decision-rule-more-evidence',
+    sourceAnalysisId: 'analysis-deterministic',
+    sourceInsightId: 'insight-rule',
+    decision: 'needs_more_evidence',
+    reviewedEvidenceIds: ['evidence-rule'],
+    comment: 'Rule output needs human evidence.',
+  }));
+  assert.equal(deterministic.decision, 'needs_more_evidence');
+});
+
+test('actions require the current matching decision and use its reviewed evidence', async () => {
+  const repository = createRepository();
+  await createAnalysis(repository, { id: 'analysis-ai', status: 'completed' });
+  const confirmed = await repository.createInsightDecision(decisionInput({
+    reviewedEvidenceIds: ['evidence-1', 'evidence-2'],
+    comment: 'All evidence supports a formal action.',
+  }));
+  const actionInput: ActionItemCreateInput = {
+    id: 'action-1',
+    workspaceId: 'demashi',
+    sourceAnalysisId: 'analysis-ai',
+    sourceInsightId: 'insight-1',
+    sourceDecisionId: confirmed.id,
+    sourceKind: 'insight',
+    creationKey: 'client-value-is-normalized',
+    evidenceIds: ['evidence-1'],
+    validationMetric: 'Issue rate decreases within 30 days.',
+    actionType: 'experience',
+    title: 'Address the confirmed issue',
+    description: '',
+    priority: 'high',
+    status: 'open',
+    productKey: null,
+    assigneeUserId: null,
+    dueAt: null,
+    createdBy: 'local-admin',
+  };
+
+  const created = await repository.createAction(actionInput);
+  assert.equal(created.creationKey, 'demashi|analysis-ai|insight-1|decision-1');
+  const duplicate = await repository.createAction({ ...actionInput, id: 'action-duplicate' });
+  assert.equal(duplicate.id, created.id);
+
+  const rejected = await repository.createInsightDecision(decisionInput({
+    id: 'decision-rejected',
+    decision: 'rejected',
+    reviewedEvidenceIds: ['evidence-1', 'evidence-2'],
+    comment: 'The issue is not supported.',
+  }));
+  assert.throws(
+    () => assertValidActionDecisionSource({
+      workspaceId: 'demashi',
+      sourceAnalysisId: 'analysis-ai',
+      sourceInsightId: 'insight-1',
+      sourceDecisionId: confirmed.id,
+      sourceKind: 'insight',
+      creationKey: created.creationKey,
+      evidenceIds: ['evidence-1'],
+      actionType: 'experience',
+      validationMetric: actionInput.validationMetric,
+    }, { ...confirmed, isCurrent: false }),
+    (error) => error instanceof ApiError && error.code === 'source_decision_superseded',
+  );
+  await assert.rejects(
+    repository.createAction({ ...actionInput, id: 'action-stale-decision-replay' }),
+    (error) => error instanceof ApiError
+      && error.status === 409
+      && error.code === 'source_decision_superseded',
+  );
+  await assert.rejects(
+    repository.createAction({ ...actionInput, id: 'action-rejected', sourceDecisionId: rejected.id }),
+    (error) => error instanceof ApiError && error.code === 'source_decision_rejected',
+  );
+
+  await createAnalysis(repository, {
+    id: 'analysis-rule',
+    status: 'partial',
+    result: {
+      mode: 'deterministic',
+      insights: [{ id: 'insight-rule', evidenceIds: ['evidence-rule'] }],
+    },
+  });
+  const moreEvidence = await repository.createInsightDecision(decisionInput({
+    id: 'decision-more-evidence',
+    sourceAnalysisId: 'analysis-rule',
+    sourceInsightId: 'insight-rule',
+    decision: 'needs_more_evidence',
+    reviewedEvidenceIds: ['evidence-rule'],
+    comment: 'Collect another review cycle.',
+  }));
+  const dataQualityInput: ActionItemCreateInput = {
+    ...actionInput,
+    id: 'action-data-quality',
+    sourceAnalysisId: 'analysis-rule',
+    sourceInsightId: 'insight-rule',
+    sourceDecisionId: moreEvidence.id,
+    evidenceIds: ['evidence-rule'],
+    actionType: 'data_quality',
+  };
+  await assert.rejects(
+    repository.createAction({ ...dataQualityInput, id: 'action-formal', actionType: 'general' }),
+    (error) => error instanceof ApiError && error.code === 'source_decision_requires_data_quality_action',
+  );
+  const dataQualityAction = await repository.createAction(dataQualityInput);
+  assert.equal(dataQualityAction.actionType, 'data_quality');
+});
+
+test('insight-backed actions cannot disguise their source kind to bypass a decision', async () => {
+  for (const sourceKind of ['raw_feedback', 'rule_action'] as const) {
+    const repository = createRepository();
+    await createAnalysis(repository, { id: 'analysis-ai', status: 'completed' });
+
+    await assert.rejects(
+      repository.createAction({
+        id: `action-disguised-${sourceKind}`,
+        workspaceId: 'demashi',
+        sourceAnalysisId: 'analysis-ai',
+        sourceInsightId: 'insight-1',
+        sourceDecisionId: null,
+        sourceKind,
+        creationKey: `disguised-${sourceKind}`,
+        evidenceIds: ['evidence-1', 'evidence-2'],
+        validationMetric: 'Issue rate decreases within 30 days.',
+        actionType: 'experience',
+        title: `Disguised ${sourceKind} action`,
+        description: '',
+        priority: 'high',
+        status: 'open',
+        productKey: null,
+        assigneeUserId: null,
+        dueAt: null,
+        createdBy: 'local-admin',
+      }),
+      rejectsWithGate('insight_source_requires_insight_kind'),
+    );
+  }
+});
+
+test('idempotent action replay rejects changes to its decision-bound payload', async () => {
+  const repository = createRepository();
+  await createAnalysis(repository, {
+    id: 'analysis-rule',
+    status: 'partial',
+    result: {
+      mode: 'deterministic',
+      insights: [{ id: 'insight-rule', evidenceIds: ['evidence-rule-1', 'evidence-rule-2'] }],
+    },
+  });
+  const decision = await repository.createInsightDecision(decisionInput({
+    id: 'decision-rule',
+    sourceAnalysisId: 'analysis-rule',
+    sourceInsightId: 'insight-rule',
+    decision: 'needs_more_evidence',
+    reviewedEvidenceIds: ['evidence-rule-1', 'evidence-rule-2'],
+    comment: 'Collect enough evidence to verify the rule output.',
+  }));
+  const actionInput: ActionItemCreateInput = {
+    id: 'action-rule',
+    workspaceId: 'demashi',
+    sourceAnalysisId: 'analysis-rule',
+    sourceInsightId: 'insight-rule',
+    sourceDecisionId: decision.id,
+    sourceKind: 'insight',
+    creationKey: 'same-client-creation-key',
+    evidenceIds: ['evidence-rule-1', 'evidence-rule-2'],
+    validationMetric: 'Collect 20 additional verified reviews.',
+    actionType: 'data_quality',
+    title: 'Collect additional evidence',
+    description: '',
+    priority: 'high',
+    status: 'open',
+    productKey: null,
+    assigneeUserId: null,
+    dueAt: null,
+    createdBy: 'local-admin',
+  };
+  await repository.createAction(actionInput);
+
+  await assert.rejects(
+    repository.createAction({
+      ...actionInput,
+      id: 'action-rule-evidence-tampered',
+      evidenceIds: ['evidence-rule-1'],
+    }),
+    rejectsWithGate('action_creation_key_conflict'),
+  );
+  await assert.rejects(
+    repository.createAction({
+      ...actionInput,
+      id: 'action-rule-type-tampered',
+      actionType: 'experience',
+    }),
+    rejectsWithGate(
+      'action_creation_key_conflict',
+      'source_decision_requires_data_quality_action',
+    ),
+  );
+  await assert.rejects(
+    repository.createAction({
+      ...actionInput,
+      id: 'action-rule-metric-tampered',
+      validationMetric: 'Collect only 3 additional verified reviews.',
+    }),
+    rejectsWithGate('action_creation_key_conflict'),
+  );
+});
+
+async function listen(app: ReturnType<typeof express>) {
+  const server = await new Promise<ReturnType<typeof app.listen>>((resolve) => {
+    const listening = app.listen(0, '127.0.0.1', () => resolve(listening));
+  });
+  const address = server.address() as AddressInfo;
+  return {
+    baseUrl: `http://127.0.0.1:${address.port}`,
+    close: () => new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve())),
+  };
+}
+
+async function json(response: Response): Promise<Record<string, any>> {
+  return await response.json() as Record<string, any>;
+}
+
+test('insight decision routes create versions, filter current records, return details, audit, and enforce permissions', async () => {
+  const repository = createRepository();
+  await repository.upsertMember({
+    workspaceId: 'demashi',
+    userId: 'viewer-user',
+    email: 'viewer@example.test',
+    displayName: 'Viewer',
+    role: 'viewer',
+    status: 'active',
+  });
+  await createAnalysis(repository, { id: 'analysis-http', status: 'completed' });
+
+  const authenticator: RequestAuthenticator = {
+    async authenticate(request) {
+      const userId = request.header('X-Test-User') || 'local-admin';
+      return { userId, email: `${userId}@example.test`, displayName: userId, authMode: 'disabled' };
+    },
+  };
+  const app = express();
+  app.use(express.json());
+  app.use('/api', createAuthenticationMiddleware(authenticator));
+  app.use('/api/saas', createSaasPlatformRouter({
+    repository,
+    access: new WorkspaceAccessService(repository),
+  }));
+  const errorHandler: ErrorRequestHandler = (error, _request, response, _next) => {
+    if (error instanceof ApiError) {
+      response.status(error.status).json({ error: error.code });
+      return;
+    }
+    if (error instanceof ZodError) {
+      response.status(400).json({ error: 'invalid_request' });
+      return;
+    }
+    response.status(500).json({ error: 'internal_error' });
+  };
+  app.use(errorHandler);
+  const server = await listen(app);
+  const basePath = `${server.baseUrl}/api/saas/workspaces/demashi/insight-decisions`;
+  const ownerHeaders = { 'Content-Type': 'application/json', 'X-Test-User': 'local-admin' };
+  const viewerHeaders = { 'Content-Type': 'application/json', 'X-Test-User': 'viewer-user' };
+
+  try {
+    const firstResponse = await fetch(basePath, {
+      method: 'POST',
+      headers: ownerHeaders,
+      body: JSON.stringify({
+        sourceAnalysisId: 'analysis-http',
+        sourceInsightId: 'insight-1',
+        decision: 'confirmed',
+        reviewedEvidenceIds: ['evidence-1', 'evidence-1', 'evidence-2'],
+      }),
+    });
+    assert.equal(firstResponse.status, 201);
+    const first = (await json(firstResponse)).decision;
+    assert.equal(first.version, 1);
+    assert.equal(first.decidedBy, 'local-admin');
+    assert.deepEqual(first.reviewedEvidenceIds, ['evidence-1', 'evidence-2']);
+
+    const missingCommentResponse = await fetch(basePath, {
+      method: 'POST',
+      headers: ownerHeaders,
+      body: JSON.stringify({
+        sourceAnalysisId: 'analysis-http',
+        sourceInsightId: 'insight-1',
+        decision: 'rejected',
+        reviewedEvidenceIds: ['evidence-1', 'evidence-2'],
+      }),
+    });
+    assert.equal(missingCommentResponse.status, 400);
+    assert.equal((await json(missingCommentResponse)).error, 'insight_decision_comment_required');
+
+    const secondResponse = await fetch(basePath, {
+      method: 'POST',
+      headers: ownerHeaders,
+      body: JSON.stringify({
+        sourceAnalysisId: 'analysis-http',
+        sourceInsightId: 'insight-1',
+        decision: 'rejected',
+        reviewedEvidenceIds: ['evidence-1', 'evidence-2'],
+        comment: 'The evidence does not support the proposed opportunity.',
+      }),
+    });
+    assert.equal(secondResponse.status, 201);
+    const second = (await json(secondResponse)).decision;
+    assert.equal(second.version, 2);
+    assert.equal(second.supersedesId, first.id);
+
+    const currentResponse = await fetch(
+      `${basePath}?analysisId=analysis-http&insightId=insight-1&currentOnly=true`,
+      { headers: viewerHeaders },
+    );
+    assert.equal(currentResponse.status, 200);
+    const current = await json(currentResponse);
+    assert.equal(current.items.length, 1);
+    assert.equal(current.items[0].id, second.id);
+
+    const historyResponse = await fetch(`${basePath}?analysisId=analysis-http`, { headers: ownerHeaders });
+    assert.equal(historyResponse.status, 200);
+    const history = await json(historyResponse);
+    assert.equal(history.items.length, 2);
+
+    const detailResponse = await fetch(`${basePath}/${first.id}`, { headers: viewerHeaders });
+    assert.equal(detailResponse.status, 200);
+    const detail = (await json(detailResponse)).decision;
+    assert.equal(detail.id, first.id);
+    assert.equal(detail.isCurrent, false);
+
+    const viewerWrite = await fetch(basePath, {
+      method: 'POST',
+      headers: viewerHeaders,
+      body: JSON.stringify({
+        sourceAnalysisId: 'analysis-http',
+        sourceInsightId: 'insight-1',
+        decision: 'confirmed',
+        reviewedEvidenceIds: ['evidence-1', 'evidence-2'],
+      }),
+    });
+    assert.equal(viewerWrite.status, 403);
+    assert.equal((await json(viewerWrite)).error, 'workspace_permission_denied');
+
+    const missingDetail = await fetch(`${basePath}/00000000-0000-4000-8000-000000000000`, { headers: ownerHeaders });
+    assert.equal(missingDetail.status, 404);
+    assert.equal((await json(missingDetail)).error, 'insight_decision_not_found');
+
+    const auditResponse = await fetch(
+      `${server.baseUrl}/api/saas/workspaces/demashi/audit?limit=20`,
+      { headers: ownerHeaders },
+    );
+    assert.equal(auditResponse.status, 200);
+    const audit = await json(auditResponse);
+    const decisionEntries = audit.items.filter((item: { action: string }) => item.action === 'decision.created');
+    assert.equal(decisionEntries.length, 2);
+    const secondEntry = decisionEntries.find((item: { entityId: string }) => item.entityId === second.id);
+    assert.equal(secondEntry.entityType, 'insight_decision');
+    assert.equal(secondEntry.metadata.sourceAnalysisId, 'analysis-http');
+    assert.deepEqual(secondEntry.metadata.reviewedEvidenceIds, ['evidence-1', 'evidence-2']);
+    assert.equal(secondEntry.metadata.version, 2);
+    assert.equal(secondEntry.metadata.supersedesId, first.id);
+  } finally {
+    await server.close();
+  }
+});

+ 41 - 0
test/jd-search.adapter.test.ts

@@ -0,0 +1,41 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { adaptJdSearchResponse } from '../src/modules/domestic-voc/adapters/jd-search.adapter.js';
+
+test('JD search adapter maps market signals without treating them as operating-period sales', () => {
+  const products = adaptJdSearchResponse({
+    code: 200,
+    data: {
+      code: 200,
+      data: {
+        products: [{
+          id: '10001',
+          title: '示例品牌 商用设备',
+          imageUrl: 'jfs/example.jpg',
+          price: '1299.00',
+          sales: '多人购买',
+          monthSales: '30日售出多件',
+          shopId: 'shop-1',
+          shopName: '示例店铺',
+          venderId: 'seller-1',
+          onShelvesTime: 1_700_000_000_000,
+        }],
+      },
+    },
+  }, {
+    brand: '示例品牌',
+    category: '商用设备',
+    keyword: '示例品牌 商用设备',
+    collectedAt: '2026-07-29T00:00:00.000Z',
+  });
+
+  assert.equal(products.length, 1);
+  assert.equal(products[0]?.productKey, 'jd:10001');
+  assert.equal(products[0]?.role, 'competitor');
+  assert.equal(products[0]?.source, 'fmode_gateway');
+  assert.equal(products[0]?.summary.averageUnitPrice, 1299);
+  assert.equal(products[0]?.summary.soldUnits, 0);
+  assert.equal(products[0]?.market?.monthSalesText, '30日售出多件');
+  assert.equal(products[0]?.market?.shopName, '示例店铺');
+  assert.match(products[0]?.detail?.imageUrl ?? '', /^https:\/\/img30\.360buyimg\.com\/sku\//);
+});

+ 24 - 0
test/local-app.test.ts

@@ -100,6 +100,30 @@ test('local demo serves a snapshot and queryable completed sync jobs without a d
     assert.equal(snapshot.status, 200);
     assert.equal((await snapshot.json() as DomesticDataset).products[0]?.productId, '11266507445');
 
+    const knowledge = await fetch(`${baseUrl}/api/knowledge/products?workspaceId=demashi&status=active`);
+    assert.equal(knowledge.status, 200);
+    assert.equal((await knowledge.json() as { items: Array<{ productId: string }> }).items[0]?.productId, '11266507445');
+
+    const savedKnowledge = await fetch(`${baseUrl}/api/knowledge/products`, {
+      method: 'PUT',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({
+        workspaceId: 'demashi',
+        productKey: 'jd:11266507445',
+        productId: '11266507445',
+        productRole: 'own',
+        featured: true,
+        status: 'active',
+        tags: ['经营TOP', '演示重点'],
+        note: '本地后端知识库写入验证',
+        ownerUserId: 'local-admin',
+      }),
+    });
+    assert.equal(savedKnowledge.status, 200);
+    const savedItem = (await savedKnowledge.json() as { item: { tags: string[]; note: string } }).item;
+    assert.deepEqual(savedItem.tags, ['经营TOP', '演示重点']);
+    assert.equal(savedItem.note, '本地后端知识库写入验证');
+
     const syncResponse = await fetch(`${baseUrl}/api/domestic-voc/sync`, {
       method: 'POST',
       headers: { 'Content-Type': 'application/json', 'Idempotency-Key': 'local-demo-sync-11266507445' },

+ 95 - 0
test/parse-rest-client.test.ts

@@ -30,6 +30,8 @@ test('Parse REST client keeps the master key server-side and encodes structured
   assert.equal(queryUrl.searchParams.get('order'), 'productKey');
   assert.equal(queryUrl.searchParams.get('limit'), '25');
   assert.equal((calls[0]!.init.headers as Record<string, string>)['X-Parse-Master-Key'], 'master-secret');
+  assert.equal((calls[0]!.init.headers as Record<string, string>)['Cache-Control'], 'no-cache, no-store, max-age=0');
+  assert.equal((calls[0]!.init.headers as Record<string, string>).Pragma, 'no-cache');
   assert.equal('X-Parse-Master-Key' in (calls[1]!.init.headers as Record<string, string>), false);
 });
 
@@ -75,3 +77,96 @@ test('Parse REST errors expose status and Parse code without returning raw respo
       && error.message === 'Permission denied',
   );
 });
+
+test('Parse REST schema writes retry without required flags for older managed servers', async () => {
+  const bodies: Array<{
+    fields: Record<string, { type: string; required?: boolean }>;
+    indexes?: Record<string, Record<string, number>>;
+  }> = [];
+  const client = new ParseRestClient({
+    serverUrl: 'https://parse.example.test/parse',
+    appId: 'app-id',
+    masterKey: 'master-secret',
+  }, async (_input, init = {}) => {
+    bodies.push(JSON.parse(String(init.body)) as {
+      fields: Record<string, { type: string; required?: boolean }>;
+      indexes?: Record<string, Record<string, number>>;
+    });
+    if (bodies.length === 1) {
+      return new Response(JSON.stringify({ error: 'unauthorized' }), {
+        status: 403,
+        headers: { 'Content-Type': 'application/json' },
+      });
+    }
+    return new Response(JSON.stringify({ className: 'VocWorkspace' }), {
+      status: 200,
+      headers: { 'Content-Type': 'application/json' },
+    });
+  });
+
+  await client.createSchema({
+    className: 'VocWorkspace',
+    fields: {
+      publicId: { type: 'String', required: true },
+      name: { type: 'String', required: false },
+    },
+    indexes: { voc_workspace_publicid_idx: { publicId: 1 } },
+  });
+
+  assert.equal(bodies.length, 2);
+  assert.equal(bodies[0]!.fields.publicId!.required, true);
+  assert.equal('required' in bodies[1]!.fields.publicId!, false);
+  assert.equal('required' in bodies[1]!.fields.name!, false);
+  assert.deepEqual(bodies[1]!.indexes, { voc_workspace_publicid_idx: { publicId: 1 } });
+});
+
+test('Parse REST schema index updates use the managed schema endpoint', async () => {
+  let request: { url: string; method: string | undefined; body: string | undefined } | null = null;
+  const client = new ParseRestClient({
+    serverUrl: 'https://parse.example.test/parse',
+    appId: 'app-id',
+    masterKey: 'master-secret',
+  }, async (input, init = {}) => {
+    request = { url: String(input), method: init.method, body: String(init.body) };
+    return new Response(JSON.stringify({ className: 'VocProduct' }), {
+      status: 200,
+      headers: { 'Content-Type': 'application/json' },
+    });
+  });
+
+  await client.addSchemaIndexes('VocProduct', {
+    voc_product_naturalkey_idx: { naturalKey: 1 },
+  });
+
+  assert.equal(request!.url, 'https://parse.example.test/parse/schemas/VocProduct');
+  assert.equal(request!.method, 'PUT');
+  assert.deepEqual(JSON.parse(request!.body!), {
+    indexes: { voc_product_naturalkey_idx: { naturalKey: 1 } },
+  });
+});
+
+test('Parse REST class requests retry transient master authorization drift', async () => {
+  let attempts = 0;
+  const client = new ParseRestClient({
+    serverUrl: 'https://parse.example.test/parse',
+    appId: 'app-id',
+    masterKey: 'master-secret',
+  }, async () => {
+    attempts += 1;
+    if (attempts < 3) {
+      return new Response(JSON.stringify({ error: 'unauthorized' }), {
+        status: 403,
+        headers: { 'Content-Type': 'application/json' },
+      });
+    }
+    return new Response(JSON.stringify({ results: [], count: 0 }), {
+      status: 200,
+      headers: { 'Content-Type': 'application/json' },
+    });
+  });
+
+  const result = await client.find('VocWorkspace', { count: true });
+
+  assert.equal(attempts, 3);
+  assert.equal(result.count, 0);
+});

+ 50 - 0
test/parse-rest-dataset-import.test.ts

@@ -0,0 +1,50 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import type { ParseRestClient } from '../src/db/parse-rest.client.js';
+import { ParseRestDatasetImportService } from '../src/modules/domestic-voc/services/parse-rest-dataset-import.service.js';
+
+test('Parse REST dataset batches update existing natural keys and create only missing rows', async () => {
+  const batches: Array<Array<{ method: string; path: string; body?: unknown }>> = [];
+  const client = {
+    findAll: async () => [{
+      objectId: 'existing-object',
+      naturalKey: 'existing-key',
+      createdAt: '2026-01-01T00:00:00.000Z',
+      updatedAt: '2026-01-01T00:00:00.000Z',
+    }],
+    batch: async (requests: Array<{ method: string; path: string; body?: unknown }>) => {
+      batches.push(requests);
+    },
+  } as unknown as ParseRestClient;
+  const service = new ParseRestDatasetImportService(client);
+  const upsertMany = (service as unknown as {
+    upsertMany(className: string, objects: Array<Record<string, unknown>>): Promise<void>;
+  }).upsertMany.bind(service);
+
+  await upsertMany('VocProduct', [
+    { naturalKey: 'existing-key', title: 'updated' },
+    { naturalKey: 'new-key', title: 'created' },
+  ]);
+
+  assert.equal(batches.length, 1);
+  assert.deepEqual(batches[0]!.map((request) => ({ method: request.method, path: request.path })), [
+    { method: 'PUT', path: '/classes/VocProduct/existing-object' },
+    { method: 'POST', path: '/classes/VocProduct' },
+  ]);
+});
+
+test('Parse REST dataset batches reject rows without a natural key', async () => {
+  const client = {
+    findAll: async () => [],
+    batch: async () => undefined,
+  } as unknown as ParseRestClient;
+  const service = new ParseRestDatasetImportService(client);
+  const upsertMany = (service as unknown as {
+    upsertMany(className: string, objects: Array<Record<string, unknown>>): Promise<void>;
+  }).upsertMany.bind(service);
+
+  await assert.rejects(
+    () => upsertMany('VocProduct', [{ title: 'missing identity' }]),
+    /missing naturalKey/,
+  );
+});

+ 90 - 0
test/product-knowledge.store.test.ts

@@ -0,0 +1,90 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import type { ParseObject, ParseQueryResult } from '../src/db/parse-rest.client.js';
+import { VOC_PARSE_CLASSES } from '../src/db/parse-rest.schema.js';
+import { ParseRestProductKnowledgeStore, type ProductKnowledgeRecord } from '../src/modules/product-knowledge/product-knowledge.store.js';
+
+type StoredKnowledge = Omit<ProductKnowledgeRecord, 'id'> & { naturalKey: string } & ParseObject;
+
+class FakeKnowledgeClient {
+  readonly records: StoredKnowledge[] = [];
+  private nextId = 1;
+
+  async find<T>(_className: string, options: { where?: Record<string, unknown>; limit?: number }): Promise<ParseQueryResult<T>> {
+    return {
+      results: this.records.filter((record) => matches(record, options.where ?? {}))
+        .sort((left, right) => left.objectId.localeCompare(right.objectId))
+        .slice(0, options.limit) as unknown as Array<T & ParseObject>,
+    };
+  }
+
+  async findOne<T>(_className: string, where: Record<string, unknown>): Promise<(T & ParseObject) | null> {
+    return (this.records.find((record) => matches(record, where)) ?? null) as (T & ParseObject) | null;
+  }
+
+  async create<T extends Record<string, unknown>>(className: string, object: T): Promise<ParseObject> {
+    assert.equal(className, VOC_PARSE_CLASSES.productKnowledge);
+    const now = '2026-07-27T00:00:00.000Z';
+    const stored = { ...object, objectId: `knowledge-${this.nextId++}`, createdAt: now, updatedAt: now } as unknown as StoredKnowledge;
+    this.records.push(stored);
+    return stored;
+  }
+
+  async update<T extends Record<string, unknown>>(_className: string, objectId: string, patch: T): Promise<{ updatedAt: string }> {
+    const record = this.records.find((item) => item.objectId === objectId);
+    assert.ok(record);
+    const updatedAt = '2026-07-27T01:00:00.000Z';
+    Object.assign(record, patch, { updatedAt });
+    return { updatedAt };
+  }
+}
+
+function matches(record: StoredKnowledge, where: Record<string, unknown>): boolean {
+  return Object.entries(where).every(([key, value]) => {
+    if (key === 'objectId' && value && typeof value === 'object' && '$gt' in value) {
+      return record.objectId > String((value as { $gt: unknown }).$gt);
+    }
+    return record[key as keyof StoredKnowledge] === value;
+  });
+}
+
+test('product knowledge stays workspace scoped and updates without duplicates', async () => {
+  const client = new FakeKnowledgeClient();
+  const store = new ParseRestProductKnowledgeStore(client);
+  const common = {
+    productKey: 'jd:1001', productId: '1001', productRole: 'own' as const,
+    featured: true, status: 'active' as const, tags: ['经营TOP', '经营TOP', ' 蒸烤箱 '],
+    note: '重点跟踪', ownerUserId: 'operator', actorUserId: 'admin',
+  };
+  const created = await store.upsert({ workspaceId: 'demashi', ...common });
+  await store.upsert({ workspaceId: 'other', ...common });
+  const updated = await store.upsert({ workspaceId: 'demashi', ...common, note: '已复核', featured: false });
+
+  assert.equal(client.records.length, 2);
+  assert.equal(created.createdBy, 'admin');
+  assert.deepEqual(created.tags, ['经营TOP', '蒸烤箱']);
+  assert.equal(updated.note, '已复核');
+  assert.equal(updated.featured, false);
+  assert.equal((await store.list({ workspaceId: 'demashi', limit: 10, cursor: null })).items.length, 1);
+  assert.equal((await store.list({ workspaceId: 'other', limit: 10, cursor: null })).items.length, 1);
+});
+
+test('product knowledge supports cursor paging and soft archive', async () => {
+  const client = new FakeKnowledgeClient();
+  const store = new ParseRestProductKnowledgeStore(client);
+  for (const productId of ['1001', '1002', '1003']) {
+    await store.upsert({
+      workspaceId: 'demashi', productKey: `jd:${productId}`, productId, productRole: 'own',
+      featured: true, status: 'active', tags: [], note: '', ownerUserId: '', actorUserId: 'admin',
+    });
+  }
+  const first = await store.list({ workspaceId: 'demashi', limit: 2, cursor: null, featured: true });
+  const second = await store.list({ workspaceId: 'demashi', limit: 2, cursor: first.nextCursor, featured: true });
+  const archived = await store.archive('demashi', 'jd:1002', 'admin');
+
+  assert.equal(first.items.length, 2);
+  assert.ok(first.nextCursor);
+  assert.deepEqual(second.items.map((item) => item.productId), ['1003']);
+  assert.equal(archived?.status, 'archived');
+  assert.equal(archived?.featured, false);
+});

+ 317 - 2
test/saas-platform.test.ts

@@ -179,10 +179,50 @@ test('local SaaS APIs cover context, cursor catalogs, workflows, and audit histo
     const analysisResponse = await fetch(`${server.baseUrl}/api/saas/workspaces/demashi/analyses`, {
       method: 'POST',
       headers: { 'Content-Type': 'application/json' },
-      body: JSON.stringify({ analysisType: 'voice', targetKind: 'workspace', input: { platform: 'jd' } }),
+      body: JSON.stringify({ analysisType: 'voc_insight', targetKind: 'workspace', input: { platform: 'jd' } }),
     });
     assert.equal(analysisResponse.status, 202);
-    assert.equal((await json(analysisResponse)).analysis.status, 'pending');
+    const createdAnalysis = (await json(analysisResponse)).analysis as { id: string; status: string };
+    assert.equal(createdAnalysis.status, 'pending');
+
+    const processingResponse = await fetch(`${server.baseUrl}/api/saas/workspaces/demashi/analyses/${createdAnalysis.id}`, {
+      method: 'PATCH',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ status: 'processing', startedAt: '2026-07-23T10:00:00.000Z', evidenceCount: 3 }),
+    });
+    assert.equal(processingResponse.status, 200);
+    const processingAnalysis = (await json(processingResponse)).analysis as {
+      status: string; startedAt: string | null; evidenceCount: number;
+    };
+    assert.equal(processingAnalysis.status, 'processing');
+    assert.equal(processingAnalysis.startedAt, '2026-07-23T10:00:00.000Z');
+    assert.equal(processingAnalysis.evidenceCount, 3);
+
+    const completedResponse = await fetch(`${server.baseUrl}/api/saas/workspaces/demashi/analyses/${createdAnalysis.id}`, {
+      method: 'PATCH',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({
+        status: 'completed',
+        result: { findings: ['Need clearer VOC evidence links'] },
+        evidenceCount: 5,
+        completedAt: '2026-07-23T10:05:00.000Z',
+      }),
+    });
+    assert.equal(completedResponse.status, 200);
+    const completedAnalysis = (await json(completedResponse)).analysis as {
+      status: string; result: Record<string, unknown> | null; completedAt: string | null;
+    };
+    assert.equal(completedAnalysis.status, 'completed');
+    assert.deepEqual(completedAnalysis.result, { findings: ['Need clearer VOC evidence links'] });
+    assert.equal(completedAnalysis.completedAt, '2026-07-23T10:05:00.000Z');
+
+    const terminalAnalysisResponse = await fetch(`${server.baseUrl}/api/saas/workspaces/demashi/analyses/${createdAnalysis.id}`, {
+      method: 'PATCH',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ status: 'cancelled' }),
+    });
+    assert.equal(terminalAnalysisResponse.status, 409);
+    assert.equal((await json(terminalAnalysisResponse)).error, 'analysis_terminal');
     const analyses = await json(await fetch(`${server.baseUrl}/api/saas/workspaces/demashi/analyses`));
     assert.equal(analyses.items.length, 1);
 
@@ -231,6 +271,7 @@ test('local SaaS APIs cover context, cursor catalogs, workflows, and audit histo
     const auditActions = (await json(auditResponse)).items.map((item: { action: string }) => item.action);
     assert.ok(auditActions.includes('member.upserted'));
     assert.ok(auditActions.includes('analysis.created'));
+    assert.ok(auditActions.includes('analysis.updated'));
     assert.ok(auditActions.includes('action.updated'));
     assert.ok(auditActions.includes('alert.updated'));
     assert.ok(auditActions.includes('sync.requested'));
@@ -239,6 +280,264 @@ test('local SaaS APIs cover context, cursor catalogs, workflows, and audit histo
   }
 });
 
+test('action items retain validated VOC insight provenance and deduplicated evidence', async () => {
+  const app = createLocalDemoApp({ dataset, corsOrigins: ['http://localhost:4200'] });
+  const server = await listen(app);
+  const headers = { 'Content-Type': 'application/json' };
+
+  async function createAnalysis(analysisType: 'voice' | 'voc_insight') {
+    const response = await fetch(`${server.baseUrl}/api/saas/workspaces/demashi/analyses`, {
+      method: 'POST',
+      headers,
+      body: JSON.stringify({ analysisType, targetKind: 'workspace', input: { source: 'action-test' } }),
+    });
+    assert.equal(response.status, 202);
+    return (await json(response)).analysis as { id: string };
+  }
+
+  async function finishAnalysis(id: string, status: 'completed' | 'partial') {
+    const processing = await fetch(`${server.baseUrl}/api/saas/workspaces/demashi/analyses/${id}`, {
+      method: 'PATCH', headers, body: JSON.stringify({ status: 'processing' }),
+    });
+    assert.equal(processing.status, 200);
+    const terminal = await fetch(`${server.baseUrl}/api/saas/workspaces/demashi/analyses/${id}`, {
+      method: 'PATCH',
+      headers,
+      body: JSON.stringify({
+        status,
+        result: {
+          insights: [
+            { id: 'insight-1', evidenceIds: ['review-1', 'review-2'] },
+            { id: 'insight-2', evidenceIds: ['review-3'] },
+          ],
+        },
+        evidenceCount: 3,
+      }),
+    });
+    assert.equal(terminal.status, 200);
+  }
+
+  async function createDecision(
+    analysisId: string,
+    insightId: string,
+    reviewedEvidenceIds: string[],
+  ): Promise<{ id: string }> {
+    const response = await fetch(`${server.baseUrl}/api/saas/workspaces/demashi/insight-decisions`, {
+      method: 'POST',
+      headers,
+      body: JSON.stringify({
+        sourceAnalysisId: analysisId,
+        sourceInsightId: insightId,
+        decision: 'confirmed',
+        reviewedEvidenceIds,
+        comment: 'Evidence reviewed for action creation.',
+      }),
+    });
+    assert.equal(response.status, 201);
+    return (await json(response)).decision as { id: string };
+  }
+
+  try {
+    const ordinaryResponse = await fetch(`${server.baseUrl}/api/saas/workspaces/demashi/actions`, {
+      method: 'POST', headers, body: JSON.stringify({ title: 'Ordinary action' }),
+    });
+    assert.equal(ordinaryResponse.status, 201);
+    const ordinary = (await json(ordinaryResponse)).action;
+    assert.equal(ordinary.sourceAnalysisId, null);
+    assert.equal(ordinary.sourceInsightId, null);
+    assert.deepEqual(ordinary.evidenceIds, []);
+    assert.equal(ordinary.validationMetric, '');
+
+    const completedInsight = await createAnalysis('voc_insight');
+    const pendingSource = await fetch(`${server.baseUrl}/api/saas/workspaces/demashi/actions`, {
+      method: 'POST',
+      headers,
+      body: JSON.stringify({ title: 'Pending source', sourceAnalysisId: completedInsight.id }),
+    });
+    assert.equal(pendingSource.status, 400);
+    assert.equal((await json(pendingSource)).error, 'source_analysis_not_ready');
+
+    await finishAnalysis(completedInsight.id, 'completed');
+
+    const missingInsight = await fetch(`${server.baseUrl}/api/saas/workspaces/demashi/actions`, {
+      method: 'POST',
+      headers,
+      body: JSON.stringify({ title: 'Missing insight', sourceAnalysisId: completedInsight.id }),
+    });
+    assert.equal(missingInsight.status, 400);
+    assert.equal((await json(missingInsight)).error, 'source_insight_required');
+
+    const unknownInsight = await fetch(`${server.baseUrl}/api/saas/workspaces/demashi/actions`, {
+      method: 'POST',
+      headers,
+      body: JSON.stringify({
+        title: 'Unknown insight',
+        sourceAnalysisId: completedInsight.id,
+        sourceInsightId: 'insight-unknown',
+        evidenceIds: ['review-1'],
+      }),
+    });
+    assert.equal(unknownInsight.status, 400);
+    assert.equal((await json(unknownInsight)).error, 'source_insight_not_found');
+
+    const missingEvidence = await fetch(`${server.baseUrl}/api/saas/workspaces/demashi/actions`, {
+      method: 'POST',
+      headers,
+      body: JSON.stringify({
+        title: 'Missing evidence',
+        sourceAnalysisId: completedInsight.id,
+        sourceInsightId: 'insight-1',
+      }),
+    });
+    assert.equal(missingEvidence.status, 400);
+    assert.equal((await json(missingEvidence)).error, 'source_evidence_required');
+
+    const unknownEvidence = await fetch(`${server.baseUrl}/api/saas/workspaces/demashi/actions`, {
+      method: 'POST',
+      headers,
+      body: JSON.stringify({
+        title: 'Unknown evidence',
+        sourceAnalysisId: completedInsight.id,
+        sourceInsightId: 'insight-1',
+        evidenceIds: ['review-unknown'],
+      }),
+    });
+    assert.equal(unknownEvidence.status, 400);
+    assert.equal((await json(unknownEvidence)).error, 'source_evidence_not_in_insight');
+
+    const crossInsightEvidence = await fetch(`${server.baseUrl}/api/saas/workspaces/demashi/actions`, {
+      method: 'POST',
+      headers,
+      body: JSON.stringify({
+        title: 'Cross insight evidence',
+        sourceAnalysisId: completedInsight.id,
+        sourceInsightId: 'insight-1',
+        evidenceIds: ['review-3'],
+      }),
+    });
+    assert.equal(crossInsightEvidence.status, 400);
+    assert.equal((await json(crossInsightEvidence)).error, 'source_evidence_not_in_insight');
+
+    const orphanInsight = await fetch(`${server.baseUrl}/api/saas/workspaces/demashi/actions`, {
+      method: 'POST',
+      headers,
+      body: JSON.stringify({ title: 'Orphan insight', sourceInsightId: 'insight-1' }),
+    });
+    assert.equal(orphanInsight.status, 400);
+    assert.equal((await json(orphanInsight)).error, 'source_insight_orphan');
+
+    const unconfirmedAction = await fetch(`${server.baseUrl}/api/saas/workspaces/demashi/actions`, {
+      method: 'POST',
+      headers,
+      body: JSON.stringify({
+        title: 'Improve evidence traceability',
+        sourceAnalysisId: completedInsight.id,
+        sourceInsightId: 'insight-1',
+        evidenceIds: ['review-1', 'review-1', 'review-2'],
+        validationMetric: 'Evidence coverage >= 80%',
+      }),
+    });
+    assert.equal(unconfirmedAction.status, 400);
+    assert.equal((await json(unconfirmedAction)).error, 'source_decision_required');
+
+    const completedDecision = await createDecision(
+      completedInsight.id,
+      'insight-1',
+      ['review-1', 'review-2'],
+    );
+    const linkedPayload = {
+      title: 'Improve evidence traceability',
+      sourceAnalysisId: completedInsight.id,
+      sourceInsightId: 'insight-1',
+      sourceDecisionId: completedDecision.id,
+      sourceKind: 'insight',
+      evidenceIds: ['review-1', 'review-1', 'review-2'],
+      validationMetric: 'Evidence coverage >= 80%',
+    };
+    const linkedResponse = await fetch(`${server.baseUrl}/api/saas/workspaces/demashi/actions`, {
+      method: 'POST', headers, body: JSON.stringify(linkedPayload),
+    });
+    assert.equal(linkedResponse.status, 201);
+    const linked = (await json(linkedResponse)).action;
+    assert.equal(linked.sourceAnalysisId, completedInsight.id);
+    assert.equal(linked.sourceInsightId, 'insight-1');
+    assert.equal(linked.sourceDecisionId, completedDecision.id);
+    assert.equal(linked.sourceKind, 'insight');
+    assert.deepEqual(linked.evidenceIds, ['review-1', 'review-2']);
+    assert.equal(linked.validationMetric, 'Evidence coverage >= 80%');
+
+    const duplicateResponse = await fetch(`${server.baseUrl}/api/saas/workspaces/demashi/actions`, {
+      method: 'POST', headers, body: JSON.stringify(linkedPayload),
+    });
+    assert.equal(duplicateResponse.status, 200);
+    const duplicateBody = await json(duplicateResponse);
+    assert.equal(duplicateBody.idempotent, true);
+    assert.equal(duplicateBody.action.id, linked.id);
+
+    const partialInsight = await createAnalysis('voc_insight');
+    await finishAnalysis(partialInsight.id, 'partial');
+    const partialDecision = await createDecision(partialInsight.id, 'insight-2', ['review-3']);
+    const partialSource = await fetch(`${server.baseUrl}/api/saas/workspaces/demashi/actions`, {
+      method: 'POST',
+      headers,
+      body: JSON.stringify({
+        title: 'Validate partial insight',
+        sourceAnalysisId: partialInsight.id,
+        sourceInsightId: 'insight-2',
+        sourceDecisionId: partialDecision.id,
+        sourceKind: 'insight',
+        evidenceIds: ['review-3'],
+      }),
+    });
+    assert.equal(partialSource.status, 201);
+
+    const voiceAnalysis = await createAnalysis('voice');
+    await finishAnalysis(voiceAnalysis.id, 'completed');
+    const wrongType = await fetch(`${server.baseUrl}/api/saas/workspaces/demashi/actions`, {
+      method: 'POST',
+      headers,
+      body: JSON.stringify({ title: 'Wrong source type', sourceAnalysisId: voiceAnalysis.id }),
+    });
+    assert.equal(wrongType.status, 400);
+    assert.equal((await json(wrongType)).error, 'source_analysis_not_voc_insight');
+
+    const missingSource = await fetch(`${server.baseUrl}/api/saas/workspaces/demashi/actions`, {
+      method: 'POST',
+      headers,
+      body: JSON.stringify({ title: 'Missing source', sourceAnalysisId: 'other-workspace-analysis' }),
+    });
+    assert.equal(missingSource.status, 400);
+    assert.equal((await json(missingSource)).error, 'source_analysis_not_found');
+
+    const tooManyEvidence = await fetch(`${server.baseUrl}/api/saas/workspaces/demashi/actions`, {
+      method: 'POST',
+      headers,
+      body: JSON.stringify({ title: 'Too much evidence', evidenceIds: Array.from({ length: 101 }, (_, index) => `review-${index}`) }),
+    });
+    assert.equal(tooManyEvidence.status, 400);
+    assert.equal((await json(tooManyEvidence)).error, 'invalid_request');
+
+    const actions = await json(await fetch(`${server.baseUrl}/api/saas/workspaces/demashi/actions?limit=100`));
+    const persisted = actions.items.find((item: { id: string }) => item.id === linked.id);
+    assert.equal(persisted.sourceAnalysisId, completedInsight.id);
+    assert.deepEqual(persisted.evidenceIds, ['review-1', 'review-2']);
+
+    const audit = await json(await fetch(`${server.baseUrl}/api/saas/workspaces/demashi/audit?limit=100`));
+    const entry = audit.items.find((item: { action: string; entityId: string }) => (
+      item.action === 'action.created' && item.entityId === linked.id
+    ));
+    assert.equal(entry.metadata.sourceAnalysisId, completedInsight.id);
+    assert.equal(entry.metadata.sourceInsightId, 'insight-1');
+    assert.equal(entry.metadata.sourceDecisionId, completedDecision.id);
+    assert.equal(entry.metadata.sourceKind, 'insight');
+    assert.deepEqual(entry.metadata.evidenceIds, ['review-1', 'review-2']);
+    assert.equal(entry.metadata.evidenceCount, 2);
+    assert.equal(entry.metadata.validationMetric, 'Evidence coverage >= 80%');
+  } finally {
+    await server.close();
+  }
+});
+
 test('viewer membership can read but cannot call write or member-management routes', async () => {
   const jobs = new LocalSyncJobStore(dataset);
   const repository = new LocalPlatformRepository(dataset, jobs, {
@@ -324,6 +623,22 @@ test('viewer membership can read but cannot call write or member-management rout
     assert.equal(invalidAssignee.status, 400);
     assert.equal((await json(invalidAssignee)).error, 'assignee_not_workspace_member');
 
+    const ownerAnalysis = await fetch(`${server.baseUrl}/api/saas/workspaces/demashi/analyses`, {
+      method: 'POST',
+      headers: ownerHeaders,
+      body: JSON.stringify({ analysisType: 'voc_insight', targetKind: 'workspace', input: { source: 'viewer-test' } }),
+    });
+    assert.equal(ownerAnalysis.status, 202);
+    const ownerAnalysisId = (await json(ownerAnalysis)).analysis.id as string;
+
+    const viewerPatch = await fetch(`${server.baseUrl}/api/saas/workspaces/demashi/analyses/${ownerAnalysisId}`, {
+      method: 'PATCH',
+      headers: viewerHeaders,
+      body: JSON.stringify({ status: 'processing' }),
+    });
+    assert.equal(viewerPatch.status, 403);
+    assert.equal((await json(viewerPatch)).error, 'workspace_permission_denied');
+
     const adminMember = await fetch(`${server.baseUrl}/api/saas/workspaces/demashi/members/admin-user`, {
       method: 'PUT',
       headers: ownerHeaders,