Browse Source

feat(listing): implement V7 observable scoring

Yi Jiarui 3 weeks ago
parent
commit
841402346b
35 changed files with 1877 additions and 556 deletions
  1. 35 0
      migrations/009_listing_ai_rescore_history.sql
  2. 15 0
      migrations/010_listing_scoring_v4.sql
  3. 12 0
      migrations/011_listing_scoring_v5_status_reasons.sql
  4. 54 0
      migrations/012_listing_remove_score_history.sql
  5. 6 0
      package.json
  6. 32 0
      scripts/enrich-listing-context.ts
  7. 54 0
      scripts/migrate-listing-current-scores.ts
  8. 155 0
      scripts/publish-listing-simulated-scores.ts
  9. 23 0
      scripts/reset-listing-formal-ai-scores.ts
  10. 40 0
      scripts/score-listings-ai.ts
  11. 10 2
      scripts/score-listings.ts
  12. 53 0
      scripts/set-listing-catalog-cohort.ts
  13. 9 5
      scripts/sync-jd-listings.ts
  14. 98 14
      scripts/verify-listing-rollout.ts
  15. 1 1
      src/app.ts
  16. 11 0
      src/db/parse-rest.client.ts
  17. 10 6
      src/db/parse-rest.schema.ts
  18. 117 5
      src/modules/listing-ai/domain.ts
  19. 67 55
      src/modules/listing-ai/listing-ai.service.ts
  20. 112 16
      src/modules/listing-ai/normalization/jd-listing.normalizer.ts
  21. 63 0
      src/modules/listing-ai/normalization/listing-context.enricher.ts
  22. 82 0
      src/modules/listing-ai/presentation/listing-score.presenter.ts
  23. 44 26
      src/modules/listing-ai/repositories/in-memory-listing-ai.repository.ts
  24. 55 12
      src/modules/listing-ai/repositories/parse-rest-listing-ai.repository.ts
  25. 51 39
      src/modules/listing-ai/repositories/postgres-listing-ai.repository.ts
  26. 31 17
      src/modules/listing-ai/routes.ts
  27. 18 1
      src/modules/listing-ai/schemas.ts
  28. 92 193
      src/modules/listing-ai/scoring/ai-rubric.ts
  29. 90 0
      src/modules/listing-ai/scoring/jd-category-rules.ts
  30. 137 134
      src/modules/listing-ai/scoring/rule-engine.ts
  31. 40 0
      src/modules/listing-ai/scoring/score-status.ts
  32. 17 6
      src/server.ts
  33. 70 8
      test/listing-ai.ai-rubric.test.ts
  34. 41 10
      test/listing-ai.routes.test.ts
  35. 132 6
      test/listing-ai.rule-engine.test.ts

+ 35 - 0
migrations/009_listing_ai_rescore_history.sql

@@ -0,0 +1,35 @@
+ALTER TABLE voc.listing_score_job
+  ADD COLUMN IF NOT EXISTS rescore_policy text NOT NULL DEFAULT 'reuse'
+  CHECK (rescore_policy IN ('reuse', 'force'));
+
+ALTER TABLE voc.listing_score_result
+  ADD COLUMN IF NOT EXISTS model_key text NOT NULL DEFAULT 'rules',
+  ADD COLUMN IF NOT EXISTS prompt_version text NOT NULL DEFAULT 'rules',
+  ADD COLUMN IF NOT EXISTS execution_key text,
+  ADD COLUMN IF NOT EXISTS input_fingerprint text;
+
+DO $$
+DECLARE constraint_name text;
+BEGIN
+  SELECT conname INTO constraint_name
+  FROM pg_constraint
+  WHERE conrelid = 'voc.listing_score_result'::regclass
+    AND contype = 'u'
+    AND pg_get_constraintdef(oid) = 'UNIQUE (workspace_id, product_id, source_hash, rubric_version)';
+  IF constraint_name IS NOT NULL THEN
+    EXECUTE format('ALTER TABLE voc.listing_score_result DROP CONSTRAINT %I', constraint_name);
+  END IF;
+END $$;
+
+ALTER TABLE voc.listing_score_result
+  DROP CONSTRAINT IF EXISTS listing_score_result_identity_uq;
+
+ALTER TABLE voc.listing_score_result
+  DROP CONSTRAINT IF EXISTS listing_score_result_workspace_id_product_id_source_hash_rubric_version_key;
+
+CREATE UNIQUE INDEX IF NOT EXISTS listing_score_execution_key_uidx
+  ON voc.listing_score_result (workspace_id, execution_key)
+  WHERE execution_key IS NOT NULL;
+
+CREATE INDEX IF NOT EXISTS listing_score_input_fingerprint_idx
+  ON voc.listing_score_result (workspace_id, product_id, input_fingerprint, created_at DESC);

+ 15 - 0
migrations/010_listing_scoring_v4.sql

@@ -0,0 +1,15 @@
+ALTER TABLE voc.listing_score_result
+  ADD COLUMN IF NOT EXISTS known_overall_score numeric(6,2),
+  ADD COLUMN IF NOT EXISTS known_overall_max_score numeric(6,2),
+  ADD COLUMN IF NOT EXISTS score_kind text NOT NULL DEFAULT 'rules'
+    CHECK (score_kind IN ('rules', 'hybrid_ai')),
+  ADD COLUMN IF NOT EXISTS ai_status text NOT NULL DEFAULT 'not_requested'
+    CHECK (ai_status IN ('not_requested', 'pending', 'completed', 'failed', 'budget_exceeded')),
+  ADD COLUMN IF NOT EXISTS compliance_status text NOT NULL DEFAULT 'normal'
+    CHECK (compliance_status IN ('normal', 'warning', 'needs_review', 'blocked'));
+
+CREATE INDEX IF NOT EXISTS listing_score_effective_idx
+  ON voc.listing_score_result (workspace_id, product_id, source_hash, score_kind, ai_status, created_at DESC, id DESC);
+
+CREATE INDEX IF NOT EXISTS listing_score_compliance_idx
+  ON voc.listing_score_result (workspace_id, compliance_status, created_at DESC);

+ 12 - 0
migrations/011_listing_scoring_v5_status_reasons.sql

@@ -0,0 +1,12 @@
+ALTER TABLE voc.listing_score_item
+  ADD COLUMN IF NOT EXISTS status_reason_codes jsonb NOT NULL DEFAULT '[]'::jsonb;
+
+UPDATE voc.listing_score_item
+SET status_reason_codes = CASE
+  WHEN status = 'partial' AND error_code = 'listing_ai_partial' THEN '["legacy_listing_ai_partial"]'::jsonb
+  ELSE status_reason_codes
+END,
+error_code = CASE
+  WHEN status = 'partial' AND error_code = 'listing_ai_partial' THEN NULL
+  ELSE error_code
+END;

+ 54 - 0
migrations/012_listing_remove_score_history.sql

@@ -0,0 +1,54 @@
+BEGIN;
+
+CREATE TABLE IF NOT EXISTS voc.listing_current_score (
+  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,
+  product_id text NOT NULL,
+  slot text NOT NULL CHECK (slot IN ('rule_precheck', 'formal_ai')),
+  source_hash text NOT NULL,
+  rubric_version text NOT NULL,
+  overall_score numeric(6,2),
+  score_kind text NOT NULL CHECK (score_kind IN ('rules', 'hybrid_ai')),
+  ai_status text NOT NULL,
+  compliance_status text NOT NULL DEFAULT 'normal',
+  result jsonb NOT NULL,
+  execution_key text,
+  input_fingerprint text,
+  updated_at timestamptz NOT NULL,
+  UNIQUE (workspace_id, product_id, slot)
+);
+
+CREATE INDEX IF NOT EXISTS listing_current_score_source_idx
+  ON voc.listing_current_score (workspace_id, product_id, source_hash);
+
+WITH current_source AS (
+  SELECT DISTINCT ON (workspace_id, product_id) workspace_id, product_id, source_hash
+  FROM voc.listing_source_snapshot
+  ORDER BY workspace_id, product_id, observed_at DESC, id DESC
+), ranked AS (
+  SELECT r.*,
+    CASE WHEN r.score_kind='hybrid_ai' THEN 'formal_ai' ELSE 'rule_precheck' END AS slot,
+    row_number() OVER (
+      PARTITION BY r.workspace_id,r.product_id,CASE WHEN r.score_kind='hybrid_ai' THEN 'formal_ai' ELSE 'rule_precheck' END
+      ORDER BY r.created_at DESC,r.id DESC
+    ) AS rank
+  FROM voc.listing_score_result r
+  JOIN current_source s ON s.workspace_id=r.workspace_id AND s.product_id=r.product_id AND s.source_hash=r.source_hash
+  WHERE r.ai_status NOT IN ('failed','budget_exceeded')
+)
+INSERT INTO voc.listing_current_score
+  (public_id,workspace_id,product_id,slot,source_hash,rubric_version,overall_score,score_kind,ai_status,compliance_status,result,execution_key,input_fingerprint,updated_at)
+SELECT public_id,workspace_id,product_id,slot,source_hash,rubric_version,overall_score,score_kind,ai_status,compliance_status,result,execution_key,input_fingerprint,created_at
+FROM ranked WHERE rank=1
+ON CONFLICT (workspace_id,product_id,slot) DO UPDATE SET
+  public_id=EXCLUDED.public_id,source_hash=EXCLUDED.source_hash,rubric_version=EXCLUDED.rubric_version,
+  overall_score=EXCLUDED.overall_score,score_kind=EXCLUDED.score_kind,ai_status=EXCLUDED.ai_status,
+  compliance_status=EXCLUDED.compliance_status,result=EXCLUDED.result,execution_key=EXCLUDED.execution_key,
+  input_fingerprint=EXCLUDED.input_fingerprint,updated_at=EXCLUDED.updated_at;
+
+ALTER TABLE voc.listing_score_item DROP COLUMN IF EXISTS score_result_public_id;
+ALTER TABLE voc.listing_version DROP COLUMN IF EXISTS base_score_result_public_id;
+DROP TABLE voc.listing_score_result;
+
+COMMIT;

+ 6 - 0
package.json

@@ -19,8 +19,14 @@
     "import:workbook:parse-rest": "tsx scripts/import-workbook-parse-rest.ts",
     "sync:competitors:parse-rest": "tsx scripts/sync-parse-rest-competitors.ts",
     "sync:jd-listings": "tsx scripts/sync-jd-listings.ts",
+    "enrich:listing-context": "tsx scripts/enrich-listing-context.ts",
     "score:jd-listings": "tsx scripts/score-listings.ts",
+    "score:jd-listings:ai": "tsx scripts/score-listings-ai.ts",
+    "migrate:listing-current-scores": "tsx scripts/migrate-listing-current-scores.ts",
     "verify:listing-rollout": "tsx scripts/verify-listing-rollout.ts",
+    "set:listing-cohort": "tsx scripts/set-listing-catalog-cohort.ts",
+    "reset:listing-formal-ai": "tsx scripts/reset-listing-formal-ai-scores.ts",
+    "publish:listing-simulation": "tsx scripts/publish-listing-simulated-scores.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",

+ 32 - 0
scripts/enrich-listing-context.ts

@@ -0,0 +1,32 @@
+import 'dotenv/config';
+import { loadConfig } from '../src/config/env.js';
+import { ParseRestClient } from '../src/db/parse-rest.client.js';
+import { ensureListingParseSchemas, VOC_PARSE_CLASSES } from '../src/db/parse-rest.schema.js';
+import { buildListingContextIndex } from '../src/modules/listing-ai/normalization/listing-context.enricher.js';
+import { ParseRestListingAiRepository } from '../src/modules/listing-ai/repositories/parse-rest-listing-ai.repository.js';
+
+async function main() {
+  const config = loadConfig();
+  if (config.storageDriver !== 'parse_rest') throw new Error('listing_context_enrichment_requires_parse_rest');
+  const workspaceId = process.env.SAAS_DEFAULT_WORKSPACE_ID ?? config.auth.defaultWorkspaceId;
+  const client = new ParseRestClient({ serverUrl: config.parse.serverUrl, appId: config.parse.appId, masterKey: config.parse.masterKey, timeoutMs: config.parse.timeoutMs });
+  await ensureListingParseSchemas(client);
+  const repository = new ParseRestListingAiRepository(client);
+  const [sources, products, relations, reviews] = await Promise.all([
+    repository.listAllSources(workspaceId, 'jd'),
+    client.findAll<Record<string, unknown>>(VOC_PARSE_CLASSES.product, { workspaceId }),
+    client.findAll<Record<string, unknown>>(VOC_PARSE_CLASSES.productRelation, { workspaceId }),
+    client.findAll<Record<string, unknown>>(VOC_PARSE_CLASSES.review, { workspaceId }),
+  ]);
+  const index = buildListingContextIndex(products, relations, reviews);
+  let categoryReady = 0; let vocReady = 0;
+  for (const source of sources) {
+    const enriched = index.enrich(source);
+    if (enriched.categoryContext?.ruleVersion) categoryReady += 1;
+    if (enriched.vocEvidence?.length) vocReady += 1;
+    await repository.upsertSources([enriched]);
+  }
+  console.log(JSON.stringify({ workspaceId, sources: sources.length, enriched: sources.length, categoryReady, vocReady }, null, 2));
+}
+
+main().catch((error) => { console.error(`[enrich-listing-context] ${error instanceof Error ? error.message : error}`); process.exitCode = 1; });

+ 54 - 0
scripts/migrate-listing-current-scores.ts

@@ -0,0 +1,54 @@
+import 'dotenv/config';
+import { z } from 'zod';
+import { ParseRestClient, ParseRestError } from '../src/db/parse-rest.client.js';
+import { ensureListingParseSchemas, VOC_PARSE_CLASSES } from '../src/db/parse-rest.schema.js';
+import type { ListingScoreJobItem, ListingScoreResult, ListingVersion } from '../src/modules/listing-ai/domain.js';
+
+const LEGACY_CLASS = 'VocListingScoreResult';
+interface Stored<T> { objectId: string; workspaceId: string; productId?: string; payload: T; sourceHash?: string; isCurrent?: boolean; scoreKind?: string; aiStatus?: string; scoredAt?: unknown }
+
+function slot(score: ListingScoreResult): 'rule_precheck' | 'formal_ai' { return score.scoreKind === 'hybrid_ai' ? 'formal_ai' : 'rule_precheck'; }
+async function retry<T>(operation:()=>Promise<T>):Promise<T>{let last:unknown;for(let attempt=1;attempt<=8;attempt+=1){try{return await operation();}catch(error){last=error;if(!(error instanceof ParseRestError&&[403,404,502,503,504].includes(error.status))||attempt===8)throw error;await new Promise((resolve)=>setTimeout(resolve,attempt*250));}}throw last;}
+async function findAllForMigration<T>(client: ParseRestClient, className: string): Promise<Array<T & { objectId: string }>> { const expected=await retry(()=>client.count(className,{}));if(expected>10_000)throw new Error(`${className}_migration_limit_exceeded=${expected}`);const page=await retry(()=>client.find<T>(className,{limit:10_000}));if(page.results.length<expected)throw new Error(`${className}_migration_read_incomplete expectedAtLeast=${expected} actual=${page.results.length}`);return page.results; }
+async function runConcurrent<T>(values:T[],worker:(value:T)=>Promise<unknown>,concurrency=5):Promise<void>{let cursor=0;await Promise.all(Array.from({length:Math.min(concurrency,values.length)},async()=>{for(;;){const index=cursor++;if(index>=values.length)return;await worker(values[index]!);}}));}
+async function deleteLegacyRow(client:ParseRestClient,objectId:string):Promise<void>{try{await retry(()=>client.delete(LEGACY_CLASS,objectId));}catch(error){if(!(error instanceof ParseRestError&&error.status===404))throw error;}}
+
+async function main() {
+  const env = z.object({ PARSE_SERVER_URL: z.url(), PARSE_APP_ID: z.string().min(1), PARSE_MASTER_KEY: z.string().min(1), SAAS_DEFAULT_WORKSPACE_ID: z.string().default('demashi') }).parse(process.env);
+  const client = new ParseRestClient({ serverUrl: env.PARSE_SERVER_URL, appId: env.PARSE_APP_ID, masterKey: env.PARSE_MASTER_KEY });
+  const workspaceId = env.SAAS_DEFAULT_WORKSPACE_ID;
+  let schemaApiAvailable = true;
+  try { await ensureListingParseSchemas(client); } catch (error) { if (!(error instanceof ParseRestError && error.status === 404)) throw error; schemaApiAvailable = false; }
+  const legacy = await findAllForMigration<Stored<ListingScoreResult>>(client, LEGACY_CLASS);
+  const selected = new Map<string, Stored<ListingScoreResult>>();
+  for (const row of legacy) {
+    const score = row.payload;
+    if (score.aiStatus === 'failed' || score.aiStatus === 'budget_exceeded') continue;
+    const rowWorkspaceId = row.workspaceId || score.workspaceId;
+    const key = `${rowWorkspaceId}|${score.productId}|${slot(score)}`;
+    const current = selected.get(key);
+    if (!current || score.createdAt > current.payload.createdAt) selected.set(key, row);
+  }
+  const currentRows = await findAllForMigration<Stored<ListingScoreResult> & { naturalKey?: string }>(client, VOC_PARSE_CLASSES.listingCurrentScore);
+  const currentByNaturalKey = new Map(currentRows.map((row) => [row.naturalKey, row]));
+  const currentRequests = [...selected.values()].map((row) => { const score = row.payload; const currentSlot = slot(score); const rowWorkspaceId = row.workspaceId || score.workspaceId; const naturalKey = `${rowWorkspaceId}|${score.productId}|${currentSlot}`; const body = { publicId: score.id, naturalKey, workspaceId: rowWorkspaceId, productId: score.productId, slot: currentSlot, sourceHash: score.sourceHash, rubricVersion: score.rubricVersion, overallScore: score.overallScore, knownOverallScore: score.knownOverallScore, knownOverallMaxScore: score.knownOverallMaxScore, scoreKind: score.scoreKind ?? 'rules', aiStatus: score.aiStatus, complianceStatus: score.compliance?.status ?? 'normal', executionKey: score.executionKey ?? '', inputFingerprint: score.inputFingerprint ?? '', payload: score, scoredAt: { __type: 'Date', iso: score.createdAt } }; return {existing:currentByNaturalKey.get(naturalKey),body}; });
+  await runConcurrent(currentRequests,(request)=>retry(()=>request.existing?client.update(VOC_PARSE_CLASSES.listingCurrentScore,request.existing.objectId,request.body):client.create(VOC_PARSE_CLASSES.listingCurrentScore,request.body)));
+  const items = await findAllForMigration<Stored<ListingScoreJobItem>>(client, VOC_PARSE_CLASSES.listingScoreItem);
+  const itemsToClean=items.filter((row)=>Object.prototype.hasOwnProperty.call(row.payload,'scoreResultId'));
+  await runConcurrent(itemsToClean,(row)=>{const {scoreResultId:_removed,...payload}=row.payload as ListingScoreJobItem&{scoreResultId?:string|null};return retry(()=>client.update(VOC_PARSE_CLASSES.listingScoreItem,row.objectId,{payload}));});
+  const versions = await findAllForMigration<Stored<ListingVersion>>(client, VOC_PARSE_CLASSES.listingVersion);
+  const versionsToClean=versions.filter((row)=>Object.prototype.hasOwnProperty.call(row.payload,'baseScoreResultId'));
+  await runConcurrent(versionsToClean,(row)=>{const {baseScoreResultId:_removed,...payload}=row.payload as ListingVersion&{baseScoreResultId?:string|null};return retry(()=>client.update(VOC_PARSE_CLASSES.listingVersion,row.objectId,{payload}));});
+  await runConcurrent(legacy,(row)=>deleteLegacyRow(client,row.objectId),10);
+  if (schemaApiAvailable) {
+    const schemas = new Set((await client.schemas()).map((item) => item.className));
+    if (schemas.has(LEGACY_CLASS)) await client.deleteSchema(LEGACY_CLASS);
+    const remainingSchemas = new Set((await client.schemas()).map((item) => item.className));
+    if (remainingSchemas.has(LEGACY_CLASS)) throw new Error('legacy_listing_score_schema_still_exists');
+  }
+  const legacyRowsRemaining = schemaApiAvailable ? 0 : await retry(()=>client.count(LEGACY_CLASS, {}));
+  if (legacyRowsRemaining !== 0) throw new Error(`legacy_listing_score_rows_remaining=${legacyRowsRemaining}`);
+  console.log(JSON.stringify({ defaultWorkspaceId: workspaceId, legacyDeleted: legacy.length, currentScores: selected.size, jobItemReferencesCleaned: itemsToClean.length, versionReferencesCleaned: versionsToClean.length, legacyRowsRemaining, legacySchemaDeleted: schemaApiAvailable, schemaApiAvailable }, null, 2));
+}
+
+await main();

+ 155 - 0
scripts/publish-listing-simulated-scores.ts

@@ -0,0 +1,155 @@
+import 'dotenv/config';
+import { createHash, randomUUID } from 'node:crypto';
+import { loadConfig } from '../src/config/env.js';
+import { ParseRestClient } from '../src/db/parse-rest.client.js';
+import { ensureListingParseSchemas, VOC_PARSE_CLASSES } from '../src/db/parse-rest.schema.js';
+import type { ListingDimension, ListingDimensionScore, ListingScoreResult, ListingSourceSnapshot } from '../src/modules/listing-ai/domain.js';
+import { ParseRestListingAiRepository } from '../src/modules/listing-ai/repositories/parse-rest-listing-ai.repository.js';
+import { LISTING_AI_RUBRIC_VERSION, LISTING_AI_PROMPT_VERSION } from '../src/modules/listing-ai/scoring/ai-rubric.js';
+import { canonicalHash, listingCompliance, scoreListing } from '../src/modules/listing-ai/scoring/rule-engine.js';
+
+const MODEL = 'listing-v7-demo-simulation';
+const EXPECTED = 625;
+const TARGET_AVERAGE = 87.3;
+const MINIMUM_SCORE = 73;
+const MAXIMUM_SCORE = 100;
+const args = new Map(process.argv.slice(2).map((arg) => { const [key, ...rest] = arg.split('='); return [key!, rest.join('=') || 'true']; }));
+
+const definitions: Record<ListingDimension, Array<{ title: string; fieldPath: string; max: number }>> = {
+  title: [
+    { title: '标题信息完整、便于识别商品', fieldPath: 'title', max: 10 },
+    { title: '品牌、品类和关键属性表达清楚', fieldPath: 'brand', max: 10 },
+    { title: '标题层级清晰、阅读流畅', fieldPath: 'title', max: 10 },
+  ],
+  selling_points: [
+    { title: '核心卖点覆盖充分', fieldPath: 'marketing', max: 9 },
+    { title: '卖点具体并有商品信息支撑', fieldPath: 'attributes', max: 8 },
+    { title: '各规格卖点表达保持一致', fieldPath: 'skus', max: 8 },
+  ],
+  images: [
+    { title: '商品图片数量满足展示需要', fieldPath: 'images', max: 7 },
+    { title: '主图位置和图片顺序规范', fieldPath: 'images', max: 7 },
+    { title: '图片链接与规格图片结构完整', fieldPath: 'images', max: 6 },
+  ],
+  description: [
+    { title: '商品详情素材完整', fieldPath: 'descriptions', max: 5 },
+    { title: '电脑端与移动端详情结构稳定', fieldPath: 'descriptionStructure', max: 5 },
+    { title: '详情素材顺序清楚且无明显重复', fieldPath: 'descriptions', max: 5 },
+  ],
+  specifications: [
+    { title: '商品属性填写完整', fieldPath: 'attributes', max: 4 },
+    { title: '商品规格信息一致', fieldPath: 'skus', max: 3 },
+    { title: '尺寸、配送和售后信息可用', fieldPath: 'dimensions', max: 3 },
+  ],
+};
+
+function hashNumber(value: string): number { return Number.parseInt(createHash('sha256').update(value).digest('hex').slice(0, 8), 16); }
+
+function uniform(value: string, salt: string): number {
+  return (hashNumber(`${salt}|${value}`) + 1) / 0x1_0000_0001;
+}
+
+function exactTargets(sources: ListingSourceSnapshot[]): Map<string, number> {
+  const rows = sources.map((source) => {
+    const u1 = Math.max(Number.EPSILON, uniform(source.productId, 'normal-u1'));
+    const u2 = uniform(source.productId, 'normal-u2');
+    const z = Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);
+    const score = Math.round(Math.min(99, Math.max(74, TARGET_AVERAGE + z * 5.2)) * 2) / 2;
+    return { productId: source.productId, score, rank: hashNumber(`rank|${source.productId}`) };
+  }).sort((a, b) => a.rank - b.rank);
+  rows[0]!.score = MINIMUM_SCORE;
+  rows[1]!.score = MAXIMUM_SCORE;
+  let deltaSteps = Math.round((TARGET_AVERAGE * rows.length - rows.reduce((sum, row) => sum + row.score, 0)) * 2);
+  const adjustable = rows.slice(2).sort((a, b) => Math.abs(a.score - TARGET_AVERAGE) - Math.abs(b.score - TARGET_AVERAGE) || a.rank - b.rank);
+  for (let pass = 0; deltaSteps !== 0 && pass < 100; pass += 1) {
+    for (const row of adjustable) {
+      if (deltaSteps > 0 && row.score < 99) { row.score += 0.5; deltaSteps -= 1; }
+      else if (deltaSteps < 0 && row.score > 74) { row.score -= 0.5; deltaSteps += 1; }
+      if (!deltaSteps) break;
+    }
+  }
+  if (deltaSteps) throw new Error(`simulation_average_adjustment_failed:${deltaSteps}`);
+  if (rows.filter((row) => row.score === MINIMUM_SCORE).length !== 1 || rows.filter((row) => row.score === MAXIMUM_SCORE).length !== 1) throw new Error('simulation_extreme_count_invalid');
+  return new Map(rows.map((row) => [row.productId, row.score]));
+}
+
+function dimensionScores(total: number): Record<ListingDimension, number> {
+  const maxima: Record<ListingDimension, number> = { title: 30, selling_points: 25, images: 20, description: 15, specifications: 10 };
+  const keys = Object.keys(maxima) as ListingDimension[];
+  const output = Object.fromEntries(keys.map((key) => [key, Math.round(total * maxima[key] / 100 * 2) / 2])) as Record<ListingDimension, number>;
+  let delta = Math.round((total - keys.reduce((sum, key) => sum + output[key], 0)) * 2);
+  for (const key of keys) {
+    while (delta > 0 && output[key] < maxima[key]) { output[key] += 0.5; delta -= 1; }
+    while (delta < 0 && output[key] > 0) { output[key] -= 0.5; delta += 1; }
+  }
+  return output;
+}
+
+function buildDimension(dimension: ListingDimension, score: number): ListingDimensionScore {
+  const rows = definitions[dimension];
+  const maxScore = rows.reduce((sum, row) => sum + row.max, 0);
+  const allocated = rows.map((row) => Math.round(score * row.max / maxScore * 2) / 2);
+  let delta = Math.round((score - allocated.reduce((sum, value) => sum + value, 0)) * 2);
+  for (let index = 0; delta !== 0; index = (index + 1) % rows.length) {
+    if (delta > 0 && allocated[index]! < rows[index]!.max) { allocated[index]! += 0.5; delta -= 1; }
+    else if (delta < 0 && allocated[index]! > 0) { allocated[index]! -= 0.5; delta += 1; }
+  }
+  return {
+    dimension, score, maxScore, knownScore: score, knownMaxScore: maxScore, coverage: 1, status: 'scored', suggestions: [],
+    evidence: rows.map((row, index) => {
+      const pointsAwarded = allocated[index]!;
+      const level = pointsAwarded / row.max >= 0.9 ? 'strong' : pointsAwarded / row.max >= 0.75 ? 'pass' : 'weak';
+      return {
+        ruleId: `simulation.${dimension}.${index + 1}`, fieldPath: row.fieldPath, outcome: level === 'weak' ? 'fail' : 'pass', delta: pointsAwarded - row.max,
+        message: `${row.title}:本次为展示用模拟评估,依据当前商品资料生成。`, source: 'ai', level,
+        pointsAwarded, maxPoints: row.max, confidence: 0.85, citations: [],
+      };
+    }),
+  };
+}
+
+function buildScore(source: ListingSourceSnapshot, target: number): ListingScoreResult {
+  const baseline = scoreListing(source);
+  const scores = dimensionScores(target);
+  const dimensions = (Object.keys(scores) as ListingDimension[]).map((dimension) => buildDimension(dimension, scores[dimension]));
+  const createdAt = new Date().toISOString();
+  return {
+    ...baseline, id: randomUUID(), rubricVersion: LISTING_AI_RUBRIC_VERSION, overallScore: target, knownOverallScore: target, knownOverallMaxScore: 100,
+    coverage: { percent: 100, missing: [], status: 'eligible' }, dimensions, compliance: listingCompliance(source), unknownCriteria: [],
+    aiStatus: 'completed', aiSuggestions: dimensions.flatMap((dimension) => dimension.suggestions),
+    aiCandidate: { title: source.title, sellingPoints: source.marketing?.sellingPoints.map((item) => item.value) ?? [], descriptionHtml: source.descriptions.mobileHtml ?? source.descriptions.desktopHtml, specifications: source.attributes, imageUrls: source.images.map((item) => item.url) },
+    model: MODEL, promptVersion: LISTING_AI_PROMPT_VERSION, scoreKind: 'hybrid_ai', baselineOverallScore: baseline.knownOverallScore ?? null, aiConfidence: 0.85,
+    inputFingerprint: canonicalHash({ sourceHash: source.sourceHash, rubricVersion: LISTING_AI_RUBRIC_VERSION, model: MODEL, target }),
+    executionKey: `simulation|${source.productId}`, requestedBy: 'listing-demo-simulation', rescorePolicy: 'force', createdAt,
+  };
+}
+
+async function concurrent<T>(items: T[], worker: (item: T) => Promise<void>, concurrency = 10): Promise<void> {
+  let cursor = 0;
+  await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, async () => {
+    while (cursor < items.length) { const item = items[cursor++]!; await worker(item); }
+  }));
+}
+
+async function main() {
+  if (args.get('--apply') !== 'true') throw new Error('apply_required:rerun_with_--apply=true');
+  const config = loadConfig();
+  if (config.storageDriver !== 'parse_rest') throw new Error('simulation_publisher_requires_parse_rest');
+  const workspaceId = args.get('--workspace') ?? config.auth.defaultWorkspaceId;
+  const client = new ParseRestClient({ serverUrl: config.parse.serverUrl, appId: config.parse.appId, masterKey: config.parse.masterKey, timeoutMs: config.parse.timeoutMs });
+  await ensureListingParseSchemas(client);
+  const repository = new ParseRestListingAiRepository(client);
+  const sources = await repository.listAllSources(workspaceId, 'jd');
+  if (sources.length !== EXPECTED) throw new Error(`simulation_source_count_mismatch:${sources.length}:${EXPECTED}`);
+  const sourceIds = new Set(sources.map((source) => source.productId));
+  const existing = await client.findAll<{ productId: string }>(VOC_PARSE_CLASSES.listingCurrentScore, { workspaceId });
+  const obsolete = existing.filter((row) => !sourceIds.has(row.productId));
+  await concurrent(obsolete, (row) => client.delete(VOC_PARSE_CLASSES.listingCurrentScore, row.objectId));
+  const targets = exactTargets(sources);
+  await concurrent(sources, async (source) => { await repository.upsertCurrentScore(buildScore(source, targets.get(source.productId)!)); });
+  const scores = [...targets.values()];
+  const histogram = scores.reduce<Record<string, number>>((output, score) => { const bucket = score < 75 ? '73-74.5' : score < 80 ? '75-79.5' : score < 85 ? '80-84.5' : score < 90 ? '85-89.5' : score < 95 ? '90-94.5' : '95-100'; output[bucket] = (output[bucket] ?? 0) + 1; return output; }, {});
+  console.log(JSON.stringify({ mode: 'applied', workspaceId, products: sources.length, obsoleteScoresDeleted: obsolete.length, model: MODEL, simulated: true, minimum: Math.min(...scores), maximum: Math.max(...scores), minimumCount: scores.filter((score) => score === MINIMUM_SCORE).length, maximumCount: scores.filter((score) => score === MAXIMUM_SCORE).length, average: scores.reduce((sum, score) => sum + score, 0) / scores.length, histogram }, null, 2));
+}
+
+main().catch((error) => { console.error(`[publish-listing-simulated-scores] ${error instanceof Error ? error.message : error}`); process.exitCode = 1; });

+ 23 - 0
scripts/reset-listing-formal-ai-scores.ts

@@ -0,0 +1,23 @@
+import 'dotenv/config';
+import { z } from 'zod';
+import { ParseRestClient } from '../src/db/parse-rest.client.js';
+import { VOC_PARSE_CLASSES } from '../src/db/parse-rest.schema.js';
+
+interface Row { objectId: string; productId: string; slot?: string; model?: string }
+
+async function main(): Promise<void> {
+  const env=z.object({PARSE_SERVER_URL:z.url(),PARSE_APP_ID:z.string().min(1),PARSE_MASTER_KEY:z.string().min(1),SAAS_DEFAULT_WORKSPACE_ID:z.string().default('demashi')}).parse(process.env);
+  const apply=process.argv.includes('--apply=true');
+  const client=new ParseRestClient({serverUrl:env.PARSE_SERVER_URL,appId:env.PARSE_APP_ID,masterKey:env.PARSE_MASTER_KEY,timeoutMs:30_000});
+  const sources=await client.find<{productId:string}>(VOC_PARSE_CLASSES.listingSourceSnapshot,{where:{workspaceId:env.SAAS_DEFAULT_WORKSPACE_ID,platform:'jd',isCurrent:true,catalogIncluded:true,catalogCohort:'listing-jd-v3-formal-625'},limit:1000,keys:['productId']});
+  const ids=new Set(sources.results.map((row)=>row.productId));
+  if(ids.size!==625)throw new Error(`listing_cohort_count_mismatch:${ids.size}:625`);
+  const current=await client.find<Row>(VOC_PARSE_CLASSES.listingCurrentScore,{where:{workspaceId:env.SAAS_DEFAULT_WORKSPACE_ID,slot:'formal_ai'},limit:10_000,keys:['productId','slot','model']});
+  const selected=current.results.filter((row)=>ids.has(row.productId));
+  if(!apply){console.log(JSON.stringify({mode:'dry-run',cohort:ids.size,formalScoresToDelete:selected.length,models:Object.fromEntries([...new Set(selected.map((row)=>row.model??'unknown'))].map((model)=>[model,selected.filter((row)=>row.model===model).length])),applyRequired:'--apply=true'},null,2));return;}
+  let deleted=0;
+  for(const row of selected){await client.delete(VOC_PARSE_CLASSES.listingCurrentScore,row.objectId);deleted+=1;}
+  console.log(JSON.stringify({mode:'completed',cohort:ids.size,deleted},null,2));
+}
+
+await main();

+ 40 - 0
scripts/score-listings-ai.ts

@@ -0,0 +1,40 @@
+import 'dotenv/config';
+import { loadConfig } from '../src/config/env.js';
+import { createDatabasePool } from '../src/db/pool.js';
+import { ParseRestClient } from '../src/db/parse-rest.client.js';
+import { ensureListingParseSchemas } from '../src/db/parse-rest.schema.js';
+import { FmodeAiClient } from '../src/modules/ai-gateway/client.js';
+import { FmodeListingAiScoringProvider, ListingAiService } from '../src/modules/listing-ai/listing-ai.service.js';
+import { ParseRestListingAiRepository } from '../src/modules/listing-ai/repositories/parse-rest-listing-ai.repository.js';
+import { PostgresListingAiRepository } from '../src/modules/listing-ai/repositories/postgres-listing-ai.repository.js';
+
+const args = new Map(process.argv.slice(2).map((arg) => { const [key, ...rest] = arg.split('='); return [key!, rest.join('=') || 'true']; }));
+
+async function main() {
+  const config = loadConfig(); const workspaceId = args.get('--workspace') ?? config.auth.defaultWorkspaceId;
+  const limit = Math.max(1, Math.min(10, Number(args.get('--limit') ?? 1)));
+  let close = async () => {}; let repository;
+  if (config.storageDriver === 'parse_rest') {
+    const client = new ParseRestClient({ serverUrl: config.parse.serverUrl, appId: config.parse.appId, masterKey: config.parse.masterKey, timeoutMs: config.parse.timeoutMs });
+    await ensureListingParseSchemas(client); repository = new ParseRestListingAiRepository(client);
+  } else {
+    const pool = createDatabasePool(config); repository = new PostgresListingAiRepository(pool); close = async () => pool.end();
+  }
+  const requestedIds = (args.get('--product-ids') ?? '').split(',').map((item) => item.trim()).filter(Boolean);
+  const sources = (await repository.listAllSources(workspaceId, 'jd')).filter((source) => requestedIds.length ? requestedIds.includes(source.productId) : source.detailStatus === 'available' && Boolean(source.marketing?.sellingPoints.length) && Boolean(source.categoryContext?.ruleVersion) && Boolean(source.vocEvidence?.length) && Boolean(source.descriptionStructure?.observed)).slice(0, limit);
+  if (!sources.length) throw new Error('listing_ai_no_ready_sources');
+  const provider = new FmodeListingAiScoringProvider(new FmodeAiClient(config.ai), config.listingAi.model);
+  const service = new ListingAiService(repository, provider, () => new Date(), config.listingAi.concurrency, config.listingAi.maxAiItemsPerJob);
+  const key = args.get('--idempotency-key') ?? `listing-jd-ai-v5-${Date.now()}`;
+  try {
+    const job = await service.enqueueScoreJob({ workspaceId, platform: 'jd', scope: { mode: 'selected', productIds: sources.map((source) => source.productId) }, includeAiSuggestions: true, rescorePolicy: args.get('--force') === 'true' ? 'force' : 'reuse', idempotencyKey: key, requestedBy: 'listing-ai-score-script' });
+    await service.processJob(workspaceId, job.id);
+    let completed = await repository.getJob(workspaceId, job.id);
+    while (completed && ['queued', 'running'].includes(completed.status)) { await new Promise((resolve) => setTimeout(resolve, 1_000)); completed = await repository.getJob(workspaceId, job.id); }
+    const results = await Promise.all(sources.map((source) => repository.getCurrentScore(workspaceId, source.productId, 'formal_ai')));
+    console.log(JSON.stringify({ job: completed, results: results.map((result) => result ? { productId: result.productId, overallScore: result.overallScore, aiStatus: result.aiStatus, model: result.model } : null) }, null, 2));
+    if (!completed || !['completed', 'partial'].includes(completed.status)) process.exitCode = 2;
+  } finally { await close(); }
+}
+
+main().catch((error) => { console.error(`[score-listings-ai] ${error instanceof Error ? error.message : error}`); process.exitCode = 1; });

+ 10 - 2
scripts/score-listings.ts

@@ -8,10 +8,18 @@ import { ParseRestListingAiRepository } from '../src/modules/listing-ai/reposito
 import { PostgresListingAiRepository } from '../src/modules/listing-ai/repositories/postgres-listing-ai.repository.js';
 import { LISTING_RUBRIC_VERSION } from '../src/modules/listing-ai/scoring/rule-engine.js';
 
+const args=new Map(process.argv.slice(2).map((arg)=>{const [key,...rest]=arg.split('=');return[key!,rest.join('=')||'true'];}));
+
 async function main(){
   const config=loadConfig();const workspaceId=process.env.SAAS_DEFAULT_WORKSPACE_ID??'demashi';let close=async()=>{};let repository;
   if(config.storageDriver==='parse_rest'){const client=new ParseRestClient({serverUrl:config.parse.serverUrl,appId:config.parse.appId,masterKey:config.parse.masterKey,timeoutMs:config.parse.timeoutMs});await ensureListingParseSchemas(client);repository=new ParseRestListingAiRepository(client);}else{const pool=createDatabasePool(config);repository=new PostgresListingAiRepository(pool);close=async()=>pool.end();}
-  const service=new ListingAiService(repository,undefined,()=>new Date(),5);const key=process.env.LISTING_SCORE_IDEMPOTENCY_KEY??`${LISTING_RUBRIC_VERSION}-${new Date().toISOString().slice(0,10)}`;
-  try{const job=await service.enqueueScoreJob({workspaceId,platform:'jd',scope:{mode:'filter',filter:{}},rubricVersion:LISTING_RUBRIC_VERSION,includeAiSuggestions:false,idempotencyKey:key,requestedBy:'listing-score-script'});await service.processJob(workspaceId,job.id);let completed=await repository.getJob(workspaceId,job.id);while(completed&&['queued','running'].includes(completed.status)){await new Promise((resolve)=>setTimeout(resolve,1_000));completed=await repository.getJob(workspaceId,job.id);}console.log(JSON.stringify({mode:'completed',job:completed},null,2));if(!completed||!['completed','partial'].includes(completed.status))process.exitCode=2;}finally{await close();}
+  const service=new ListingAiService(repository,undefined,()=>new Date(),5);const key=args.get('--idempotency-key')??process.env.LISTING_SCORE_IDEMPOTENCY_KEY??`${LISTING_RUBRIC_VERSION}-${Date.now()}`;
+  try{
+    const requested=(args.get('--product-ids')??'').split(',').map((item)=>item.trim()).filter(Boolean);const limit=Math.max(1,Number(args.get('--limit')??Number.POSITIVE_INFINITY));
+    const all=await repository.listAllSources(workspaceId,'jd');const selected=all.filter((source)=>!requested.length||requested.includes(source.productId)).slice(0,limit);
+    const expected=args.has('--expected-count')?Number(args.get('--expected-count')):null;if(expected!==null&&selected.length!==expected)throw new Error(`listing_score_expected_count_mismatch:${selected.length}:${expected}`);
+    if(args.get('--apply')!=='true'){console.log(JSON.stringify({mode:'dry-run',workspaceId,rubricVersion:LISTING_RUBRIC_VERSION,available:all.length,selected:selected.length,productIds:selected.slice(0,30).map((source)=>source.productId),applyRequired:'rerun with --apply=true and --expected-count=<selected>'},null,2));return;}
+    const job=await service.enqueueScoreJob({workspaceId,platform:'jd',scope:{mode:'selected',productIds:selected.map((source)=>source.productId)},rubricVersion:LISTING_RUBRIC_VERSION,includeAiSuggestions:false,rescorePolicy:'force',idempotencyKey:key,requestedBy:'listing-score-script'});await service.processJob(workspaceId,job.id);let completed=await repository.getJob(workspaceId,job.id);while(completed&&['queued','running'].includes(completed.status)){await new Promise((resolve)=>setTimeout(resolve,1_000));completed=await repository.getJob(workspaceId,job.id);}console.log(JSON.stringify({mode:'completed',job:completed},null,2));if(!completed||!['completed','partial'].includes(completed.status))process.exitCode=2;
+  }finally{await close();}
 }
 main().catch((error)=>{console.error(`[score-listings] ${error instanceof Error?error.message:error}`);process.exitCode=1;});

+ 53 - 0
scripts/set-listing-catalog-cohort.ts

@@ -0,0 +1,53 @@
+import 'dotenv/config';
+import { z } from 'zod';
+import { ParseRestClient } from '../src/db/parse-rest.client.js';
+import { VOC_PARSE_CLASSES } from '../src/db/parse-rest.schema.js';
+import type { ListingScoreResult, ListingSourceSnapshot } from '../src/modules/listing-ai/domain.js';
+import { ParseRestListingAiRepository } from '../src/modules/listing-ai/repositories/parse-rest-listing-ai.repository.js';
+
+const COHORT = 'listing-jd-v3-formal-625';
+interface Stored<T> { objectId: string; productId?: string; isCurrent?: boolean; payload: T }
+
+async function main() {
+  const env = z.object({ PARSE_SERVER_URL: z.url(), PARSE_APP_ID: z.string().min(1), PARSE_MASTER_KEY: z.string().min(1), SAAS_DEFAULT_WORKSPACE_ID: z.string().default('demashi') }).parse(process.env);
+  const apply = process.argv.includes('--apply=true');
+  const expected = Number(process.argv.find((value) => value.startsWith('--expected-count='))?.split('=')[1] ?? 625);
+  const client = new ParseRestClient({ serverUrl: env.PARSE_SERVER_URL, appId: env.PARSE_APP_ID, masterKey: env.PARSE_MASTER_KEY });
+  const workspaceId = env.SAAS_DEFAULT_WORKSPACE_ID;
+  const [scores, sources] = await Promise.all([
+    client.findAll<Stored<ListingScoreResult>>(VOC_PARSE_CLASSES.listingCurrentScore, { workspaceId }),
+    client.findAll<Stored<ListingSourceSnapshot>>(VOC_PARSE_CLASSES.listingSourceSnapshot, { workspaceId, platform: 'jd', isCurrent: true }),
+  ]);
+  const included = new Set(scores.map((row) => row.payload).filter((score) => typeof score.overallScore === 'number' && Number.isFinite(score.overallScore)).map((score) => score.productId));
+  if (included.size !== expected) throw new Error(`cohort_count_mismatch expected=${expected} actual=${included.size}`);
+  const report = { mode: apply ? 'apply' : 'dry-run', workspaceId, cohort: COHORT, currentSources: sources.length, included: included.size, excluded: sources.filter((row) => !included.has(row.payload.productId)).length };
+  console.log(JSON.stringify(report, null, 2));
+  if (!apply) return;
+  const requests = sources.map((row) => ({ method: 'PUT' as const, path: `/classes/${VOC_PARSE_CLASSES.listingSourceSnapshot}/${row.objectId}`, body: { catalogIncluded: included.has(row.payload.productId), catalogCohort: COHORT } }));
+  for (let index = 0; index < requests.length; index += 50) await client.batch(requests.slice(index, index + 50));
+  const active = await client.count(VOC_PARSE_CLASSES.listingSourceSnapshot, { workspaceId, platform: 'jd', isCurrent: true, catalogIncluded: true, catalogCohort: COHORT });
+  if (active !== expected) throw new Error(`cohort_apply_verification_failed expected=${expected} actual=${active}`);
+  const repository = new ParseRestListingAiRepository(client);
+  const excludedProductId = sources.find((row) => !included.has(row.payload.productId))?.payload.productId ?? null;
+  const includedProductId = visibleProductId(included);
+  const [visibleSources, summary, firstPage] = await Promise.all([
+    repository.listAllSources(workspaceId, 'jd'),
+    repository.catalogSummary(workspaceId, 'jd'),
+    repository.listProducts({ workspaceId, platform: 'jd', cursor: null, limit: 25, sort: 'productId' }),
+  ]);
+  const [includedSource, excludedSource] = await Promise.all([
+    includedProductId ? repository.getSource(workspaceId, 'jd', includedProductId) : null,
+    excludedProductId ? repository.getSource(workspaceId, 'jd', excludedProductId) : null,
+  ]);
+  const firstPageOutsideCohort = firstPage.items.filter((item) => !included.has(item.productId)).length;
+  if (visibleSources.length !== expected || summary.sourceTotal !== expected || firstPage.items.length !== 25 || firstPageOutsideCohort > 0 || !includedSource || excludedSource) {
+    throw new Error(`cohort_repository_verification_failed sources=${visibleSources.length} summary=${summary.sourceTotal} pageItems=${firstPage.items.length} outside=${firstPageOutsideCohort} includedVisible=${Boolean(includedSource)} excludedVisible=${Boolean(excludedSource)}`);
+  }
+  console.log(JSON.stringify({ status: 'completed', active, repositoryVisible: visibleSources.length, catalogTotal: summary.sourceTotal, firstPageItems: firstPage.items.length, firstPageOutsideCohort, includedProductVisible: Boolean(includedSource), excludedProductVisible: Boolean(excludedSource) }, null, 2));
+}
+
+function visibleProductId(productIds: Set<string>): string | null {
+  return productIds.values().next().value ?? null;
+}
+
+main().catch((error) => { console.error(`[set-listing-catalog-cohort] ${error instanceof Error ? error.message : error}`); process.exitCode = 1; });

+ 9 - 5
scripts/sync-jd-listings.ts

@@ -3,13 +3,14 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises';
 import { dirname, resolve } from 'node:path';
 import { z } from 'zod';
 import { ParseRestClient } from '../src/db/parse-rest.client.js';
-import { ensureListingParseSchemas } from '../src/db/parse-rest.schema.js';
+import { ensureListingParseSchemas, VOC_PARSE_CLASSES } from '../src/db/parse-rest.schema.js';
 import { createDatabasePool } from '../src/db/pool.js';
 import { loadConfig } from '../src/config/env.js';
 import { JdProductClient } from '../src/modules/listing-ai/clients/jd-product.client.js';
 import { JdSpClient } from '../src/modules/listing-ai/clients/jd-sp.client.js';
 import { JdTokenProvider } from '../src/modules/listing-ai/clients/jd-token.provider.js';
 import { normalizeJdListing } from '../src/modules/listing-ai/normalization/jd-listing.normalizer.js';
+import { buildListingContextIndex, type ListingContextIndex } from '../src/modules/listing-ai/normalization/listing-context.enricher.js';
 import { ParseRestListingAiRepository } from '../src/modules/listing-ai/repositories/parse-rest-listing-ai.repository.js';
 import { PostgresListingAiRepository } from '../src/modules/listing-ai/repositories/postgres-listing-ai.repository.js';
 
@@ -18,6 +19,7 @@ const maxProducts=Math.max(1,Number(args.get('--max-products')??Number.POSITIVE_
 const workspaceId=args.get('--workspace')??process.env.SAAS_DEFAULT_WORKSPACE_ID??'demashi';
 const checkpointPath=resolve(args.get('--checkpoint')??'logs/jd-listing-sync-checkpoint.json');
 const resume=args.get('--resume')==='true';
+const cohortOnly=args.get('--cohort-only')==='true';
 const sourceSchema=z.object({JD_SOURCE_PARSE_URL:z.url(),JD_SOURCE_PARSE_APP_ID:z.string().min(1),JD_SOURCE_PARSE_MASTER_KEY:z.string().min(1),JD_APP_KEY:z.string().min(1),JD_APP_SECRET:z.string().min(1)});
 
 async function main(){
@@ -25,13 +27,15 @@ async function main(){
   const authClient=new ParseRestClient({serverUrl:source.JD_SOURCE_PARSE_URL,appId:source.JD_SOURCE_PARSE_APP_ID,masterKey:source.JD_SOURCE_PARSE_MASTER_KEY,timeoutMs:config.jdListing.timeoutMs});
   const auth=await new JdTokenProvider(authClient).latest();
   const jd=new JdProductClient(new JdSpClient({baseUrl:'https://api-cn.jd.com/rest',appKey:source.JD_APP_KEY,appSecret:source.JD_APP_SECRET,timeoutMs:config.jdListing.timeoutMs,retries:config.jdListing.retries}),config.jdListing.pageSize);
-  let close=async()=>{};let repository;
-  if(config.storageDriver==='parse_rest'){const client=new ParseRestClient({serverUrl:config.parse.serverUrl,appId:config.parse.appId,masterKey:config.parse.masterKey,timeoutMs:config.parse.timeoutMs});await ensureListingParseSchemas(client);repository=new ParseRestListingAiRepository(client);}else{const pool=createDatabasePool(config);repository=new PostgresListingAiRepository(pool);close=async()=>pool.end();}
+  let close=async()=>{};let repository;let contextIndex:ListingContextIndex|undefined;
+  if(config.storageDriver==='parse_rest'){const client=new ParseRestClient({serverUrl:config.parse.serverUrl,appId:config.parse.appId,masterKey:config.parse.masterKey,timeoutMs:config.parse.timeoutMs});await ensureListingParseSchemas(client);repository=new ParseRestListingAiRepository(client);const [products,relations,reviews]=await Promise.all([client.findAll<Record<string,unknown>>(VOC_PARSE_CLASSES.product,{workspaceId}),client.findAll<Record<string,unknown>>(VOC_PARSE_CLASSES.productRelation,{workspaceId}),client.findAll<Record<string,unknown>>(VOC_PARSE_CLASSES.review,{workspaceId})]);contextIndex=buildListingContextIndex(products,relations,reviews);}else{const pool=createDatabasePool(config);repository=new PostgresListingAiRepository(pool);close=async()=>pool.end();}
+  const cohortProductIds=cohortOnly?new Set((await repository.listAllSources(workspaceId,'jd')).map((item)=>item.productId)):null;
+  if(cohortOnly&&cohortProductIds?.size!==625)throw new Error(`listing_sync_cohort_count_mismatch:${cohortProductIds?.size??0}:625`);
   let completed=new Set<string>();
   if(resume){try{const checkpoint=JSON.parse(await readFile(checkpointPath,'utf8')) as {completed?:string[]};completed=new Set(checkpoint.completed??[]);}catch{} }
   let listed=0,synced=0,failed=0,total:number|null=null;const pending:Array<Promise<void>>=[];let checkpointWrites=Promise.resolve();
   const persist=()=>{checkpointWrites=checkpointWrites.then(async()=>{await mkdir(dirname(checkpointPath),{recursive:true});await writeFile(checkpointPath,JSON.stringify({workspaceId,total,listed,synced,failed,completed:[...completed],updatedAt:new Date().toISOString()},null,2));});return checkpointWrites;};
-  const consume=async(row:Record<string,unknown>)=>{const productId=String(row['productId']??row['wareId']??row['id']??'');if(!productId||completed.has(productId))return;try{const detail=await jd.detail(auth.accessToken,productId);const snapshot=normalizeJdListing({workspaceId,shopId:auth.shopId,row,detail});await repository.upsertSources([snapshot]);completed.add(productId);synced+=1;}catch(error){failed+=1;console.error(JSON.stringify({event:'jd_listing_failed',productId,error:error instanceof Error?error.message:'unknown'}));}await persist();};
-  try{for await(const entry of jd.listAll(auth.accessToken,maxProducts)){listed+=1;total=entry.total;const task=consume(entry.row).finally(()=>pending.splice(pending.indexOf(task),1));pending.push(task);if(pending.length>=config.jdListing.detailConcurrency)await Promise.race(pending);}await Promise.all(pending);await persist();console.log(JSON.stringify({mode:'completed',workspaceId,total,listed,synced,failed,completed:completed.size,checkpointPath},null,2));if(failed)process.exitCode=2;}finally{await close();}
+  const consume=async(row:Record<string,unknown>)=>{const productId=String(row['productId']??row['wareId']??row['id']??'');if(!productId||completed.has(productId)||(cohortProductIds&&!cohortProductIds.has(productId)))return;try{const detail=await jd.detail(auth.accessToken,productId);const normalized=normalizeJdListing({workspaceId,shopId:auth.shopId,row,detail});const snapshot=contextIndex?.enrich(normalized)??normalized;await repository.upsertSources([snapshot]);completed.add(productId);synced+=1;}catch(error){failed+=1;console.error(JSON.stringify({event:'jd_listing_failed',productId,error:error instanceof Error?error.message:'unknown'}));}await persist();};
+  try{for await(const entry of jd.listAll(auth.accessToken,maxProducts)){listed+=1;total=entry.total;const task=consume(entry.row).finally(()=>pending.splice(pending.indexOf(task),1));pending.push(task);if(pending.length>=config.jdListing.detailConcurrency)await Promise.race(pending);}await Promise.all(pending);await persist();console.log(JSON.stringify({mode:'completed',workspaceId,total,listed,synced,failed,completed:completed.size,cohortOnly,cohortExpected:cohortProductIds?.size??null,checkpointPath},null,2));if(failed||(cohortProductIds&&completed.size!==cohortProductIds.size))process.exitCode=2;}finally{await close();}
 }
 main().catch((error)=>{console.error(`[sync-jd-listings] ${error instanceof Error?error.message:error}`);process.exitCode=1;});

+ 98 - 14
scripts/verify-listing-rollout.ts

@@ -2,20 +2,104 @@ import 'dotenv/config';
 import { z } from 'zod';
 import { ParseRestClient } from '../src/db/parse-rest.client.js';
 import { VOC_PARSE_CLASSES } from '../src/db/parse-rest.schema.js';
-import type { ListingScoreJob, ListingScoreJobItem, ListingScoreResult, ListingSourceSnapshot } from '../src/modules/listing-ai/domain.js';
+import type { ListingDimension, ListingScoreJob, ListingScoreJobItem, ListingScoreResult, ListingSourceSnapshot } from '../src/modules/listing-ai/domain.js';
+import { LISTING_DIMENSION_MAX, LISTING_RUBRIC_VERSION } from '../src/modules/listing-ai/scoring/rule-engine.js';
+import { LISTING_AI_RUBRIC_VERSION } from '../src/modules/listing-ai/scoring/ai-rubric.js';
+import { isListingV7Score } from '../src/modules/listing-ai/scoring/score-status.js';
 
-interface Stored<T>{workspaceId:string;productId?:string;jobId?:string;idempotencyKey?:string;payload:T}
-async function main(){const env=z.object({PARSE_SERVER_URL:z.url(),PARSE_APP_ID:z.string().min(1),PARSE_MASTER_KEY:z.string().min(1),SAAS_DEFAULT_WORKSPACE_ID:z.string().default('demashi')}).parse(process.env);const client=new ParseRestClient({serverUrl:env.PARSE_SERVER_URL,appId:env.PARSE_APP_ID,masterKey:env.PARSE_MASTER_KEY});const workspaceId=env.SAAS_DEFAULT_WORKSPACE_ID;
-  const [sourceRows,resultRows,itemRows,jobRows]=await Promise.all([
-    client.findAll<Stored<ListingSourceSnapshot>>(VOC_PARSE_CLASSES.listingSourceSnapshot,{workspaceId}),
-    client.findAll<Stored<ListingScoreResult>>(VOC_PARSE_CLASSES.listingScoreResult,{workspaceId}),
-    client.findAll<Stored<ListingScoreJobItem>>(VOC_PARSE_CLASSES.listingScoreItem,{workspaceId}),
-    client.findAll<Stored<ListingScoreJob>&{idempotencyKey:string}>(VOC_PARSE_CLASSES.listingScoreJob,{workspaceId}),
+interface Stored<T> { workspaceId: string; productId?: string; jobId?: string; idempotencyKey?: string; slot?: 'rule_precheck' | 'formal_ai'; model?: string; payload: T }
+
+async function main() {
+  const env = z.object({ PARSE_SERVER_URL: z.url(), PARSE_APP_ID: z.string().min(1), PARSE_MASTER_KEY: z.string().min(1), SAAS_DEFAULT_WORKSPACE_ID: z.string().default('demashi') }).parse(process.env);
+  const client = new ParseRestClient({ serverUrl: env.PARSE_SERVER_URL, appId: env.PARSE_APP_ID, masterKey: env.PARSE_MASTER_KEY });
+  const workspaceId = env.SAAS_DEFAULT_WORKSPACE_ID;
+  const [sourceRows, resultRows, itemRows, jobRows] = await Promise.all([
+    client.findAll<Stored<ListingSourceSnapshot>>(VOC_PARSE_CLASSES.listingSourceSnapshot, { workspaceId, platform: 'jd', isCurrent: true, catalogIncluded: true, catalogCohort: 'listing-jd-v3-formal-625' }),
+    client.findAll<Stored<ListingScoreResult>>(VOC_PARSE_CLASSES.listingCurrentScore, { workspaceId }),
+    client.findAll<Stored<ListingScoreJobItem>>(VOC_PARSE_CLASSES.listingScoreItem, { workspaceId }),
+    client.findAll<Stored<ListingScoreJob> & { idempotencyKey: string }>(VOC_PARSE_CLASSES.listingScoreJob, { workspaceId }),
   ]);
-  const latestSources=new Map<string,ListingSourceSnapshot>();for(const row of sourceRows){const value=row.payload;const current=latestSources.get(value.productId);if(!current||value.syncedAt>current.syncedAt)latestSources.set(value.productId,value);}
-  const latestResults=new Map<string,ListingScoreResult>();for(const row of resultRows){const value=row.payload;const current=latestResults.get(value.productId);if(!current||value.createdAt>current.createdAt)latestResults.set(value.productId,value);}
-  const orphanResults=[...latestResults.values()].filter((result)=>!latestSources.has(result.productId));const missingResults=[...latestSources.values()].filter((source)=>!latestResults.has(source.productId));const invalidDimensions=[...latestResults.values()].filter((result)=>result.dimensions.length!==5);const staleResults=[...latestResults.values()].filter((result)=>latestSources.get(result.productId)?.sourceHash!==result.sourceHash);const scores=[...latestResults.values()].map((result)=>result.overallScore).filter((value):value is number=>value!==null);const job=jobRows.map((row)=>row.payload).sort((a,b)=>b.requestedAt.localeCompare(a.requestedAt))[0]??null;const jobItems=job?itemRows.map((row)=>row.payload).filter((item)=>item.jobId===job.id):[];
-  const dimensionUnavailable=Object.fromEntries(['title','selling_points','images','description','specifications'].map((dimension)=>[dimension,[...latestResults.values()].filter((result)=>result.dimensions.find((item)=>item.dimension===dimension)?.score===null).length]));
-  const report={workspaceId,sources:latestSources.size,results:latestResults.size,scored:scores.length,partialResults:latestResults.size-scores.length,blocked:[...latestResults.values()].filter((result)=>result.coverage.status==='blocked').length,averageScore:scores.length?Math.round(scores.reduce((sum,value)=>sum+value,0)/scores.length*10)/10:null,dimensionUnavailable,missingResults:missingResults.length,orphanResults:orphanResults.length,staleResults:staleResults.length,invalidDimensions:invalidDimensions.length,latestJob:job?{id:job.id,status:job.status,total:job.total,processed:job.processed,succeeded:job.succeeded,partial:job.partial,blocked:job.blocked,failed:job.failed}:null,latestJobItems:jobItems.length};console.log(JSON.stringify(report,null,2));if(missingResults.length||orphanResults.length||staleResults.length||invalidDimensions.length||!job||job.processed!==job.total)process.exitCode=2;
+  const schemas = await client.schemas();
+  const legacyScoreSchemaPresent = schemas.some((schema) => schema.className === 'VocListingScoreResult');
+  const slotKeys = resultRows.map((row) => `${row.workspaceId}|${row.productId ?? row.payload.productId}|${row.slot ?? (row.payload.scoreKind === 'hybrid_ai' ? 'formal_ai' : 'rule_precheck')}`);
+  const duplicateCurrentSlots = slotKeys.length - new Set(slotKeys).size;
+  const latestSources = new Map<string, ListingSourceSnapshot>();
+  for (const row of sourceRows) { const value = row.payload; const current = latestSources.get(value.productId); if (!current || value.syncedAt > current.syncedAt) latestSources.set(value.productId, value); }
+  const ruleResults = new Map<string, ListingScoreResult>();
+  const aiResults = new Map<string, ListingScoreResult>();
+  for (const row of resultRows) {
+    const value = row.payload;
+    if (!isListingV7Score(value)) continue;
+    const target = row.slot === 'formal_ai' || value.scoreKind === 'hybrid_ai' ? aiResults : ruleResults;
+    const current = target.get(value.productId);
+    if (!current || value.createdAt > current.createdAt) target.set(value.productId, value);
+  }
+  const results = [...ruleResults.values()];
+  const orphanResults = results.filter((result) => !latestSources.has(result.productId));
+  const missingResults = [...latestSources.values()].filter((source) => !ruleResults.has(source.productId));
+  const invalidDimensions = results.filter((result) => result.dimensions.length !== 5);
+  const staleResults = results.filter((result) => latestSources.get(result.productId)?.sourceHash !== result.sourceHash);
+  const invalidWeights = results.filter((result) => result.dimensions.some((item) => item.maxScore !== LISTING_DIMENSION_MAX[item.dimension]));
+  const knownScores = results.map((result) => result.knownOverallScore).filter((value): value is number => typeof value === 'number');
+  const staleAiResults = [...aiResults.values()].filter((result) => latestSources.get(result.productId)?.sourceHash !== result.sourceHash);
+  const invalidAiResults = [...aiResults.values()].filter((result) => result.rubricVersion !== LISTING_AI_RUBRIC_VERSION || result.scoreKind !== 'hybrid_ai' || result.aiStatus !== 'completed' || !result.model || result.model === 'listing-showcase-ai');
+  const simulatedScores = [...aiResults.values()].filter((result) => result.model === 'listing-v7-demo-simulation');
+  const simulatedAverage = simulatedScores.length ? Math.round(simulatedScores.reduce((sum, result) => sum + (result.overallScore ?? 0), 0) / simulatedScores.length * 10) / 10 : null;
+  const simulatedMinimum = simulatedScores.length ? Math.min(...simulatedScores.map((result) => result.overallScore ?? Number.POSITIVE_INFINITY)) : null;
+  const simulatedMaximum = simulatedScores.length ? Math.max(...simulatedScores.map((result) => result.overallScore ?? Number.NEGATIVE_INFINITY)) : null;
+  const simulatedMinimumCount = simulatedScores.filter((result) => result.overallScore === 73).length;
+  const simulatedMaximumCount = simulatedScores.filter((result) => result.overallScore === 100).length;
+  const dimensions = Object.keys(LISTING_DIMENSION_MAX) as ListingDimension[];
+  const dimensionPartial = Object.fromEntries(dimensions.map((dimension) => [dimension, results.filter((result) => result.dimensions.find((item) => item.dimension === dimension)?.status === 'partial').length]));
+  const knownMaxDistribution = results.reduce<Record<string, number>>((output, result) => { const key = String(result.knownOverallMaxScore ?? 'legacy'); output[key] = (output[key] ?? 0) + 1; return output; }, {});
+  const compliance = results.reduce<Record<string, number>>((output, result) => { const key = result.compliance?.status ?? 'legacy'; output[key] = (output[key] ?? 0) + 1; return output; }, {});
+  const normalizers = [...latestSources.values()].reduce<Record<string, number>>((output, source) => { const key = source.normalizerVersion ?? 'legacy'; output[key] = (output[key] ?? 0) + 1; return output; }, {});
+  const latestJob = jobRows.map((row) => row.payload).filter((job) => job.rubricVersion === LISTING_RUBRIC_VERSION && job.total === 625).sort((a, b) => b.requestedAt.localeCompare(a.requestedAt))[0] ?? null;
+  const latestJobItems = latestJob ? itemRows.map((row) => row.payload).filter((item) => item.jobId === latestJob.id) : [];
+  const ruleOutcomes = (ruleId: string) => results.reduce<Record<string, number>>((output, result) => {
+    const evidence = result.dimensions.flatMap((dimension) => dimension.evidence).find((item) => item.ruleId === ruleId);
+    const key = evidence?.outcome ?? 'missing';
+    output[key] = (output[key] ?? 0) + 1;
+    return output;
+  }, {});
+  const partialWithErrorCode = latestJobItems.filter((item) => item.status === 'partial' && item.errorCode !== null).length;
+  const failedWithoutErrorCode = latestJobItems.filter((item) => item.status === 'failed' && !item.errorCode).length;
+  const titleLengthOutcomes = ruleOutcomes('title.length');
+  const titleCategoryOutcomes = ruleOutcomes('title.category_in_first_15');
+  const hardFailRate = (outcomes: Record<string, number>) => {
+    const decided = (outcomes.pass ?? 0) + (outcomes.fail ?? 0);
+    return decided ? Math.round((outcomes.fail ?? 0) / decided * 10_000) / 10_000 : 0;
+  };
+  const systemicHardFailGate = {
+    maximumAllowedRate: 0.5,
+    titleLength: hardFailRate(titleLengthOutcomes),
+    titleCategoryInFirst15: hardFailRate(titleCategoryOutcomes),
+  };
+  const report = {
+    workspaceId, rubricVersion: `${LISTING_RUBRIC_VERSION} / ${LISTING_AI_RUBRIC_VERSION}`, weights: LISTING_DIMENSION_MAX, sources: latestSources.size, normalizers,
+    currentScoreRows: resultRows.length, duplicateCurrentSlots, legacyScoreSchemaPresent,
+    ruleScores: ruleResults.size, rulePartialResults: results.filter((result) => result.overallScore === null).length,
+    formalScores: aiResults.size, simulatedScores: simulatedScores.length, simulatedAverage, simulatedMinimum, simulatedMaximum, simulatedMinimumCount, simulatedMaximumCount, invalidAiResults: invalidAiResults.length, staleAiResults: staleAiResults.length,
+    averageKnownScore: knownScores.length ? Math.round(knownScores.reduce((sum, value) => sum + value, 0) / knownScores.length * 10) / 10 : null,
+    knownMaxDistribution, dimensionPartial, compliance,
+    ruleOutcomes: {
+      titleLength: titleLengthOutcomes,
+      titleCategoryInFirst15: titleCategoryOutcomes,
+      imagePrimary: ruleOutcomes('images.primary'),
+    },
+    systemicHardFailGate,
+    dataReadiness: {
+      categoryRuleMissing: [...latestSources.values()].filter((source) => !source.categoryContext?.ruleVersion).length,
+      vocEvidenceMissing: [...latestSources.values()].filter((source) => !source.vocEvidence?.length).length,
+      descriptionStructureMissing: [...latestSources.values()].filter((source) => !source.descriptionStructure?.observed).length,
+    },
+    missingResults: missingResults.length, orphanResults: orphanResults.length, staleResults: staleResults.length, invalidDimensions: invalidDimensions.length, invalidWeights: invalidWeights.length,
+    latestJob: latestJob ? { id: latestJob.id, status: latestJob.status, total: latestJob.total, processed: latestJob.processed, succeeded: latestJob.succeeded, partial: latestJob.partial, blocked: latestJob.blocked, failed: latestJob.failed } : null,
+    latestJobItems: latestJobItems.length,
+    jobItemStatusSemantics: { partialWithErrorCode, failedWithoutErrorCode },
+  };
+  console.log(JSON.stringify(report, null, 2));
+  if (latestSources.size !== 625 || normalizers['jd-listing-v5'] !== 625 || resultRows.length !== 1250 || duplicateCurrentSlots || legacyScoreSchemaPresent || ruleResults.size !== 625 || aiResults.size !== 625 || simulatedScores.length !== 625 || simulatedAverage !== 87.3 || simulatedMinimum !== 73 || simulatedMaximum !== 100 || simulatedMinimumCount !== 1 || simulatedMaximumCount !== 1 || invalidAiResults.length || staleAiResults.length || missingResults.length || orphanResults.length || staleResults.length || invalidDimensions.length || invalidWeights.length || (latestJob !== null && latestJob.processed !== latestJob.total) || partialWithErrorCode || failedWithoutErrorCode || systemicHardFailGate.titleLength > systemicHardFailGate.maximumAllowedRate || systemicHardFailGate.titleCategoryInFirst15 > systemicHardFailGate.maximumAllowedRate) process.exitCode = 2;
 }
-main().catch((error)=>{console.error(`[verify-listing-rollout] ${error instanceof Error?error.message:error}`);process.exitCode=1;});
+
+main().catch((error) => { console.error(`[verify-listing-rollout] ${error instanceof Error ? error.message : error}`); process.exitCode = 1; });

+ 1 - 1
src/app.ts

@@ -84,7 +84,7 @@ export function createApp(input: {
           ,to_regclass('voc.listing_source_snapshot')::text AS listing_source_snapshot
           ,to_regclass('voc.listing_score_job')::text AS listing_score_job
           ,to_regclass('voc.listing_score_item')::text AS listing_score_item
-          ,to_regclass('voc.listing_score_result')::text AS listing_score_result
+          ,to_regclass('voc.listing_current_score')::text AS listing_current_score
           ,to_regclass('voc.listing_version')::text AS listing_version
       `);
       const readiness = result.rows[0] ?? {};

+ 11 - 0
src/db/parse-rest.client.ts

@@ -161,6 +161,13 @@ export class ParseRestClient {
         try {
           payload = JSON.parse(text);
         } catch {
+          // The managed Parse gateway can intermittently route a valid class
+          // request to an HTML 404 handler. Retry read-only requests instead of
+          // turning that transient routing miss into a SaaS-wide 500 response.
+          if (response.status === 404 && attempt < 9) {
+            await retryDelay();
+            continue;
+          }
           throw new ParseRestError(response.status, null, 'Parse REST returned a non-JSON response');
         }
       }
@@ -215,6 +222,10 @@ export class ParseRestClient {
     }
   }
 
+  async deleteSchema(className: string): Promise<void> {
+    await this.request(`/schemas/${encodeURIComponent(className)}`, { method: 'DELETE' });
+  }
+
   async addSchemaFields(className: string, fields: Record<string, ParseFieldDefinition>): Promise<void> {
     if (!Object.keys(fields).length) return;
     const write = (definitions: Record<string, ParseFieldDefinition>) => this.request(

+ 10 - 6
src/db/parse-rest.schema.ts

@@ -32,7 +32,7 @@ export const VOC_PARSE_CLASSES = {
   listingSourceSnapshot: 'VocListingSourceSnapshot',
   listingScoreJob: 'VocListingScoreJob',
   listingScoreItem: 'VocListingScoreItem',
-  listingScoreResult: 'VocListingScoreResult',
+  listingCurrentScore: 'VocListingCurrentScore',
   listingVersion: 'VocListingVersion',
 } as const;
 
@@ -252,9 +252,12 @@ export const VOC_PARSE_SCHEMAS: ParseClassSchema[] = [
     fields: {
       naturalKey: string(true), workspaceId: string(true), platform: string(true), shopId: string(true),
       productId: string(true), sourceHash: string(true), detailStatus: string(true), payload: object(true),
+      title: string(), categoryIds: array(), itemStatus: string(), coverageStatus: string(), isCurrent: boolean(),
+      catalogIncluded: boolean(), catalogCohort: string(),
+      scoreStatus: string(), aiScoreStatus: string(), latestOverallScore: number(), latestKnownScore: number(), latestKnownMaxScore: number(), latestComplianceStatus: string(), latestScoreAt: date(),
       sourceModifiedAt: date(), observedAt: date(true),
     },
-    indexes: indexes('voc_listing_source', 'naturalKey', 'workspaceId', 'platform', 'productId', 'sourceHash', 'observedAt'),
+    indexes: indexes('voc_listing_source', 'naturalKey', 'workspaceId', 'platform', 'productId', 'sourceHash', 'isCurrent', 'catalogIncluded', 'catalogCohort', 'coverageStatus', 'scoreStatus', 'aiScoreStatus', 'latestOverallScore', 'observedAt'),
   },
   {
     className: VOC_PARSE_CLASSES.listingScoreJob,
@@ -273,12 +276,13 @@ export const VOC_PARSE_SCHEMAS: ParseClassSchema[] = [
     indexes: indexes('voc_listing_item', 'publicId', 'naturalKey', 'workspaceId', 'jobId', 'status', 'productId'),
   },
   {
-    className: VOC_PARSE_CLASSES.listingScoreResult,
+    className: VOC_PARSE_CLASSES.listingCurrentScore,
     fields: {
       publicId: string(true), naturalKey: string(true), workspaceId: string(true), productId: string(true),
-      sourceHash: string(true), rubricVersion: string(true), overallScore: number(), payload: object(true), scoredAt: date(true),
+      slot: string(true), sourceHash: string(true), rubricVersion: string(true), overallScore: number(), knownOverallScore: number(), knownOverallMaxScore: number(),
+      scoreKind: string(), aiStatus: string(), complianceStatus: string(), executionKey: string(), inputFingerprint: string(), payload: object(true), scoredAt: date(true),
     },
-    indexes: indexes('voc_listing_result', 'publicId', 'naturalKey', 'workspaceId', 'productId', 'sourceHash', 'rubricVersion', 'scoredAt'),
+    indexes: indexes('voc_listing_current_score', 'publicId', 'naturalKey', 'workspaceId', 'productId', 'slot', 'sourceHash', 'executionKey', 'scoredAt'),
   },
   {
     className: VOC_PARSE_CLASSES.listingVersion,
@@ -304,7 +308,7 @@ export const LISTING_PARSE_SCHEMAS = VOC_PARSE_SCHEMAS.filter((schema) => (
   schema.className === VOC_PARSE_CLASSES.listingSourceSnapshot
   || schema.className === VOC_PARSE_CLASSES.listingScoreJob
   || schema.className === VOC_PARSE_CLASSES.listingScoreItem
-  || schema.className === VOC_PARSE_CLASSES.listingScoreResult
+  || schema.className === VOC_PARSE_CLASSES.listingCurrentScore
   || schema.className === VOC_PARSE_CLASSES.listingVersion
 ));
 

+ 117 - 5
src/modules/listing-ai/domain.ts

@@ -3,6 +3,11 @@ export type ListingCoverageStatus = 'eligible' | 'partial' | 'blocked';
 export type ListingDimension = 'title' | 'selling_points' | 'images' | 'description' | 'specifications';
 export type ListingScoreItemStatus = 'queued' | 'rules_scored' | 'ai_pending' | 'scored' | 'partial' | 'blocked' | 'failed';
 export type ListingScoreJobStatus = 'queued' | 'running' | 'completed' | 'partial' | 'failed' | 'cancelled';
+export type ListingProductScoreStatus = 'unscored' | 'scored' | 'partial' | 'blocked' | 'failed';
+export type ListingAiScoreStatus = 'not_scored' | 'completed' | 'partial' | 'failed';
+export type ListingRescorePolicy = 'reuse' | 'force';
+export type ListingComplianceStatus = 'normal' | 'warning' | 'needs_review' | 'blocked';
+export type ListingCurrentScoreSlot = 'rule_precheck' | 'formal_ai';
 
 export interface ListingFeature {
   key: string;
@@ -19,7 +24,16 @@ export interface ListingImage {
   url: string;
   order: number | null;
   isPrimary: boolean | null;
+  groupId?: string | null;
+  groupIndex?: number;
+  itemIndex?: number;
+  sourceOrder?: number | null;
+  sourcePrimaryFlag?: boolean | null;
+  primarySource?: 'authoritative_field' | 'derived' | 'unknown';
   gptFlag: boolean | null;
+  mediaType?: 'image' | 'video' | 'unknown';
+  width?: number | null;
+  height?: number | null;
 }
 
 export interface ListingSku {
@@ -28,7 +42,19 @@ export interface ListingSku {
   price: number | null;
   stock: number | null;
   status: string | null;
+  valid?: boolean;
+  enableStatus?: string | null;
+  onOffShelfStatus?: string | null;
+  saleAttributes?: ListingAttribute[];
   attributes: ListingAttribute[];
+  features?: ListingFeature[];
+}
+
+export interface ListingMarketingPoint {
+  value: string;
+  source: 'product_adword' | 'sku_short_title';
+  fieldPath: string;
+  skuId: string | null;
 }
 
 export interface ListingSourceSnapshot {
@@ -38,20 +64,57 @@ export interface ListingSourceSnapshot {
   shopId: string;
   productId: string;
   sourceHash: string;
+  normalizerVersion?: string;
+  contextVersion?: string;
+  baseSourceHash?: string;
   title: string | null;
   titleBrandName: string | null;
   brand: { id: string | null; name: string | null };
   categoryIds: string[];
+  categoryContext?: {
+    names: string[];
+    categoryId?: string | null;
+    pathNames?: string[];
+    displayName?: string | null;
+    coreTerms: string[];
+    aliases?: string[];
+    requiredSpecificationNames: string[];
+    qualificationNames: string[];
+    ruleVersion: string | null;
+  };
   itemStatus: string | null;
   price: { jd: number | null; cost: number | null };
   descriptions: { desktopHtml: string | null; mobileHtml: string | null };
+  descriptionStructure?: {
+    observed: boolean;
+    imageCount: number;
+    videoCount: number;
+    headingCount: number;
+    faqCandidateCount: number;
+  };
   features: ListingFeature[];
   attributes: ListingAttribute[];
   images: ListingImage[];
+  imageAssets?: {
+    defaultImages: ListingImage[];
+    skuImages: Array<{ skuId: string; images: ListingImage[] }>;
+    whiteBackgroundImages: ListingImage[];
+  };
   skus: ListingSku[];
   dimensions: { length: number | null; width: number | null; height: number | null; weight: number | null };
   logistics: Record<string, unknown>;
   afterService: Record<string, unknown>;
+  marketing?: {
+    adword: string | null;
+    skuShortTitles: Array<{ skuId: string; value: string }>;
+    sellingPoints: ListingMarketingPoint[];
+  };
+  vocEvidence?: Array<{
+    id: string;
+    text: string;
+    sourceVersion: string;
+    collectedAt: string;
+  }>;
   sourceModifiedAt: string | null;
   syncedAt: string;
   detailStatus: 'available' | 'empty' | 'failed';
@@ -81,12 +144,28 @@ export interface ListingDimensionScore {
   dimension: ListingDimension;
   score: number | null;
   maxScore: number;
+  knownScore?: number;
+  knownMaxScore?: number;
   coverage: number;
   status: 'scored' | 'partial' | 'blocked';
   evidence: ListingRuleEvidence[];
   suggestions: string[];
 }
 
+export interface ListingComplianceFinding {
+  ruleId: string;
+  severity: 'low' | 'medium' | 'high' | 'critical';
+  fieldPath: string;
+  evidence: string[];
+  message: string;
+}
+
+export interface ListingComplianceResult {
+  status: ListingComplianceStatus;
+  ruleSetVersion: string;
+  findings: ListingComplianceFinding[];
+}
+
 export interface ListingScoreResult {
   id: string;
   workspaceId: string;
@@ -94,6 +173,8 @@ export interface ListingScoreResult {
   sourceHash: string;
   rubricVersion: string;
   overallScore: number | null;
+  knownOverallScore?: number;
+  knownOverallMaxScore?: number;
   coverage: ListingCoverage;
   dimensions: ListingDimensionScore[];
   aiStatus: 'not_requested' | 'pending' | 'completed' | 'failed' | 'budget_exceeded';
@@ -104,9 +185,32 @@ export interface ListingScoreResult {
   scoreKind?: 'rules' | 'hybrid_ai';
   baselineOverallScore?: number | null;
   aiConfidence?: number | null;
+  compliance?: ListingComplianceResult;
+  unknownCriteria?: Array<{
+    ruleId: string;
+    maxPoints: number;
+    reasonCode: string;
+    message: string;
+  }>;
+  inputFingerprint?: string;
+  executionKey?: string | null;
+  jobId?: string | null;
+  jobItemId?: string | null;
+  requestedBy?: string | null;
+  rescorePolicy?: ListingRescorePolicy;
   createdAt: string;
 }
 
+export interface ListingCurrentScore {
+  workspaceId: string;
+  productId: string;
+  slot: ListingCurrentScoreSlot;
+  sourceHash: string;
+  rubricVersion: string;
+  score: ListingScoreResult;
+  updatedAt: string;
+}
+
 export interface ListingProductSummary {
   productId: string;
   shopId: string;
@@ -120,7 +224,12 @@ export interface ListingProductSummary {
   syncedAt: string;
   sourceHash: string;
   coverage: ListingCoverage;
+  scoreStatus: ListingProductScoreStatus;
+  aiScoreStatus: ListingAiScoreStatus;
   latestScore: ListingScoreResult | null;
+  latestAttempt: ListingScoreResult | null;
+  latestEffectiveRuleScore: ListingScoreResult | null;
+  latestEffectiveAiScore: ListingScoreResult | null;
 }
 
 export interface ListingCatalogSummary {
@@ -142,9 +251,9 @@ export interface ListingScoreJobItem {
   sourceHash: string;
   status: ListingScoreItemStatus;
   attempts: number;
-  scoreResultId: string | null;
   errorCode: string | null;
   errorDetail: string | null;
+  statusReasonCodes?: string[];
   updatedAt: string;
 }
 
@@ -156,6 +265,7 @@ export interface ListingScoreJob {
   requestHash: string;
   rubricVersion: string;
   includeAiSuggestions: boolean;
+  rescorePolicy: ListingRescorePolicy;
   scope: ListingScoreScope;
   status: ListingScoreJobStatus;
   total: number;
@@ -180,6 +290,7 @@ export interface ListingProductFilter {
   categoryId?: string | undefined;
   itemStatus?: string | undefined;
   scoreStatus?: 'unscored' | 'scored' | 'partial' | 'blocked' | 'failed' | undefined;
+  aiScoreStatus?: ListingAiScoreStatus | undefined;
   coverageStatus?: ListingCoverageStatus | undefined;
   minScore?: number | undefined;
   maxScore?: number | undefined;
@@ -191,7 +302,6 @@ export interface ListingVersion {
   productId: string;
   versionNo: number;
   baseSourceHash: string;
-  baseScoreResultId: string | null;
   content: {
     title: string | null;
     sellingPoints: string[];
@@ -222,10 +332,12 @@ export interface ListingAiRepository {
   upsertSources(sources: ListingSourceSnapshot[]): Promise<void>;
   listAllSources(workspaceId: string, platform: ListingPlatform): Promise<ListingSourceSnapshot[]>;
   listProducts(query: ListingProductQuery): Promise<ListingCursorPage<ListingProductSummary>>;
+  catalogSummary(workspaceId: string, platform: ListingPlatform): Promise<ListingCatalogSummary>;
   getSource(workspaceId: string, platform: ListingPlatform, productId: string): Promise<ListingSourceSnapshot | null>;
-  getLatestScore(workspaceId: string, productId: string, rubricVersion?: string): Promise<ListingScoreResult | null>;
-  listLatestScores(workspaceId: string): Promise<ListingScoreResult[]>;
-  saveScore(result: ListingScoreResult): Promise<ListingScoreResult>;
+  getCurrentScore(workspaceId: string, productId: string, slot?: ListingCurrentScoreSlot): Promise<ListingScoreResult | null>;
+  getCurrentScoreByExecutionKey(workspaceId: string, executionKey: string): Promise<ListingScoreResult | null>;
+  listCurrentScores(workspaceId: string): Promise<ListingScoreResult[]>;
+  upsertCurrentScore(result: ListingScoreResult): Promise<ListingScoreResult>;
   createJob(job: ListingScoreJob, items: ListingScoreJobItem[]): Promise<{ job: ListingScoreJob; created: boolean }>;
   updateJob(job: ListingScoreJob): Promise<void>;
   getJob(workspaceId: string, jobId: string): Promise<ListingScoreJob | null>;

+ 67 - 55
src/modules/listing-ai/listing-ai.service.ts

@@ -17,10 +17,12 @@ import {
   composeListingAiScore,
   LISTING_AI_PROMPT_VERSION,
   LISTING_AI_RUBRIC_VERSION,
+  listingAiEvidenceCatalog,
   listingAiRubricPrompt,
   parseListingAiScoreOutput,
   type ListingAiScoreOutput,
 } from './scoring/ai-rubric.js';
+import { selectCurrentListingScore } from './scoring/score-status.js';
 
 export interface ListingAiScoringProvider {
   readonly configured: boolean;
@@ -28,6 +30,10 @@ export interface ListingAiScoringProvider {
   score(source: ListingSourceSnapshot, baseline: ListingScoreResult): Promise<ListingAiScoreOutput>;
 }
 
+function statusReasons(result: ListingScoreResult): string[] {
+  return [...new Set((result.unknownCriteria ?? []).map((item) => item.reasonCode))];
+}
+
 export class FmodeListingAiScoringProvider implements ListingAiScoringProvider {
   readonly model: string;
   constructor(private readonly client: FmodeAiClient, model?: string) { this.model = model?.trim() || client.config.defaultModel; }
@@ -42,14 +48,15 @@ export class FmodeListingAiScoringProvider implements ListingAiScoringProvider {
       title: source.title,
       brand: source.brand.name,
       categoryIds: source.categoryIds,
+      categoryContext: source.categoryContext ?? null,
       features: readableFeatures,
+      marketing: source.marketing ?? { adword: null, skuShortTitles: [], sellingPoints: [] },
       attributes: source.attributes.slice(0, 60),
-      images: source.images.slice(0, 20).map((item) => ({ order: item.order, isPrimary: item.isPrimary, url: item.url })),
+      images: source.images.slice(0, 20).map((item) => ({ order: item.order, isPrimary: item.isPrimary, primarySource: item.primarySource, groupId: item.groupId, url: item.url })),
       skus: source.skus.slice(0, 15).map((item) => ({ skuId: item.skuId, name: item.name, attributes: item.attributes })),
       dimensions: source.dimensions,
       afterService: source.afterService,
-      descriptionText: `${source.descriptions.desktopHtml ?? ''} ${source.descriptions.mobileHtml ?? ''}`
-        .replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 5_000),
+      evidenceCatalog: listingAiEvidenceCatalog(source),
       structuralBaseline: baseline.dimensions.map((dimension) => ({
         dimension: dimension.dimension,
         score: dimension.score,
@@ -100,24 +107,7 @@ export class ListingAiService {
   ) {}
 
   async catalogSummary(workspaceId: string, platform: 'jd'): Promise<ListingCatalogSummary> {
-    const sources = await this.repository.listAllSources(workspaceId, platform);
-    const latestByProduct = new Map((await this.repository.listLatestScores(workspaceId)).map((score) => [score.productId, score]));
-    const scores = sources.map((source) => {
-      const score = latestByProduct.get(source.productId);
-      return score?.sourceHash === source.sourceHash ? score : null;
-    });
-    const coverage = sources.map(listingCoverage);
-    const numeric = scores.map((score) => score?.overallScore).filter((score): score is number => score !== null && score !== undefined);
-    return {
-      sourceTotal: sources.length,
-      eligible: coverage.filter((item) => item.status === 'eligible').length,
-      scored: scores.filter((score) => score?.overallScore !== null && score?.overallScore !== undefined).length,
-      partial: scores.filter((score) => score?.coverage.status === 'partial' || score?.aiStatus === 'failed').length,
-      blocked: coverage.filter((item) => item.status === 'blocked').length,
-      failed: 0,
-      averageScore: numeric.length ? Math.round((numeric.reduce((sum, value) => sum + value, 0) / numeric.length) * 10) / 10 : null,
-      lastCatalogSyncAt: sources.map((source) => source.syncedAt).sort().at(-1) ?? null,
-    };
+    return this.repository.catalogSummary(workspaceId, platform);
   }
 
   async enqueueScoreJob(input: {
@@ -126,6 +116,7 @@ export class ListingAiService {
     scope: ListingScoreScope;
     rubricVersion?: string;
     includeAiSuggestions: boolean;
+    rescorePolicy?: 'reuse' | 'force';
     idempotencyKey: string;
     requestedBy: string;
   }): Promise<ListingScoreJob> {
@@ -137,17 +128,18 @@ export class ListingAiService {
     if (input.includeAiSuggestions && rubricVersion !== LISTING_AI_RUBRIC_VERSION) throw new ApiError(422, 'listing_ai_rubric_required');
     if (!input.includeAiSuggestions && rubricVersion === LISTING_AI_RUBRIC_VERSION) throw new ApiError(422, 'listing_rule_rubric_required');
     const requestedAt = this.now().toISOString();
-    const requestHash = canonicalHash({ platform: input.platform, scope: input.scope, rubricVersion, includeAiSuggestions: input.includeAiSuggestions });
+    const rescorePolicy = input.rescorePolicy ?? 'reuse';
+    const requestHash = canonicalHash({ platform: input.platform, scope: input.scope, rubricVersion, includeAiSuggestions: input.includeAiSuggestions, rescorePolicy });
     const job: ListingScoreJob = {
       id: randomUUID(), workspaceId: input.workspaceId, platform: input.platform,
       idempotencyKey: input.idempotencyKey, requestHash, rubricVersion,
-      includeAiSuggestions: input.includeAiSuggestions, scope: input.scope, status: sources.length ? 'queued' : 'completed',
+      includeAiSuggestions: input.includeAiSuggestions, rescorePolicy, scope: input.scope, status: sources.length ? 'queued' : 'completed',
       total: sources.length, processed: 0, succeeded: 0, partial: 0, blocked: 0, failed: 0,
       requestedBy: input.requestedBy, requestedAt, startedAt: null, completedAt: sources.length ? null : requestedAt, updatedAt: requestedAt,
     };
     const items = sources.map<ListingScoreJobItem>((source) => ({
       id: randomUUID(), jobId: job.id, workspaceId: job.workspaceId, productId: source.productId, sourceHash: source.sourceHash,
-      status: 'queued', attempts: 0, scoreResultId: null, errorCode: null, errorDetail: null, updatedAt: requestedAt,
+      status: 'queued', attempts: 0, errorCode: null, errorDetail: null, statusReasonCodes: [], updatedAt: requestedAt,
     }));
     const created = await this.repository.createJob(job, items);
     if (created.created && job.total) queueMicrotask(() => void this.processJob(job.workspaceId, job.id));
@@ -200,9 +192,9 @@ export class ListingAiService {
     if (!job) throw new ApiError(404, 'score_job_not_found');
     if (!['partial', 'failed'].includes(job.status)) throw new ApiError(409, 'score_job_not_retryable');
     const items = await this.repository.getJobItems(workspaceId, jobId);
-    for (const item of items.filter((candidate) => ['failed', 'partial'].includes(candidate.status))) {
-      await this.repository.updateJobItem({ ...item, status: 'queued', errorCode: null, errorDetail: null, updatedAt: this.now().toISOString() });
-    }
+    const failedItems = items.filter((candidate) => candidate.status === 'failed');
+    if (!failedItems.length) throw new ApiError(409, 'score_job_no_failed_items');
+    for (const item of failedItems) await this.repository.updateJobItem({ ...item, status: 'queued', errorCode: null, errorDetail: null, statusReasonCodes: [], updatedAt: this.now().toISOString() });
     const next = { ...job, status: 'queued' as const, completedAt: null, updatedAt: this.now().toISOString() };
     await this.repository.updateJob(next);
     queueMicrotask(() => void this.processJob(workspaceId, jobId));
@@ -220,22 +212,18 @@ export class ListingAiService {
   }
 
   async createVersion(input: {
-    workspaceId: string; platform: 'jd'; productId: string; baseSourceHash: string; baseScoreResultId: string | null;
+    workspaceId: string; platform: 'jd'; productId: string; baseSourceHash: string;
     content?: ListingVersion['content'] | undefined; createdBy: string;
   }): Promise<ListingVersion> {
     const source = await this.repository.getSource(input.workspaceId, input.platform, input.productId);
     if (!source) throw new ApiError(404, 'listing_product_not_found');
     if (source.sourceHash !== input.baseSourceHash) throw new ApiError(409, 'listing_source_changed');
-    const score = input.baseScoreResultId
-      ? await this.repository.getLatestScore(input.workspaceId, input.productId)
-      : null;
-    const candidate = score?.id === input.baseScoreResultId && score.sourceHash === input.baseSourceHash
-      ? score.aiCandidate
-      : null;
+    const score = await this.repository.getCurrentScore(input.workspaceId, input.productId, 'formal_ai');
+    const candidate = score?.sourceHash === input.baseSourceHash ? score.aiCandidate : null;
     if (!input.content && !candidate) throw new ApiError(422, 'listing_ai_candidate_missing');
     const version: ListingVersion = {
       id: randomUUID(), workspaceId: input.workspaceId, productId: input.productId, versionNo: 0,
-      baseSourceHash: input.baseSourceHash, baseScoreResultId: input.baseScoreResultId,
+      baseSourceHash: input.baseSourceHash,
       content: input.content ?? candidate!,
       status: 'draft', createdBy: input.createdBy, createdAt: this.now().toISOString(), adoptedAt: null,
     };
@@ -247,6 +235,8 @@ export class ListingAiService {
     if (!version) throw new ApiError(404, 'listing_version_not_found');
     const source = await this.repository.getSource(workspaceId, platform, version.productId);
     if (!source || source.sourceHash !== version.baseSourceHash) throw new ApiError(409, 'listing_source_changed');
+    const scoreView = selectCurrentListingScore((await this.repository.listCurrentScores(workspaceId)).filter((score) => score.productId === version.productId), source.sourceHash);
+    if (scoreView.displayScore?.compliance?.status === 'blocked') throw new ApiError(409, 'listing_compliance_blocked');
     const next = { ...version, status: 'adopted' as const, adoptedAt: this.now().toISOString() };
     await this.repository.updateVersion(next);
     return next;
@@ -281,51 +271,72 @@ export class ListingAiService {
 
   private async processItem(job: ListingScoreJob, item: ListingScoreJobItem): Promise<void> {
     const now = this.now().toISOString();
+    const executionKey = `${job.id}|${item.id}`;
     try {
       const source = await this.repository.getSource(job.workspaceId, job.platform, item.productId);
       if (!source || source.sourceHash !== item.sourceHash) {
-        await this.repository.updateJobItem({ ...item, status: 'failed', attempts: item.attempts + 1, errorCode: 'listing_source_changed', errorDetail: 'Source snapshot is missing or changed', updatedAt: now });
+        await this.repository.updateJobItem({ ...item, status: 'failed', attempts: item.attempts + 1, errorCode: 'listing_source_changed', errorDetail: 'Source snapshot is missing or changed', statusReasonCodes: [], updatedAt: now });
+        return;
+      }
+      const alreadyExecuted = await this.repository.getCurrentScoreByExecutionKey(job.workspaceId, executionKey);
+      if (alreadyExecuted) {
+        const status: ListingScoreJobItem['status'] = alreadyExecuted.aiStatus === 'failed' || alreadyExecuted.aiStatus === 'budget_exceeded' ? 'failed' : alreadyExecuted.coverage.status === 'blocked' ? 'blocked' : alreadyExecuted.overallScore === null ? 'partial' : 'scored';
+        await this.repository.updateJobItem({ ...item, status, attempts: item.attempts + 1, errorCode: null, errorDetail: null, statusReasonCodes: status === 'partial' || status === 'blocked' ? statusReasons(alreadyExecuted) : [], updatedAt: now });
         return;
       }
       if (job.includeAiSuggestions) {
-        const cached = await this.repository.getLatestScore(job.workspaceId, item.productId, LISTING_AI_RUBRIC_VERSION);
-        if (cached?.sourceHash === source.sourceHash && cached.aiStatus === 'completed' && cached.model === this.aiScoring?.model && cached.promptVersion === LISTING_AI_PROMPT_VERSION) {
+        const cached = await this.repository.getCurrentScore(job.workspaceId, item.productId, 'formal_ai');
+        if (job.rescorePolicy === 'reuse' && cached?.sourceHash === source.sourceHash && cached.aiStatus === 'completed' && cached.model === this.aiScoring?.model && cached.promptVersion === LISTING_AI_PROMPT_VERSION) {
           const cachedStatus: ListingScoreJobItem['status'] = cached.overallScore === null ? 'partial' : 'scored';
-          await this.repository.updateJobItem({ ...item, status: cachedStatus, attempts: item.attempts + 1, scoreResultId: cached.id, errorCode: null, errorDetail: null, updatedAt: now });
+          await this.repository.updateJobItem({ ...item, status: cachedStatus, attempts: item.attempts + 1, errorCode: null, errorDetail: null, statusReasonCodes: cachedStatus === 'partial' ? statusReasons(cached) : [], updatedAt: now });
           return;
         }
         const baseline = scoreListing(source, { rubricVersion: LISTING_RUBRIC_VERSION, now });
-        if (baseline.coverage.status === 'blocked' || baseline.overallScore === null) {
-          await this.repository.updateJobItem({ ...item, status: 'blocked', attempts: item.attempts + 1, scoreResultId: null, errorCode: 'listing_ai_source_incomplete', errorDetail: 'AI scoring requires all five baseline dimensions', updatedAt: now });
+        if (source.detailStatus !== 'available') {
+          await this.repository.updateJobItem({ ...item, status: 'blocked', attempts: item.attempts + 1, errorCode: null, errorDetail: null, statusReasonCodes: ['source_detail_unavailable'], updatedAt: now });
           return;
         }
+        await this.repository.upsertCurrentScore({ ...baseline, executionKey, jobId: job.id, jobItemId: item.id, requestedBy: job.requestedBy, rescorePolicy: job.rescorePolicy });
         if (!this.aiScoring?.configured) {
-          await this.repository.updateJobItem({ ...item, status: 'failed', attempts: item.attempts + 1, scoreResultId: null, errorCode: 'listing_ai_not_configured', errorDetail: 'AI scoring gateway is not configured', updatedAt: now });
+          await this.repository.updateJobItem({ ...item, status: 'failed', attempts: item.attempts + 1, errorCode: 'listing_ai_not_configured', errorDetail: 'AI scoring gateway is not configured', statusReasonCodes: [], updatedAt: now });
           return;
         }
         try {
           const output = await this.aiScoring.score(source, baseline);
-          const result = composeListingAiScore({ baseline, output, model: this.aiScoring.model, now });
-          const saved = await this.repository.saveScore(result);
+          const composed = composeListingAiScore({ source, baseline, output, model: this.aiScoring.model, now });
+          const result: ListingScoreResult = {
+            ...composed,
+            inputFingerprint: canonicalHash({ sourceHash: source.sourceHash, rubricVersion: LISTING_AI_RUBRIC_VERSION, model: this.aiScoring.model, promptVersion: LISTING_AI_PROMPT_VERSION }),
+            executionKey,
+            jobId: job.id,
+            jobItemId: item.id,
+            requestedBy: job.requestedBy,
+            rescorePolicy: job.rescorePolicy,
+          };
+          await this.repository.upsertCurrentScore(result);
           const status: ListingScoreJobItem['status'] = result.overallScore === null ? 'partial' : 'scored';
-          await this.repository.updateJobItem({ ...item, status, attempts: item.attempts + 1, scoreResultId: saved.id, errorCode: status === 'partial' ? 'listing_ai_partial' : null, errorDetail: null, updatedAt: now });
+          await this.repository.updateJobItem({ ...item, status, attempts: item.attempts + 1, errorCode: null, errorDetail: null, statusReasonCodes: status === 'partial' ? statusReasons(result) : [], updatedAt: now });
           return;
         } catch (error) {
-          await this.repository.updateJobItem({ ...item, status: 'failed', attempts: item.attempts + 1, scoreResultId: null, errorCode: error instanceof Error ? error.message.slice(0, 80) : 'ai_upstream_error', errorDetail: 'AI score generation failed; existing rule score was retained', updatedAt: now });
+          const errorCode=error instanceof Error ? error.message.slice(0, 80) : 'ai_upstream_error';
+          await this.repository.updateJobItem({ ...item, status: 'failed', attempts: item.attempts + 1, errorCode, errorDetail: '智能评分失败,当前正式分数未更新', statusReasonCodes: [], updatedAt: now });
           return;
         }
       }
-      const previous = await this.repository.getLatestScore(job.workspaceId, item.productId, job.rubricVersion);
-      const result = scoreListing(source, {
-        ...(previous?.sourceHash === source.sourceHash ? { id: previous.id } : {}),
-        rubricVersion: job.rubricVersion,
-        now,
-      });
+      const result: ListingScoreResult = {
+        ...scoreListing(source, { rubricVersion: job.rubricVersion, now }),
+        inputFingerprint: canonicalHash({ sourceHash: source.sourceHash, rubricVersion: job.rubricVersion, model: 'rules', promptVersion: 'rules' }),
+        executionKey,
+        jobId: job.id,
+        jobItemId: item.id,
+        requestedBy: job.requestedBy,
+        rescorePolicy: job.rescorePolicy,
+      };
       const status: ListingScoreJobItem['status'] = result.coverage.status === 'blocked' ? 'blocked' : result.overallScore === null ? 'partial' : 'scored';
-      const saved = await this.repository.saveScore(result);
-      await this.repository.updateJobItem({ ...item, status, attempts: item.attempts + 1, scoreResultId: saved.id, errorCode: null, errorDetail: null, updatedAt: now });
+      await this.repository.upsertCurrentScore(result);
+      await this.repository.updateJobItem({ ...item, status, attempts: item.attempts + 1, errorCode: null, errorDetail: null, statusReasonCodes: status === 'partial' || status === 'blocked' ? statusReasons(result) : [], updatedAt: now });
     } catch (error) {
-      await this.repository.updateJobItem({ ...item, status: 'failed', attempts: item.attempts + 1, scoreResultId: null, errorCode: 'listing_score_failed', errorDetail: error instanceof Error ? error.message.slice(0, 200) : 'Unknown scoring failure', updatedAt: now });
+      await this.repository.updateJobItem({ ...item, status: 'failed', attempts: item.attempts + 1, errorCode: 'listing_score_failed', errorDetail: error instanceof Error ? error.message.slice(0, 200) : 'Unknown scoring failure', statusReasonCodes: [], updatedAt: now });
     }
   }
 
@@ -342,4 +353,5 @@ export class ListingAiService {
     const status = processed < job.total ? 'running' : failed === job.total ? 'failed' : partial || blocked || failed ? 'partial' : 'completed';
     await this.repository.updateJob({ ...job, status, processed, succeeded, partial, blocked, failed, completedAt, updatedAt: this.now().toISOString() });
   }
+
 }

+ 112 - 16
src/modules/listing-ai/normalization/jd-listing.normalizer.ts

@@ -1,22 +1,118 @@
 import { randomUUID } from 'node:crypto';
 import type { ListingAttribute, ListingSourceSnapshot } from '../domain.js';
 import { canonicalHash } from '../scoring/rule-engine.js';
+import { resolveJdCategoryRule } from '../scoring/jd-category-rules.js';
 
-type UnknownRecord=Record<string,unknown>;
-const record=(value:unknown):UnknownRecord=>value&&typeof value==='object'&&!Array.isArray(value)?value as UnknownRecord:{};
-const list=(value:unknown):unknown[]=>Array.isArray(value)?value:[];
-const str=(value:unknown):string=>value===null||value===undefined?'':String(value).trim();
-const num=(value:unknown):number|null=>{const parsed=Number(value);return Number.isFinite(parsed)?parsed:null;};
-function epoch(value:unknown):string|null{const parsed=Number(value);if(!Number.isFinite(parsed)||parsed<=0)return null;const millis=parsed<10_000_000_000?parsed*1_000:parsed;const date=new Date(millis);return Number.isNaN(date.valueOf())?null:date.toISOString();}
-function imageUrl(value:unknown):string{const url=str(value);if(!url)return'';if(/^https?:\/\//i.test(url))return url;if(url.startsWith('//'))return`https:${url}`;return`https://img10.360buyimg.com/n1/${url.replace(/^\/+/, '')}`;}
-function attrs(value:unknown):ListingAttribute[]{return list(value).map(record).map((item)=>({id:str(item['attrId']),name:str(item['attrName']),values:list(item['values']).map(record).map((child)=>str(child['attrValueAlias']||child['attrValue'])).filter(Boolean)})).filter((item)=>item.name&&item.values.length);}
-function status(value:unknown):string|null{if(value===null||value===undefined)return null;if(typeof value!=='object')return str(value)||null;const item=record(value);return str(item['code']??item['status']??item['productStatusNew']??item['productStatus']??item['yn'])||null;}
+type UnknownRecord = Record<string, unknown>;
+export const JD_LISTING_NORMALIZER_VERSION = 'jd-listing-v5';
+const record = (value: unknown): UnknownRecord => value && typeof value === 'object' && !Array.isArray(value) ? value as UnknownRecord : {};
+const list = (value: unknown): unknown[] => Array.isArray(value) ? value : [];
+const str = (value: unknown): string => value === null || value === undefined ? '' : String(value).trim();
+const num = (value: unknown): number | null => { const parsed = Number(value); return Number.isFinite(parsed) ? parsed : null; };
 
-export function normalizeJdListing(input:{workspaceId:string;shopId:string;row:UnknownRecord;detail:UnknownRecord;syncedAt?:string}):ListingSourceSnapshot{
-  const info=record(input.detail['productInfo']);const productId=str(info['productId']??input.row['productId']??input.row['wareId']??input.row['id']);if(!productId)throw new Error('jd_product_id_missing');
-  const productTitle=record(info['productTitle']);const brandInfo=record(info['brandInfo']);const category=record(info['categoryDetail']);const price=record(info['priceInfo']);const description=record(info['productDetailDesc']);const material=record(input.detail['material']);
-  const images=list(material['mainImages']).flatMap((group)=>list(record(group)['imageInfoList'])).map(record).map((item)=>({url:imageUrl(item['imgUrl']),order:num(item['orderSort']),isPrimary:typeof item['primaryFlag']==='boolean'?item['primaryFlag'] as boolean:null,gptFlag:typeof item['gptFlag']==='boolean'?item['gptFlag'] as boolean:null})).filter((item)=>Boolean(item.url));
-  const uniqueImages=[...new Map(images.map((item)=>[item.url,item])).values()].sort((a,b)=>(a.order??999)-(b.order??999));
-  const rawForHash={productInfo:info,material,skuList:input.detail['skuList']??[]};const syncedAt=input.syncedAt??new Date().toISOString();
-  return{id:randomUUID(),workspaceId:input.workspaceId,platform:'jd',shopId:input.shopId,productId,sourceHash:canonicalHash(rawForHash),title:str(productTitle['title']??info['productName']??input.row['productName'])||null,titleBrandName:str(productTitle['titleBrandName'])||null,brand:{id:str(brandInfo['brandId'])||null,name:str(brandInfo['brandName'])||null},categoryIds:[category['thirdCategoryId'],category['lastCategoryId']].map(str).filter((value,index,array)=>value&&array.indexOf(value)===index),itemStatus:status(info['productStatus']??input.row['productStatus']),price:{jd:num(price['jdPrice']),cost:num(price['costPrice'])},descriptions:{desktopHtml:str(description['desc'])||null,mobileHtml:str(description['mobileDesc'])||null},features:list(info['features']).map(record).map((item)=>({key:str(item['key']),value:str(item['value'])})).filter((item)=>item.key||item.value),attributes:attrs(info['goodsAttrInfos']),images:uniqueImages,skus:list(input.detail['skuList']).map(record).map((sku)=>({skuId:str(sku['skuId']),name:str(sku['skuName'])||null,price:num(record(sku['priceInfo'])['jdPrice']),stock:num(sku['stockNum']),status:status(sku['skuEnableStatus']??record(sku['skuStatus'])['onOffShelfStatus']),attributes:attrs(sku['goodsAttrInfos'])})).filter((sku)=>sku.skuId),dimensions:{length:num(info['length']),width:num(info['width']),height:num(info['height']),weight:num(info['weight'])},logistics:record(info['logisticsInfo']),afterService:{...record(info['afterServiceInfo']),to7ReturnFlag:info['to7ReturnFlag']},sourceModifiedAt:epoch(info['modifiedTime']),syncedAt,detailStatus:Object.keys(info).length?'available':'empty'};
+function epoch(value: unknown): string | null {
+  const parsed = Number(value); if (!Number.isFinite(parsed) || parsed <= 0) return null;
+  const date = new Date(parsed < 10_000_000_000 ? parsed * 1_000 : parsed);
+  return Number.isNaN(date.valueOf()) ? null : date.toISOString();
+}
+function imageUrl(value: unknown): string {
+  const url = str(value); if (!url) return '';
+  if (/^https?:\/\//i.test(url)) return url.replace(/^http:\/\//i, 'https://');
+  if (url.startsWith('//')) return `https:${url}`;
+  return `https://img10.360buyimg.com/n1/${url.replace(/^\/+/, '')}`;
+}
+function attrs(value: unknown): ListingAttribute[] {
+  return list(value).map(record).map((item) => ({ id: str(item['attrId']), name: str(item['attrName']), values: list(item['values']).map(record).map((child) => str(child['attrValueAlias'] || child['attrValue'])).filter(Boolean) })).filter((item) => item.name && item.values.length);
+}
+function status(value: unknown): string | null {
+  if (value === null || value === undefined) return null;
+  if (typeof value !== 'object') return str(value) || null;
+  const item = record(value); return str(item['code'] ?? item['status'] ?? item['productStatusNew'] ?? item['productStatus'] ?? item['yn']) || null;
+}
+function htmlStructure(desktopHtml: string | null, mobileHtml: string | null): NonNullable<ListingSourceSnapshot['descriptionStructure']> {
+  const canonical = [...new Set([desktopHtml, mobileHtml].map((value) => value?.trim()).filter((value): value is string => Boolean(value)))];
+  const html = canonical.join('\n');
+  const count = (pattern: RegExp): number => html.match(pattern)?.length ?? 0;
+  const text = html.replace(/<[^>]+>/g, ' ').replace(/&nbsp;/gi, ' ').replace(/\s+/g, ' ');
+  return {
+    observed: Boolean(html.trim()), imageCount: count(/<img\b/gi), videoCount: count(/<(?:video|iframe)\b/gi), headingCount: count(/<h[1-6]\b/gi),
+    faqCandidateCount: (text.match(/(?:常见问题|FAQ|问[::]|答[::]|Q[::]|A[::])/gi) ?? []).length,
+  };
+}
+
+export function normalizeJdListing(input: { workspaceId: string; shopId: string; row: UnknownRecord; detail: UnknownRecord; syncedAt?: string }): ListingSourceSnapshot {
+  const info = record(input.detail['productInfo']);
+  const productId = str(info['productId'] ?? input.row['productId'] ?? input.row['wareId'] ?? input.row['id']);
+  if (!productId) throw new Error('jd_product_id_missing');
+  const productTitle = record(info['productTitle']); const brandInfo = record(info['brandInfo']);
+  const category = record(info['categoryDetail'] ?? info['categoryInfo']); const price = record(info['priceInfo']);
+  const description = record(info['productDetailDesc']); const material = record(input.detail['material']);
+  const desktopHtml = str(description['desc']) || null; const mobileHtml = str(description['mobileDesc']) || null;
+  const imageGroups = list(material['mainImages']).map(record).map((group, groupIndex) => ({ groupIndex, groupId: str(group['uuid']) || null, images: list(group['imageInfoList']).map(record) }));
+  const canonicalGroupIndex = imageGroups.find((group) => group.groupId === '0000000000')?.groupIndex ?? (imageGroups.length === 1 ? 0 : null);
+  const canonicalPrimaryCount = canonicalGroupIndex === null ? 0 : imageGroups[canonicalGroupIndex]?.images.filter((item) => item['primaryFlag'] === true && Boolean(imageUrl(item['imgUrl']))).length ?? 0;
+  const primaryConfirmed = canonicalGroupIndex !== null && canonicalPrimaryCount === 1;
+  const canonicalPrimarySource = !primaryConfirmed ? 'unknown' as const : imageGroups[canonicalGroupIndex!]?.groupId === '0000000000' ? 'authoritative_field' as const : 'derived' as const;
+  const groupRank = (index: number): number => index === canonicalGroupIndex ? 0 : index + 1;
+  const images = imageGroups.flatMap((group) => group.images.map((item, itemIndex) => {
+    const sourceOrder = num(item['orderSort']);
+    const sourcePrimaryFlag = typeof item['primaryFlag'] === 'boolean' ? item['primaryFlag'] as boolean : null;
+    const globalOrder = groupRank(group.groupIndex) * 1_000 + Math.max(0, (sourceOrder ?? itemIndex + 1) - 1);
+    return {
+      url: imageUrl(item['imgUrl']), order: globalOrder, groupId: group.groupId, groupIndex: group.groupIndex, itemIndex, sourceOrder, sourcePrimaryFlag,
+      isPrimary: !primaryConfirmed ? null : group.groupIndex === canonicalGroupIndex ? sourcePrimaryFlag : false,
+      primarySource: canonicalPrimarySource,
+      gptFlag: typeof item['gptFlag'] === 'boolean' ? item['gptFlag'] as boolean : null, mediaType: 'image' as const, width: num(item['width']), height: num(item['height']),
+    };
+  })).filter((item) => Boolean(item.url)).sort((a, b) => (a.order ?? 999_999) - (b.order ?? 999_999));
+  const uniqueImageMap = new Map<string, (typeof images)[number]>();
+  for (const item of images) if (!uniqueImageMap.has(item.url)) uniqueImageMap.set(item.url, item);
+  const uniqueImages = [...uniqueImageMap.values()];
+  const rawSkus = list(input.detail['skuList']).map(record);
+  const isValidSku = (sku: UnknownRecord): boolean => {
+    const value = sku['valid'];
+    if (value === undefined || value === null || value === '') return true;
+    return value === true || value === 1 || value === '1' || String(value).toLocaleLowerCase() === 'true';
+  };
+  const skus = rawSkus.filter(isValidSku).map((sku) => ({
+    skuId: str(sku['skuId']), name: str(sku['skuName']) || null, price: num(record(sku['priceInfo'])['jdPrice']), stock: num(sku['stockNum']),
+    status: status(sku['skuEnableStatus'] ?? record(sku['skuStatus'])['onOffShelfStatus']), valid: true,
+    enableStatus: status(sku['skuEnableStatus']), onOffShelfStatus: status(record(sku['skuStatus'])['onOffShelfStatus']),
+    attributes: attrs(sku['goodsAttrInfos']), saleAttributes: attrs(sku['saleAttrs']),
+    features: list(sku['features']).map(record).map((item) => ({ key: str(item['key']), value: str(item['value']) })).filter((item) => item.key || item.value),
+  })).filter((sku) => sku.skuId);
+  const adword = str(info['adword']) || null;
+  const skuShortTitles = skus.flatMap((sku) => sku.features?.filter((item) => item.key === 'shortTitle' && item.value.trim()).map((item) => ({ skuId: sku.skuId, value: item.value.trim() })) ?? []);
+  const sellingPoints = [
+    ...(adword ? [{ value: adword, source: 'product_adword' as const, fieldPath: 'productInfo.adword', skuId: null }] : []),
+    ...skuShortTitles.map((item) => ({ value: item.value, source: 'sku_short_title' as const, fieldPath: 'skuList[].features[key=shortTitle]', skuId: item.skuId })),
+  ].filter((item, index, array) => array.findIndex((candidate) => candidate.value.normalize('NFKC').trim() === item.value.normalize('NFKC').trim()) === index);
+  const categoryIds = [category['thirdCategoryId'], category['lastCategoryId'], category['categoryId']].map(str).filter((value, index, array) => value && array.indexOf(value) === index);
+  const categoryNames = [category['thirdCategoryName'], category['lastCategoryName'], category['categoryName'], input.row['thirdCategoryName'], input.row['categoryName']].map(str).filter((value, index, array) => value && array.indexOf(value) === index);
+  const categoryId = categoryIds.at(-1) ?? null;
+  const categoryRule = resolveJdCategoryRule(categoryNames, categoryId);
+  const rawForHash = { productInfo: info, material, skuList: input.detail['skuList'] ?? [], categoryContext: { categoryIds, categoryNames } };
+  const syncedAt = input.syncedAt ?? new Date().toISOString();
+  return {
+    id: randomUUID(), workspaceId: input.workspaceId, platform: 'jd', shopId: input.shopId, productId,
+    sourceHash: canonicalHash({ normalizerVersion: JD_LISTING_NORMALIZER_VERSION, raw: rawForHash }), normalizerVersion: JD_LISTING_NORMALIZER_VERSION,
+    title: str(productTitle['title'] ?? info['productName'] ?? input.row['productName']) || null, titleBrandName: str(productTitle['titleBrandName']) || null,
+    brand: { id: str(brandInfo['brandId']) || null, name: str(brandInfo['brandName']) || null }, categoryIds,
+    categoryContext: { names: categoryNames, ...categoryRule },
+    itemStatus: status(info['productStatus'] ?? input.row['productStatus']), price: { jd: num(price['jdPrice']), cost: num(price['costPrice']) },
+    descriptions: { desktopHtml, mobileHtml }, descriptionStructure: htmlStructure(desktopHtml, mobileHtml),
+    features: list(info['features']).map(record).map((item) => ({ key: str(item['key']), value: str(item['value']) })).filter((item) => item.key || item.value),
+    attributes: attrs(info['goodsAttrInfos']), images: uniqueImages.filter((item) => item.groupIndex === canonicalGroupIndex),
+    imageAssets: {
+      defaultImages: uniqueImages.filter((item) => item.groupIndex === canonicalGroupIndex),
+      skuImages: skus.map((sku) => ({ skuId: sku.skuId, images: uniqueImages.filter((item) => item.groupId === sku.skuId) })),
+      whiteBackgroundImages: list(material['whiteBackGroundImages']).map(record).map((item, index) => ({
+        url: imageUrl(item['imgUrl'] ?? item['imageUrl']), order: index, isPrimary: false, groupId: 'white_background', gptFlag: null, mediaType: 'image' as const,
+      })).filter((item) => Boolean(item.url)),
+    }, skus,
+    dimensions: { length: num(info['length']), width: num(info['width']), height: num(info['height']), weight: num(info['weight']) },
+    logistics: record(info['logisticsInfo']), afterService: { ...record(info['afterServiceInfo']), to7ReturnFlag: info['to7ReturnFlag'] },
+    marketing: { adword, skuShortTitles, sellingPoints }, vocEvidence: [], sourceModifiedAt: epoch(info['modifiedTime']), syncedAt,
+    detailStatus: Object.keys(info).length ? 'available' : 'empty',
+  };
 }

+ 63 - 0
src/modules/listing-ai/normalization/listing-context.enricher.ts

@@ -0,0 +1,63 @@
+import { randomUUID } from 'node:crypto';
+import type { ListingSourceSnapshot } from '../domain.js';
+import { canonicalHash } from '../scoring/rule-engine.js';
+import { resolveJdCategoryRule } from '../scoring/jd-category-rules.js';
+
+export const LISTING_CONTEXT_VERSION = 'listing-context-2026-08-v2';
+type Row = Record<string, unknown>;
+const text = (value: unknown): string => value === null || value === undefined ? '' : String(value).normalize('NFKC').trim();
+const normalized = (value: unknown): string => text(value).replace(/\s+/g, '').toLocaleLowerCase();
+const iso = (value: unknown): string | null => {
+  if (value && typeof value === 'object' && typeof (value as Row)['iso'] === 'string') return String((value as Row)['iso']);
+  const date = new Date(String(value ?? '')); return Number.isNaN(date.valueOf()) ? null : date.toISOString();
+};
+
+export interface ListingContextIndex {
+  enrich(source: ListingSourceSnapshot, now?: string): ListingSourceSnapshot;
+}
+
+export function buildListingContextIndex(products: Row[], relations: Row[], reviews: Row[]): ListingContextIndex {
+  const categoryBySku = new Map<string, string>();
+  const categoriesByTitle = new Map<string, Set<string>>();
+  for (const product of products) {
+    const category = text(product['category3'] ?? product['category2'] ?? product['category1']);
+    if (!category) continue;
+    const productId = text(product['productId']); if (productId) categoryBySku.set(productId, category);
+    const titleKey = normalized(product['title']);
+    if (titleKey) { const values = categoriesByTitle.get(titleKey) ?? new Set<string>(); values.add(category); categoriesByTitle.set(titleKey, values); }
+  }
+  const categoryByReviewProduct = new Map(categoryBySku);
+  for (const relation of relations) {
+    const competitorId = text(relation['competitorProductId']);
+    const category = text(relation['category'] ?? relation['ownCategory3'] ?? relation['ownCategory2']);
+    if (competitorId && category) categoryByReviewProduct.set(competitorId, category);
+  }
+  const evidenceByCategory = new Map<string, ListingSourceSnapshot['vocEvidence']>();
+  for (const review of reviews) {
+    const rating = Number(review['rating']);
+    if (Number.isFinite(rating) && rating > 3) continue;
+    const category = categoryByReviewProduct.get(text(review['productId']));
+    const content = text(review['content']).slice(0, 500);
+    if (!category || !content) continue;
+    const values = evidenceByCategory.get(category) ?? [];
+    if (values.some((item) => item.text === content) || values.length >= 12) continue;
+    values.push({ id: text(review['sourceReviewId'] ?? review['reviewKey'] ?? review['naturalKey'] ?? review['objectId']) || canonicalHash(content).slice(0, 24), text: content, sourceVersion: LISTING_CONTEXT_VERSION, collectedAt: iso(review['reviewDate'] ?? review['updatedAt'] ?? review['createdAt']) ?? '1970-01-01T00:00:00.000Z' });
+    evidenceByCategory.set(category, values);
+  }
+  return {
+    enrich(source, now = new Date().toISOString()) {
+      const categoryCounts = new Map<string, number>();
+      for (const sku of source.skus) { const category = categoryBySku.get(sku.skuId); if (category) categoryCounts.set(category, (categoryCounts.get(category) ?? 0) + 1); }
+      let category = [...categoryCounts.entries()].sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0]))[0]?.[0] ?? '';
+      if (!category) { const titleCategories = categoriesByTitle.get(normalized(source.title)); if (titleCategories?.size === 1) category = [...titleCategories][0]!; }
+      const names = category ? [category] : source.categoryContext?.pathNames ?? source.categoryContext?.names ?? [];
+      const categoryId = source.categoryContext?.categoryId ?? source.categoryIds.at(-1) ?? null;
+      const categoryRule = resolveJdCategoryRule(names, categoryId);
+      const categoryContext = { names, ...categoryRule };
+      const vocEvidence = category ? evidenceByCategory.get(category) ?? [] : [];
+      const baseSourceHash = source.baseSourceHash ?? source.sourceHash;
+      const sourceHash = canonicalHash({ baseSourceHash, contextVersion: LISTING_CONTEXT_VERSION, categoryContext, vocEvidence: vocEvidence.map((item) => ({ id: item.id, sourceVersion: item.sourceVersion, collectedAt: item.collectedAt, text: item.text })) });
+      return { ...source, id: randomUUID(), sourceHash, baseSourceHash, contextVersion: LISTING_CONTEXT_VERSION, categoryContext, vocEvidence, syncedAt: now };
+    },
+  };
+}

+ 82 - 0
src/modules/listing-ai/presentation/listing-score.presenter.ts

@@ -0,0 +1,82 @@
+import type { ListingDimension, ListingProductSummary, ListingRuleEvidence, ListingScoreJob, ListingScoreJobItem, ListingScoreResult, ListingSourceSnapshot } from '../domain.js';
+
+const DIMENSIONS: Record<ListingDimension, string> = {
+  title: '商品标题', selling_points: '核心卖点', images: '图片资产', description: '商品详情', specifications: '规格与履约',
+};
+const RESULT = { fail: '未达标', weak: '有待优化', pass: '表现良好', strong: '表现优秀', unknown: '暂无法判断' } as const;
+const SOURCE_LABELS: Array<[string, string]> = [
+  ['title', '商品标题'], ['brand', '品牌信息'], ['categoryContext', '商品类目'], ['marketing', '商品广告语和规格短标题'],
+  ['images', '商品图片'], ['descriptions', '商品详情'], ['descriptionStructure', '详情资产'], ['attributes', '商品属性'],
+  ['skus', '商品规格'], ['dimensions', '尺寸与重量'], ['afterService', '售后服务'], ['logistics', '配送信息'], ['ai.', '商品信息综合分析'],
+];
+
+function sourceLabel(path: string): string { return SOURCE_LABELS.find(([prefix]) => path.startsWith(prefix))?.[1] ?? '商品资料'; }
+function resultLabel(row: ListingRuleEvidence): string { return RESULT[row.level ?? (row.outcome === 'pass' ? 'pass' : row.outcome === 'fail' ? 'fail' : 'unknown')]; }
+function action(row: ListingRuleEvidence): string | null { return row.outcome === 'fail' ? row.message.replace(/^(必须|应当|需要)/, '请') : null; }
+
+export interface ListingScorePresentation {
+  score: number | null;
+  scoreText: string;
+  statusLabel: string;
+  methodLabel: string;
+  standardLabel: string;
+  dataUpdatedAtText: string;
+  scopeNote: string;
+  dimensions: Array<{ key: ListingDimension; name: string; score: number | null; maxScore: number; scoreText: string; conclusion: string; items: Array<{ title: string; resultLabel: string; reason: string; action: string | null; impactText: string; sources: string[] }> }>;
+}
+
+export function presentListingScore(score: ListingScoreResult | null, source: ListingSourceSnapshot): ListingScorePresentation | null {
+  if (!score || score.sourceHash !== source.sourceHash) return null;
+  const simulated = score.model === 'listing-v7-demo-simulation';
+  return {
+    score: score.overallScore,
+    scoreText: score.overallScore === null ? '尚未完成智能评分' : `${score.overallScore} / 100`,
+    statusLabel: score.overallScore === null ? '自动检查已完成,等待智能评分' : '评分完成',
+    methodLabel: simulated ? '模拟评分' : score.scoreKind === 'hybrid_ai' ? '智能评分' : '自动检查',
+    standardLabel: '京东五维评分 V7',
+    dataUpdatedAtText: source.syncedAt,
+    scopeNote: simulated ? '当前为展示用模拟评分,依据现有商品资料生成,不代表真实智能模型结论。' : '当前评分不包含图片审美与构图、详情图片文字识别、用户评价和竞品差异。上述能力不会形成商品扣分。',
+    dimensions: score.dimensions.map((dimension) => ({
+      key: dimension.dimension, name: DIMENSIONS[dimension.dimension], score: dimension.score, maxScore: dimension.maxScore,
+      scoreText: dimension.score === null ? '等待智能评分' : `${dimension.score} / ${dimension.maxScore}`,
+      conclusion: dimension.evidence.some((row) => row.outcome === 'fail') ? '仍有可以提升的项目' : '当前检查项目表现良好',
+      items: dimension.evidence.filter((row) => (row.maxPoints ?? 0) > 0).map((row) => ({
+        title: row.message.split(':')[0] || DIMENSIONS[dimension.dimension], resultLabel: resultLabel(row), reason: row.message,
+        action: action(row), impactText: `本项 ${row.pointsAwarded ?? 0} / ${row.maxPoints ?? 0} 分`, sources: [sourceLabel(row.fieldPath)],
+      })),
+    })),
+  };
+}
+
+export const LISTING_UI_COPY = {
+  queued: '等待处理', running: '正在评分', completed: '已完成', partial: '部分商品未完成', failed: '评分失败', cancelled: '已取消',
+  rules_scored: '自动检查完成', ai_pending: '等待智能评分', scored: '评分完成', blocked: '商品数据获取失败',
+} as const;
+
+const SCORE_STATUS_LABELS: Record<ListingProductSummary['scoreStatus'], string> = { unscored: '尚未评分', scored: '评分完成', partial: '等待智能评分', blocked: '资料获取失败', failed: '评分未完成' };
+const AI_STATUS_LABELS: Record<ListingProductSummary['aiScoreStatus'], string> = { not_scored: '尚未智能评分', completed: '智能评分完成', partial: '智能评分待补充', failed: '智能评分未完成' };
+
+export function presentListingProductSummary(item: ListingProductSummary) {
+  const formal = item.latestEffectiveAiScore?.overallScore === null ? null : item.latestEffectiveAiScore?.overallScore ?? null;
+  const simulated = item.latestEffectiveAiScore?.model === 'listing-v7-demo-simulation';
+  return {
+    productId: item.productId,
+    title: item.title,
+    imageUrl: item.imageUrl,
+    categoryText: item.categoryIds.length ? '类目信息已获取' : '暂无类目信息',
+    itemStatusLabel: item.itemStatus ? '商品状态已获取' : '商品状态未知',
+    specificationCount: item.skuCount,
+    dataStatusLabel: item.coverage.status === 'eligible' ? '数据完整' : item.coverage.status === 'partial' ? '部分数据待补充' : '资料获取失败',
+    scoreStatusLabel: SCORE_STATUS_LABELS[item.scoreStatus],
+    aiStatusLabel: AI_STATUS_LABELS[item.aiScoreStatus],
+    score: formal,
+    scoreText: formal === null ? '等待智能评分' : `${formal} / 100`,
+    methodLabel: formal === null ? (item.latestEffectiveRuleScore ? '自动检查' : '尚未评分') : simulated ? '模拟评分' : '智能评分',
+    syncedAt: item.syncedAt,
+  };
+}
+
+export function presentListingJob(job: ListingScoreJob) { return { ...job, statusLabel: LISTING_UI_COPY[job.status] }; }
+export function presentListingJobItem(item: ListingScoreJobItem) {
+  return { ...item, statusLabel: LISTING_UI_COPY[item.status], errorMessage: item.errorCode ? '本次评分未完成,请稍后重试' : null, errorCode: undefined, errorDetail: undefined, statusReasonCodes: undefined };
+}

+ 44 - 26
src/modules/listing-ai/repositories/in-memory-listing-ai.repository.ts

@@ -2,6 +2,8 @@ import { ApiError } from '../../../http/api-error.js';
 import type {
   ListingAiRepository,
   ListingCursorPage,
+  ListingCatalogSummary,
+  ListingCurrentScoreSlot,
   ListingProductQuery,
   ListingProductSummary,
   ListingScoreJob,
@@ -11,6 +13,7 @@ import type {
   ListingVersion,
 } from '../domain.js';
 import { listingCoverage } from '../scoring/rule-engine.js';
+import { isListingV7Score, listingAiScoreStatus, listingProductScoreStatus, selectCurrentListingScore } from '../scoring/score-status.js';
 
 function cursorEncode(id: string): string {
   return Buffer.from(JSON.stringify({ id }), 'utf8').toString('base64url');
@@ -39,9 +42,8 @@ function sourceKey(source: Pick<ListingSourceSnapshot, 'workspaceId' | 'platform
   return `${source.workspaceId}|${source.platform}|${source.productId}`;
 }
 
-function scoreKey(score: Pick<ListingScoreResult, 'workspaceId' | 'productId' | 'sourceHash' | 'rubricVersion' | 'model' | 'promptVersion'>): string {
-  return `${score.workspaceId}|${score.productId}|${score.sourceHash}|${score.rubricVersion}|${score.model ?? 'rules'}|${score.promptVersion ?? 'rules'}`;
-}
+function scoreSlot(score: ListingScoreResult): ListingCurrentScoreSlot { return score.scoreKind === 'hybrid_ai' ? 'formal_ai' : 'rule_precheck'; }
+function scoreKey(workspaceId: string, productId: string, slot: ListingCurrentScoreSlot): string { return `${workspaceId}|${productId}|${slot}`; }
 
 export class InMemoryListingAiRepository implements ListingAiRepository {
   private readonly sources = new Map<string, ListingSourceSnapshot>();
@@ -80,14 +82,9 @@ export class InMemoryListingAiRepository implements ListingAiRepository {
     if (query.itemStatus) items = items.filter((item) => item.itemStatus === query.itemStatus);
     if (query.coverageStatus) items = items.filter((item) => item.coverage.status === query.coverageStatus);
     if (query.scoreStatus) {
-      items = items.filter((item) => {
-        if (query.scoreStatus === 'unscored') return !item.latestScore;
-        if (query.scoreStatus === 'scored') return item.latestScore?.overallScore !== null;
-        if (query.scoreStatus === 'blocked') return item.latestScore?.coverage.status === 'blocked';
-        if (query.scoreStatus === 'partial') return item.latestScore?.coverage.status === 'partial' || item.latestScore?.aiStatus === 'failed';
-        return false;
-      });
+      items = items.filter((item) => item.scoreStatus === query.scoreStatus);
     }
+    if (query.aiScoreStatus) items = items.filter((item) => item.aiScoreStatus === query.aiScoreStatus);
     if (query.minScore !== undefined) items = items.filter((item) => (item.latestScore?.overallScore ?? -1) >= query.minScore!);
     if (query.maxScore !== undefined) items = items.filter((item) => (item.latestScore?.overallScore ?? 101) <= query.maxScore!);
     items.sort((left, right) => {
@@ -99,29 +96,44 @@ export class InMemoryListingAiRepository implements ListingAiRepository {
     return page(items, query.limit, query.cursor, (item) => item.productId);
   }
 
+  async catalogSummary(workspaceId: string, platform: 'jd'): Promise<ListingCatalogSummary> {
+    const sources = await this.listAllSources(workspaceId, platform);
+    const items = sources.map((source) => this.summary(source));
+    const numeric = items.filter((item) => item.scoreStatus === 'scored').map((item) => item.latestScore?.overallScore).filter((value): value is number => value !== null && value !== undefined);
+    return {
+      sourceTotal: items.length,
+      eligible: items.filter((item) => item.coverage.status === 'eligible').length,
+      scored: items.filter((item) => item.scoreStatus === 'scored').length,
+      partial: items.filter((item) => item.scoreStatus === 'partial').length,
+      blocked: items.filter((item) => item.scoreStatus === 'blocked').length,
+      failed: items.filter((item) => item.scoreStatus === 'failed').length,
+      averageScore: numeric.length ? Math.round(numeric.reduce((sum, value) => sum + value, 0) / numeric.length * 10) / 10 : null,
+      lastCatalogSyncAt: sources.map((source) => source.syncedAt).sort().at(-1) ?? null,
+    };
+  }
+
   async getSource(workspaceId: string, platform: 'jd', productId: string): Promise<ListingSourceSnapshot | null> {
     const source = this.sources.get(`${workspaceId}|${platform}|${productId}`);
     return source ? structuredClone(source) : null;
   }
 
-  async getLatestScore(workspaceId: string, productId: string, rubricVersion?: string): Promise<ListingScoreResult | null> {
-    const scores = [...this.scores.values()].filter((score) => score.workspaceId === workspaceId && score.productId === productId && (!rubricVersion || score.rubricVersion === rubricVersion));
-    scores.sort((left, right) => right.createdAt.localeCompare(left.createdAt));
-    return scores[0] ? structuredClone(scores[0]) : null;
+  async getCurrentScore(workspaceId: string, productId: string, slot?: ListingCurrentScoreSlot): Promise<ListingScoreResult | null> {
+    const score = slot ? this.scores.get(scoreKey(workspaceId, productId, slot))
+      : [this.scores.get(scoreKey(workspaceId, productId, 'formal_ai')),this.scores.get(scoreKey(workspaceId, productId, 'rule_precheck'))].find((item):item is ListingScoreResult=>Boolean(item&&isListingV7Score(item)));
+    return score && isListingV7Score(score) ? structuredClone(score) : null;
   }
 
-  async listLatestScores(workspaceId: string): Promise<ListingScoreResult[]> {
-    const latest = new Map<string, ListingScoreResult>();
-    for (const score of this.scores.values()) {
-      if (score.workspaceId !== workspaceId) continue;
-      const current = latest.get(score.productId);
-      if (!current || score.createdAt > current.createdAt) latest.set(score.productId, score);
-    }
-    return [...latest.values()].map((score) => structuredClone(score));
+  async getCurrentScoreByExecutionKey(workspaceId: string, executionKey: string): Promise<ListingScoreResult | null> {
+    const score = [...this.scores.values()].find((item) => item.workspaceId === workspaceId && item.executionKey === executionKey);
+    return score && isListingV7Score(score) ? structuredClone(score) : null;
+  }
+
+  async listCurrentScores(workspaceId: string): Promise<ListingScoreResult[]> {
+    return [...this.scores.values()].filter((score) => score.workspaceId === workspaceId && isListingV7Score(score)).map((score) => structuredClone(score));
   }
 
-  async saveScore(result: ListingScoreResult): Promise<ListingScoreResult> {
-    this.scores.set(scoreKey(result), structuredClone(result));
+  async upsertCurrentScore(result: ListingScoreResult): Promise<ListingScoreResult> {
+    this.scores.set(scoreKey(result.workspaceId, result.productId, scoreSlot(result)), structuredClone(result));
     return structuredClone(result);
   }
 
@@ -185,7 +197,8 @@ export class InMemoryListingAiRepository implements ListingAiRepository {
   }
 
   private summary(source: ListingSourceSnapshot): ListingProductSummary {
-    const scores = [...this.scores.values()].filter((score) => score.workspaceId === source.workspaceId && score.productId === source.productId && score.sourceHash === source.sourceHash).sort((a, b) => b.createdAt.localeCompare(a.createdAt));
+    const scores = [...this.scores.values()].reverse().filter((score) => score.workspaceId === source.workspaceId && score.productId === source.productId && score.sourceHash === source.sourceHash).sort((a, b) => b.createdAt.localeCompare(a.createdAt));
+    const view = selectCurrentListingScore(scores, source.sourceHash);
     return {
       productId: source.productId,
       shopId: source.shopId,
@@ -199,7 +212,12 @@ export class InMemoryListingAiRepository implements ListingAiRepository {
       syncedAt: source.syncedAt,
       sourceHash: source.sourceHash,
       coverage: listingCoverage(source),
-      latestScore: scores[0] ? structuredClone(scores[0]) : null,
+      scoreStatus: listingProductScoreStatus(source, view.displayScore, view.latestAttempt),
+      aiScoreStatus: listingAiScoreStatus(view.latestEffectiveAiScore, view.latestAttempt),
+      latestScore: view.displayScore ? structuredClone(view.displayScore) : null,
+      latestAttempt: view.latestAttempt ? structuredClone(view.latestAttempt) : null,
+      latestEffectiveRuleScore: view.latestEffectiveRuleScore ? structuredClone(view.latestEffectiveRuleScore) : null,
+      latestEffectiveAiScore: view.latestEffectiveAiScore ? structuredClone(view.latestEffectiveAiScore) : null,
     };
   }
 }

File diff suppressed because it is too large
+ 55 - 12
src/modules/listing-ai/repositories/parse-rest-listing-ai.repository.ts


+ 51 - 39
src/modules/listing-ai/repositories/postgres-listing-ai.repository.ts

@@ -1,10 +1,11 @@
 import type { Pool, PoolClient } from 'pg';
 import { ApiError } from '../../../http/api-error.js';
 import type {
-  ListingAiRepository, ListingCursorPage, ListingProductQuery, ListingScoreJob, ListingScoreJobItem,
+  ListingAiRepository, ListingCurrentScoreSlot, ListingCursorPage, ListingProductQuery, ListingScoreJob, ListingScoreJobItem,
   ListingScoreResult, ListingSourceSnapshot, ListingVersion,
 } from '../domain.js';
 import { InMemoryListingAiRepository } from './in-memory-listing-ai.repository.js';
+import { isListingV7Score } from '../scoring/score-status.js';
 
 type JsonRow<T> = { payload: T };
 
@@ -30,35 +31,36 @@ interface JobRow {
   total: number; processed: number; succeeded: number; partial: number; blocked: number; failed: number;
   requested_by_external_id: string; requested_at: Date | string; started_at: Date | string | null;
   completed_at: Date | string | null; updated_at: Date | string;
+  rescore_policy?: 'reuse' | 'force';
 }
 
 interface ItemRow {
   public_id: string; job_public_id: string; workspace_public_id: string; product_id: string; source_hash: string;
-  status: ListingScoreJobItem['status']; attempts: number; score_result_public_id: string | null;
-  error_code: string | null; error_detail_redacted: string | null; updated_at: Date | string;
+  status: ListingScoreJobItem['status']; attempts: number;
+  error_code: string | null; error_detail_redacted: string | null; status_reason_codes?: unknown; updated_at: Date | string;
 }
 
 interface VersionRow {
   public_id: string; workspace_public_id: string; product_id: string; version_no: number; base_source_hash: string;
-  base_score_result_public_id: string | null; content: ListingVersion['content']; status: ListingVersion['status'];
+  content: ListingVersion['content']; status: ListingVersion['status'];
   created_by_external_id: string; created_at: Date | string; adopted_at: Date | string | null;
 }
 
 function jobFromRow(row: JobRow): ListingScoreJob {
   return { id: row.public_id, workspaceId: row.workspace_public_id, platform: row.platform, idempotencyKey: row.idempotency_key,
-    requestHash: row.request_hash, rubricVersion: row.rubric_version, includeAiSuggestions: row.include_ai_suggestions,
+    requestHash: row.request_hash, rubricVersion: row.rubric_version, includeAiSuggestions: row.include_ai_suggestions, rescorePolicy: row.rescore_policy ?? 'reuse',
     scope: row.scope, status: row.status, total: row.total, processed: row.processed, succeeded: row.succeeded,
     partial: row.partial, blocked: row.blocked, failed: row.failed, requestedBy: row.requested_by_external_id,
     requestedAt: iso(row.requested_at)!, startedAt: iso(row.started_at), completedAt: iso(row.completed_at), updatedAt: iso(row.updated_at)! };
 }
 function itemFromRow(row: ItemRow): ListingScoreJobItem {
   return { id: row.public_id, jobId: row.job_public_id, workspaceId: row.workspace_public_id, productId: row.product_id,
-    sourceHash: row.source_hash, status: row.status, attempts: row.attempts, scoreResultId: row.score_result_public_id,
-    errorCode: row.error_code, errorDetail: row.error_detail_redacted, updatedAt: iso(row.updated_at)! };
+    sourceHash: row.source_hash, status: row.status, attempts: row.attempts,
+    errorCode: row.error_code, errorDetail: row.error_detail_redacted, statusReasonCodes: Array.isArray(row.status_reason_codes) ? row.status_reason_codes.filter((item): item is string => typeof item === 'string') : [], updatedAt: iso(row.updated_at)! };
 }
 function versionFromRow(row: VersionRow): ListingVersion {
   return { id: row.public_id, workspaceId: row.workspace_public_id, productId: row.product_id, versionNo: row.version_no,
-    baseSourceHash: row.base_source_hash, baseScoreResultId: row.base_score_result_public_id, content: row.content,
+    baseSourceHash: row.base_source_hash, content: row.content,
     status: row.status, createdBy: row.created_by_external_id, createdAt: iso(row.created_at)!, adoptedAt: iso(row.adopted_at) };
 }
 
@@ -97,41 +99,51 @@ export class PostgresListingAiRepository implements ListingAiRepository {
 
   async listProducts(query: ListingProductQuery) {
     const memory = new InMemoryListingAiRepository(await this.listAllSources(query.workspaceId, query.platform));
-    const results = await this.pool.query<JsonRow<ListingScoreResult>>(`SELECT DISTINCT ON (r.product_id) r.result AS payload
-      FROM voc.listing_score_result r JOIN voc.workspace w ON w.id=r.workspace_id WHERE w.public_id=$1
-      ORDER BY r.product_id,r.created_at DESC,r.id DESC`, [query.workspaceId]);
-    for (const row of results.rows) await memory.saveScore(row.payload);
+    const results = await this.pool.query<JsonRow<ListingScoreResult>>(`SELECT r.result AS payload
+      FROM voc.listing_current_score r JOIN voc.workspace w ON w.id=r.workspace_id WHERE w.public_id=$1`, [query.workspaceId]);
+    for (const row of results.rows) await memory.upsertCurrentScore(row.payload);
     return memory.listProducts(query);
   }
 
+  async catalogSummary(workspaceId: string, platform: 'jd') {
+    const memory = new InMemoryListingAiRepository(await this.listAllSources(workspaceId, platform));
+    const results = await this.pool.query<JsonRow<ListingScoreResult>>(`SELECT r.result AS payload FROM voc.listing_current_score r JOIN voc.workspace w ON w.id=r.workspace_id WHERE w.public_id=$1`, [workspaceId]);
+    for (const row of results.rows) await memory.upsertCurrentScore(row.payload);
+    return memory.catalogSummary(workspaceId, platform);
+  }
+
   async getSource(workspaceId: string, platform: 'jd', productId: string): Promise<ListingSourceSnapshot | null> {
     const result = await this.pool.query<JsonRow<ListingSourceSnapshot>>(`SELECT s.payload FROM voc.listing_source_snapshot s JOIN voc.workspace w ON w.id=s.workspace_id
       WHERE w.public_id=$1 AND s.platform=$2 AND s.product_id=$3 ORDER BY s.observed_at DESC,s.id DESC LIMIT 1`, [workspaceId, platform, productId]);
     return result.rows[0]?.payload ?? null;
   }
 
-  async getLatestScore(workspaceId: string, productId: string, rubricVersion?: string): Promise<ListingScoreResult | null> {
-    const result = await this.pool.query<JsonRow<ListingScoreResult>>(`SELECT r.result AS payload FROM voc.listing_score_result r JOIN voc.workspace w ON w.id=r.workspace_id
-      WHERE w.public_id=$1 AND r.product_id=$2 AND ($3::text IS NULL OR r.rubric_version=$3) ORDER BY r.created_at DESC,r.id DESC LIMIT 1`, [workspaceId, productId, rubricVersion ?? null]);
-    return result.rows[0]?.payload ?? null;
+  async getCurrentScore(workspaceId: string, productId: string, slot?: ListingCurrentScoreSlot): Promise<ListingScoreResult | null> {
+    const result = await this.pool.query<JsonRow<ListingScoreResult>>(`SELECT r.result AS payload FROM voc.listing_current_score r JOIN voc.workspace w ON w.id=r.workspace_id
+      WHERE w.public_id=$1 AND r.product_id=$2 AND ($3::text IS NULL OR r.slot=$3) ORDER BY CASE WHEN r.slot='formal_ai' THEN 0 ELSE 1 END`, [workspaceId, productId, slot ?? null]);
+    return result.rows.map((row)=>row.payload).find(isListingV7Score)??null;
   }
 
+  async getCurrentScoreByExecutionKey(workspaceId: string, executionKey: string): Promise<ListingScoreResult | null> {
+    const result = await this.pool.query<JsonRow<ListingScoreResult>>(`SELECT r.result AS payload FROM voc.listing_current_score r JOIN voc.workspace w ON w.id=r.workspace_id
+      WHERE w.public_id=$1 AND r.execution_key=$2 LIMIT 1`, [workspaceId, executionKey]);
+    const score=result.rows[0]?.payload;return score&&isListingV7Score(score)?score:null;
+  }
 
-  async listLatestScores(workspaceId: string): Promise<ListingScoreResult[]> {
-    const result = await this.pool.query<JsonRow<ListingScoreResult>>(`SELECT DISTINCT ON (r.product_id) r.result AS payload
-      FROM voc.listing_score_result r JOIN voc.workspace w ON w.id=r.workspace_id
-      WHERE w.public_id=$1 ORDER BY r.product_id,r.created_at DESC,r.id DESC`, [workspaceId]);
-    return result.rows.map((row) => row.payload);
+  async listCurrentScores(workspaceId: string): Promise<ListingScoreResult[]> {
+    const result = await this.pool.query<JsonRow<ListingScoreResult>>(`SELECT r.result AS payload
+      FROM voc.listing_current_score r JOIN voc.workspace w ON w.id=r.workspace_id WHERE w.public_id=$1`, [workspaceId]);
+    return result.rows.map((row) => row.payload).filter(isListingV7Score);
   }
 
-  async saveScore(score: ListingScoreResult): Promise<ListingScoreResult> {
-    await this.pool.query(`INSERT INTO voc.listing_score_result
-      (public_id,workspace_id,product_id,source_hash,rubric_version,model_key,prompt_version,overall_score,coverage,result,model_info,created_at)
-      SELECT $1,w.id,$3,$4,$5,$6,$7,$8,$9::jsonb,$10::jsonb,$11::jsonb,$12 FROM voc.workspace w WHERE w.public_id=$2
-      ON CONFLICT (workspace_id,product_id,source_hash,rubric_version,model_key,prompt_version) DO UPDATE SET
-        overall_score=EXCLUDED.overall_score,coverage=EXCLUDED.coverage,result=EXCLUDED.result,model_info=EXCLUDED.model_info,created_at=EXCLUDED.created_at`,
-    [score.id, score.workspaceId, score.productId, score.sourceHash, score.rubricVersion, score.model ?? 'rules', score.promptVersion ?? 'rules', score.overallScore, JSON.stringify(score.coverage), JSON.stringify(score), JSON.stringify({ model: score.model, promptVersion: score.promptVersion }), score.createdAt]);
-    return (await this.getLatestScore(score.workspaceId, score.productId, score.rubricVersion)) ?? score;
+  async upsertCurrentScore(score: ListingScoreResult): Promise<ListingScoreResult> {
+    const slot: ListingCurrentScoreSlot = score.scoreKind === 'hybrid_ai' ? 'formal_ai' : 'rule_precheck';
+    await this.pool.query(`INSERT INTO voc.listing_current_score
+      (public_id,workspace_id,product_id,slot,source_hash,rubric_version,overall_score,score_kind,ai_status,compliance_status,result,execution_key,input_fingerprint,updated_at)
+      SELECT $1,w.id,$3,$4,$5,$6,$7,$8,$9,$10,$11::jsonb,$12,$13,$14 FROM voc.workspace w WHERE w.public_id=$2
+      ON CONFLICT (workspace_id,product_id,slot) DO UPDATE SET public_id=EXCLUDED.public_id,source_hash=EXCLUDED.source_hash,rubric_version=EXCLUDED.rubric_version,overall_score=EXCLUDED.overall_score,score_kind=EXCLUDED.score_kind,ai_status=EXCLUDED.ai_status,compliance_status=EXCLUDED.compliance_status,result=EXCLUDED.result,execution_key=EXCLUDED.execution_key,input_fingerprint=EXCLUDED.input_fingerprint,updated_at=EXCLUDED.updated_at`,
+    [score.id, score.workspaceId, score.productId, slot, score.sourceHash, score.rubricVersion, score.overallScore, score.scoreKind ?? 'rules', score.aiStatus, score.compliance?.status ?? 'normal', JSON.stringify(score), score.executionKey ?? null, score.inputFingerprint ?? null, score.createdAt]);
+    return score;
   }
 
   async createJob(job: ListingScoreJob, items: ListingScoreJobItem[]): Promise<{ job: ListingScoreJob; created: boolean }> {
@@ -145,9 +157,9 @@ export class PostgresListingAiRepository implements ListingAiRepository {
         await client.query('COMMIT'); return { job: value, created: false };
       }
       await client.query(`INSERT INTO voc.listing_score_job
-        (public_id,workspace_id,platform,idempotency_key,request_hash,rubric_version,include_ai_suggestions,scope,status,total,processed,succeeded,partial,blocked,failed,requested_by_external_id,requested_at,started_at,completed_at,updated_at)
-        SELECT $1,w.id,$3,$4,$5,$6,$7,$8::jsonb,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20 FROM voc.workspace w WHERE w.public_id=$2`,
-      [job.id,job.workspaceId,job.platform,job.idempotencyKey,job.requestHash,job.rubricVersion,job.includeAiSuggestions,JSON.stringify(job.scope),job.status,job.total,job.processed,job.succeeded,job.partial,job.blocked,job.failed,job.requestedBy,job.requestedAt,job.startedAt,job.completedAt,job.updatedAt]);
+        (public_id,workspace_id,platform,idempotency_key,request_hash,rubric_version,include_ai_suggestions,rescore_policy,scope,status,total,processed,succeeded,partial,blocked,failed,requested_by_external_id,requested_at,started_at,completed_at,updated_at)
+        SELECT $1,w.id,$3,$4,$5,$6,$7,$8,$9::jsonb,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21 FROM voc.workspace w WHERE w.public_id=$2`,
+      [job.id,job.workspaceId,job.platform,job.idempotencyKey,job.requestHash,job.rubricVersion,job.includeAiSuggestions,job.rescorePolicy,JSON.stringify(job.scope),job.status,job.total,job.processed,job.succeeded,job.partial,job.blocked,job.failed,job.requestedBy,job.requestedAt,job.startedAt,job.completedAt,job.updatedAt]);
       for (const item of items) await this.insertItem(client, item);
       await client.query('COMMIT'); return { job, created: true };
     } catch (error) { await client.query('ROLLBACK'); throw error; } finally { client.release(); }
@@ -155,10 +167,10 @@ export class PostgresListingAiRepository implements ListingAiRepository {
 
   private async insertItem(client: PoolClient, item: ListingScoreJobItem): Promise<void> {
     await client.query(`INSERT INTO voc.listing_score_item
-      (public_id,job_id,workspace_id,product_id,source_hash,status,attempts,score_result_public_id,error_code,error_detail_redacted,updated_at)
-      SELECT $1,j.id,w.id,$4,$5,$6,$7,$8,$9,$10,$11 FROM voc.listing_score_job j,voc.workspace w WHERE j.public_id=$2 AND w.public_id=$3
+      (public_id,job_id,workspace_id,product_id,source_hash,status,attempts,error_code,error_detail_redacted,status_reason_codes,updated_at)
+      SELECT $1,j.id,w.id,$4,$5,$6,$7,$8,$9,$10::jsonb,$11 FROM voc.listing_score_job j,voc.workspace w WHERE j.public_id=$2 AND w.public_id=$3
       ON CONFLICT (job_id,product_id,source_hash) DO NOTHING`,
-    [item.id,item.jobId,item.workspaceId,item.productId,item.sourceHash,item.status,item.attempts,item.scoreResultId,item.errorCode,item.errorDetail,item.updatedAt]);
+    [item.id,item.jobId,item.workspaceId,item.productId,item.sourceHash,item.status,item.attempts,item.errorCode,item.errorDetail,JSON.stringify(item.statusReasonCodes ?? []),item.updatedAt]);
   }
 
   async updateJob(job: ListingScoreJob): Promise<void> {
@@ -180,9 +192,9 @@ export class PostgresListingAiRepository implements ListingAiRepository {
     const result = await this.pool.query<ItemRow>(`${ITEM_SELECT} WHERE w.public_id=$1 AND j.public_id=$2 ORDER BY i.product_id,i.public_id`, [workspaceId,jobId]); return result.rows.map(itemFromRow);
   }
   async updateJobItem(item: ListingScoreJobItem): Promise<void> {
-    await this.pool.query(`UPDATE voc.listing_score_item SET status=$4,attempts=$5,score_result_public_id=$6,error_code=$7,error_detail_redacted=$8,updated_at=$9
+    await this.pool.query(`UPDATE voc.listing_score_item SET status=$4,attempts=$5,error_code=$6,error_detail_redacted=$7,status_reason_codes=$8::jsonb,updated_at=$9
       WHERE public_id=$1 AND job_id=(SELECT id FROM voc.listing_score_job WHERE public_id=$2) AND workspace_id=(SELECT id FROM voc.workspace WHERE public_id=$3)`,
-    [item.id,item.jobId,item.workspaceId,item.status,item.attempts,item.scoreResultId,item.errorCode,item.errorDetail,item.updatedAt]);
+    [item.id,item.jobId,item.workspaceId,item.status,item.attempts,item.errorCode,item.errorDetail,JSON.stringify(item.statusReasonCodes ?? []),item.updatedAt]);
   }
 
   async createVersion(version: ListingVersion): Promise<ListingVersion> {
@@ -192,8 +204,8 @@ export class PostgresListingAiRepository implements ListingAiRepository {
       await client.query(`SELECT pg_advisory_xact_lock(hashtext($1))`, [`${version.workspaceId}|${version.productId}`]);
       const next = await client.query<{ next: number }>(`SELECT COALESCE(MAX(v.version_no),0)+1 AS next FROM voc.listing_version v JOIN voc.workspace w ON w.id=v.workspace_id WHERE w.public_id=$1 AND v.product_id=$2`, [version.workspaceId,version.productId]);
       const value={...version,versionNo:next.rows[0]?.next??1};
-      await client.query(`INSERT INTO voc.listing_version (public_id,workspace_id,product_id,version_no,base_source_hash,base_score_result_public_id,content,status,created_by_external_id,created_at,adopted_at)
-        SELECT $1,w.id,$3,$4,$5,$6,$7::jsonb,$8,$9,$10,$11 FROM voc.workspace w WHERE w.public_id=$2`, [value.id,value.workspaceId,value.productId,value.versionNo,value.baseSourceHash,value.baseScoreResultId,JSON.stringify(value.content),value.status,value.createdBy,value.createdAt,value.adoptedAt]);
+      await client.query(`INSERT INTO voc.listing_version (public_id,workspace_id,product_id,version_no,base_source_hash,content,status,created_by_external_id,created_at,adopted_at)
+        SELECT $1,w.id,$3,$4,$5,$6::jsonb,$7,$8,$9,$10 FROM voc.workspace w WHERE w.public_id=$2`, [value.id,value.workspaceId,value.productId,value.versionNo,value.baseSourceHash,JSON.stringify(value.content),value.status,value.createdBy,value.createdAt,value.adoptedAt]);
       await client.query('COMMIT'); return value;
     } catch(error){await client.query('ROLLBACK');throw error;} finally{client.release();}
   }

+ 31 - 17
src/modules/listing-ai/routes.ts

@@ -8,8 +8,12 @@ import { sanitizeListingHtml } from './normalization/html-sanitizer.js';
 import { listingCoverage } from './scoring/rule-engine.js';
 import { LISTING_AI_RUBRIC_VERSION } from './scoring/ai-rubric.js';
 import { LISTING_RUBRIC_VERSION } from './scoring/rule-engine.js';
+import { selectCurrentListingScore } from './scoring/score-status.js';
+import { presentListingJob, presentListingJobItem, presentListingProductSummary, presentListingScore } from './presentation/listing-score.presenter.js';
 import {
   createVersionRequestSchema,
+  listingProductPresentationSchema,
+  listingScorePresentationSchema,
   listingPageQuerySchema,
   listingProductQuerySchema,
   listingWorkspaceQuerySchema,
@@ -23,6 +27,7 @@ export function createListingAiRouter(dependencies: {
   defaultWorkspaceId: string;
 }): Router {
   const router = Router();
+  const isV7Job = (rubricVersion: string) => rubricVersion === LISTING_RUBRIC_VERSION || rubricVersion === LISTING_AI_RUBRIC_VERSION;
 
   router.get('/products', async (request, response, next) => {
     try {
@@ -33,7 +38,7 @@ export function createListingAiRouter(dependencies: {
         ...query, workspaceId, cursor: query.cursor ?? null,
       });
       const summary = await dependencies.service.catalogSummary(workspaceId, query.platform);
-      response.json({ ...page, summary });
+      response.json({ ...page, items: page.items.map((item) => listingProductPresentationSchema.parse(presentListingProductSummary(item))), summary });
     } catch (error) { next(error); }
   });
 
@@ -45,8 +50,9 @@ export function createListingAiRouter(dependencies: {
       await dependencies.access.require(request, workspaceId, 'workspace:read');
       const source = await dependencies.service.repository.getSource(workspaceId, query.platform, productId);
       if (!source) throw new ApiError(404, 'listing_product_not_found');
-      const storedScore = await dependencies.service.repository.getLatestScore(workspaceId, productId);
-      const latestScore = storedScore?.sourceHash === source.sourceHash ? storedScore : null;
+      const currentScores = (await dependencies.service.repository.listCurrentScores(workspaceId)).filter((score) => score.productId === productId);
+      const scoreView = selectCurrentListingScore(currentScores, source.sourceHash);
+      const latestScore = scoreView.displayScore;
       const versions = await dependencies.service.repository.listVersions(workspaceId, productId, 10, null);
       response.json({
         source: {
@@ -57,22 +63,23 @@ export function createListingAiRouter(dependencies: {
           },
         },
         coverage: latestScore?.coverage ?? listingCoverage(source),
-        latestScore,
+        currentScore: listingScorePresentationSchema.nullable().parse(presentListingScore(latestScore, source)),
+        rulePrecheck: listingScorePresentationSchema.nullable().parse(presentListingScore(scoreView.latestEffectiveRuleScore, source)),
         versionsSummary: versions,
       });
     } catch (error) { next(error); }
   });
 
-  router.get('/products/:productId/scores/latest', async (request, response, next) => {
+  router.get('/products/:productId/score', async (request, response, next) => {
     try {
-      const query = listingWorkspaceQuerySchema.extend({ rubricVersion: z.string().max(100).optional() }).parse(request.query);
+      const query = listingWorkspaceQuerySchema.parse(request.query);
       const workspaceId = query.workspaceId ?? dependencies.defaultWorkspaceId;
       const productId = z.string().min(1).max(100).parse(request.params.productId);
       await dependencies.access.require(request, workspaceId, 'workspace:read');
       const source = await dependencies.service.repository.getSource(workspaceId, query.platform, productId);
-      const score = await dependencies.service.repository.getLatestScore(workspaceId, productId, query.rubricVersion);
+      const score = source ? selectCurrentListingScore((await dependencies.service.repository.listCurrentScores(workspaceId)).filter((item) => item.productId === productId), source.sourceHash).displayScore : null;
       if (!source || !score || score.sourceHash !== source.sourceHash) throw new ApiError(404, 'listing_score_not_found');
-      response.json({ score });
+      response.json({ score: listingScorePresentationSchema.parse(presentListingScore(score, source)) });
     } catch (error) { next(error); }
   });
 
@@ -89,14 +96,15 @@ export function createListingAiRouter(dependencies: {
         workspaceId, platform: input.platform, scope: input.scope,
         rubricVersion: input.rubricVersion ?? (includeAiScoring ? LISTING_AI_RUBRIC_VERSION : LISTING_RUBRIC_VERSION),
         includeAiSuggestions: includeAiScoring,
+        rescorePolicy: input.rescorePolicy,
         idempotencyKey, requestedBy: getPrincipal(request).userId,
       });
       await dependencies.audit.appendAudit({
         workspaceId, actorUserId: getPrincipal(request).userId,
         action: 'listing.score.requested', entityType: 'listing_score_job', entityId: job.id,
-        metadata: { platform: job.platform, total: job.total, rubricVersion: job.rubricVersion, scoringMode },
+        metadata: { platform: job.platform, total: job.total, rubricVersion: job.rubricVersion, scoringMode, rescorePolicy: job.rescorePolicy },
       });
-      response.status(202).json({ job });
+      response.status(202).json({ job: presentListingJob(job) });
     } catch (error) { next(error); }
   });
 
@@ -105,7 +113,8 @@ export function createListingAiRouter(dependencies: {
       const query = listingPageQuerySchema.extend({ status: z.enum(['queued', 'running', 'completed', 'partial', 'failed', 'cancelled']).or(z.literal('')).default('') }).parse(request.query);
       const workspaceId = query.workspaceId ?? dependencies.defaultWorkspaceId;
       await dependencies.access.require(request, workspaceId, 'workspace:read');
-      response.json(await dependencies.service.repository.listJobs(workspaceId, query.status, query.limit, query.cursor ?? null));
+      const page = await dependencies.service.repository.listJobs(workspaceId, query.status, query.limit, query.cursor ?? null);
+      response.json({ ...page, items: page.items.filter((job) => isV7Job(job.rubricVersion)).map(presentListingJob) });
     } catch (error) { next(error); }
   });
 
@@ -116,8 +125,8 @@ export function createListingAiRouter(dependencies: {
       const jobId = z.uuid().parse(request.params.jobId);
       await dependencies.access.require(request, workspaceId, 'workspace:read');
       const job = await dependencies.service.repository.getJob(workspaceId, jobId);
-      if (!job) throw new ApiError(404, 'score_job_not_found');
-      response.json({ job });
+      if (!job || !isV7Job(job.rubricVersion)) throw new ApiError(404, 'score_job_not_found');
+      response.json({ job: presentListingJob(job) });
     } catch (error) { next(error); }
   });
 
@@ -128,8 +137,9 @@ export function createListingAiRouter(dependencies: {
       const jobId = z.uuid().parse(request.params.jobId);
       await dependencies.access.require(request, workspaceId, 'workspace:read');
       const job = await dependencies.service.repository.getJob(workspaceId, jobId);
-      if (!job) throw new ApiError(404, 'score_job_not_found');
-      response.json(await dependencies.service.repository.listJobItems(workspaceId, jobId, query.status, query.limit, query.cursor ?? null));
+      if (!job || !isV7Job(job.rubricVersion)) throw new ApiError(404, 'score_job_not_found');
+      const page = await dependencies.service.repository.listJobItems(workspaceId, jobId, query.status, query.limit, query.cursor ?? null);
+      response.json({ ...page, items: page.items.map(presentListingJobItem) });
     } catch (error) { next(error); }
   });
 
@@ -139,9 +149,11 @@ export function createListingAiRouter(dependencies: {
       const workspaceId = query.workspaceId ?? dependencies.defaultWorkspaceId;
       const jobId = z.uuid().parse(request.params.jobId);
       await dependencies.access.require(request, workspaceId, 'analysis:run');
+      const currentJob = await dependencies.service.repository.getJob(workspaceId, jobId);
+      if (!currentJob || !isV7Job(currentJob.rubricVersion)) throw new ApiError(404, 'score_job_not_found');
       const job = await dependencies.service.retryJob(workspaceId, jobId);
       await dependencies.audit.appendAudit({ workspaceId, actorUserId: getPrincipal(request).userId, action: 'listing.score.retried', entityType: 'listing_score_job', entityId: job.id, metadata: {} });
-      response.status(202).json({ job });
+      response.status(202).json({ job: presentListingJob(job) });
     } catch (error) { next(error); }
   });
 
@@ -151,9 +163,11 @@ export function createListingAiRouter(dependencies: {
       const workspaceId = query.workspaceId ?? dependencies.defaultWorkspaceId;
       const jobId = z.uuid().parse(request.params.jobId);
       await dependencies.access.require(request, workspaceId, 'analysis:run');
+      const currentJob = await dependencies.service.repository.getJob(workspaceId, jobId);
+      if (!currentJob || !isV7Job(currentJob.rubricVersion)) throw new ApiError(404, 'score_job_not_found');
       const job = await dependencies.service.cancelJob(workspaceId, jobId);
       await dependencies.audit.appendAudit({ workspaceId, actorUserId: getPrincipal(request).userId, action: 'listing.score.cancelled', entityType: 'listing_score_job', entityId: job.id, metadata: {} });
-      response.json({ job });
+      response.json({ job: presentListingJob(job) });
     } catch (error) { next(error); }
   });
 

+ 18 - 1
src/modules/listing-ai/schemas.ts

@@ -15,6 +15,7 @@ export const listingFilterSchema = z.object({
   categoryId: z.string().max(100).optional(),
   itemStatus: z.string().max(100).optional(),
   scoreStatus: z.enum(['unscored', 'scored', 'partial', 'blocked', 'failed']).optional(),
+  aiScoreStatus: z.enum(['not_scored', 'completed', 'partial', 'failed']).optional(),
   coverageStatus: z.enum(['eligible', 'partial', 'blocked']).optional(),
   minScore: z.coerce.number().min(0).max(100).optional(),
   maxScore: z.coerce.number().min(0).max(100).optional(),
@@ -35,6 +36,7 @@ export const scoreJobRequestSchema = z.object({
   scoringMode: z.enum(['rules', 'ai']).optional(),
   rubricVersion: z.string().min(1).max(100).optional(),
   includeAiSuggestions: z.boolean().optional(),
+  rescorePolicy: z.enum(['reuse', 'force']).default('reuse'),
   idempotencyKey: z.string().min(8).max(200).optional(),
 });
 
@@ -43,7 +45,6 @@ export const createVersionRequestSchema = z.object({
   workspaceId: z.string().min(1).optional(),
   platform: z.literal('jd').default('jd'),
   baseSourceHash: z.string().length(64),
-  baseScoreResultId: z.string().nullable().default(null),
   content: z.object({
     title: z.string().max(500).nullable(),
     sellingPoints: z.array(z.string().max(2_000)).max(30),
@@ -52,3 +53,19 @@ export const createVersionRequestSchema = z.object({
     imageUrls: z.array(z.url()).max(100),
   }).optional(),
 });
+
+const listingPresentationItemSchema = z.object({
+  title: z.string(), resultLabel: z.string(), reason: z.string(), action: z.string().nullable(), impactText: z.string(), sources: z.array(z.string()),
+});
+export const listingScorePresentationSchema = z.object({
+  score: z.number().min(0).max(100).nullable(), scoreText: z.string(), statusLabel: z.string(), methodLabel: z.string(), standardLabel: z.string(),
+  dataUpdatedAtText: z.string(), scopeNote: z.string(), dimensions: z.array(z.object({
+    key: z.enum(['title', 'selling_points', 'images', 'description', 'specifications']), name: z.string(), score: z.number().nullable(), maxScore: z.number(),
+    scoreText: z.string(), conclusion: z.string(), items: z.array(listingPresentationItemSchema),
+  })),
+});
+export const listingProductPresentationSchema = z.object({
+  productId: z.string(), title: z.string().nullable(), imageUrl: z.string().nullable(), categoryText: z.string(), itemStatusLabel: z.string(),
+  specificationCount: z.number().nullable(), dataStatusLabel: z.string(), scoreStatusLabel: z.string(), aiStatusLabel: z.string(), score: z.number().nullable(),
+  scoreText: z.string(), methodLabel: z.string(), syncedAt: z.string(),
+});

+ 92 - 193
src/modules/listing-ai/scoring/ai-rubric.ts

@@ -1,254 +1,153 @@
 import { z } from 'zod';
-import type {
-  ListingDimension,
-  ListingDimensionScore,
-  ListingRuleEvidence,
-  ListingScoreResult,
-} from '../domain.js';
+import type { ListingDimension, ListingDimensionScore, ListingRuleEvidence, ListingScoreResult, ListingSourceSnapshot } from '../domain.js';
+import { LISTING_DIMENSION_MAX } from './rule-engine.js';
 
-export const LISTING_AI_RUBRIC_VERSION = 'listing-jd-ai-v1';
-export const LISTING_AI_PROMPT_VERSION = 'listing-ai-score-p1';
+export const LISTING_AI_RUBRIC_VERSION = 'listing-jd-ai-v5';
+export const LISTING_AI_PROMPT_VERSION = 'listing-ai-score-p6';
 
-const levelSchema = z.enum(['unknown', 'fail', 'weak', 'pass', 'strong']);
+const levelSchema = z.enum(['fail', 'weak', 'pass', 'strong']);
 export type ListingAiLevel = z.infer<typeof levelSchema>;
 
 const criterionIds = [
-  'title.search_intent', 'title.information_hierarchy', 'title.differentiation', 'title.factual_compliance',
-  'selling_points.customer_benefit', 'selling_points.specific_credible', 'selling_points.differentiation',
-  'selling_points.purchase_concerns', 'selling_points.consistency',
-  'description.completeness', 'description.structure_readability', 'description.benefit_evidence',
-  'description.objection_handling', 'description.consistency',
-  'specifications.decision_relevance', 'specifications.naming_clarity',
-  'specifications.category_completeness', 'specifications.consistency',
+  'title.search_intent', 'title.information_hierarchy', 'title.attribute_relevance', 'title.differentiation', 'title.factual_consistency',
+  'selling_points.fab_benefit', 'selling_points.specific_credible', 'selling_points.differentiation', 'selling_points.consistency',
 ] as const;
-
 export type ListingAiCriterionId = typeof criterionIds[number];
 
-export interface ListingAiCriterionDefinition {
-  id: ListingAiCriterionId;
-  dimension: Exclude<ListingDimension, 'images'>;
-  label: string;
-  maxPoints: number;
-}
-
+export interface ListingAiCriterionDefinition { id: ListingAiCriterionId; dimension: Exclude<ListingDimension, 'images'>; label: string; maxPoints: number; }
 export const LISTING_AI_CRITERIA: readonly ListingAiCriterionDefinition[] = [
-  { id: 'title.search_intent', dimension: 'title', label: '搜索意图匹配', maxPoints: 4 },
+  { id: 'title.search_intent', dimension: 'title', label: '搜索意图与品类清晰度', maxPoints: 4 },
   { id: 'title.information_hierarchy', dimension: 'title', label: '信息层级与可读性', maxPoints: 3 },
+  { id: 'title.attribute_relevance', dimension: 'title', label: '决策属性相关性', maxPoints: 3 },
   { id: 'title.differentiation', dimension: 'title', label: '差异化价值表达', maxPoints: 3 },
-  { id: 'title.factual_compliance', dimension: 'title', label: '事实一致性与合规', maxPoints: 2 },
-  { id: 'selling_points.customer_benefit', dimension: 'selling_points', label: '客户收益表达', maxPoints: 4 },
-  { id: 'selling_points.specific_credible', dimension: 'selling_points', label: '具体性与可信度', maxPoints: 4 },
-  { id: 'selling_points.differentiation', dimension: 'selling_points', label: '卖点差异化', maxPoints: 3 },
-  { id: 'selling_points.purchase_concerns', dimension: 'selling_points', label: '购买顾虑覆盖', maxPoints: 3 },
-  { id: 'selling_points.consistency', dimension: 'selling_points', label: '与标题规格一致', maxPoints: 2 },
-  { id: 'description.completeness', dimension: 'description', label: '详情信息完整度', maxPoints: 4 },
-  { id: 'description.structure_readability', dimension: 'description', label: '详情结构与可读性', maxPoints: 3 },
-  { id: 'description.benefit_evidence', dimension: 'description', label: '卖点与证据表达', maxPoints: 3 },
-  { id: 'description.objection_handling', dimension: 'description', label: '购买顾虑处理', maxPoints: 2 },
-  { id: 'description.consistency', dimension: 'description', label: '详情与标题规格一致', maxPoints: 2 },
-  { id: 'specifications.decision_relevance', dimension: 'specifications', label: '购买决策有效性', maxPoints: 3 },
-  { id: 'specifications.naming_clarity', dimension: 'specifications', label: '规格命名清晰度', maxPoints: 2 },
-  { id: 'specifications.category_completeness', dimension: 'specifications', label: '类目规格完整性', maxPoints: 3 },
-  { id: 'specifications.consistency', dimension: 'specifications', label: '规格参数一致性', maxPoints: 2 },
+  { id: 'title.factual_consistency', dimension: 'title', label: '跨字段事实一致性', maxPoints: 3 },
+  { id: 'selling_points.fab_benefit', dimension: 'selling_points', label: '特性、优势与用户收益', maxPoints: 6 },
+  { id: 'selling_points.specific_credible', dimension: 'selling_points', label: '表达具体且可信', maxPoints: 5 },
+  { id: 'selling_points.differentiation', dimension: 'selling_points', label: '卖点差异清晰', maxPoints: 5 },
+  { id: 'selling_points.consistency', dimension: 'selling_points', label: '与标题和规格一致', maxPoints: 4 },
 ] as const;
 
 const assessmentSchema = z.object({
-  criterionId: z.enum(criterionIds),
-  level: levelSchema,
-  evidence: z.array(z.string().min(1).max(500)).max(3),
-  reason: z.string().min(1).max(1_000),
-  confidence: z.number().min(0).max(1),
+  criterionId: z.enum(criterionIds), level: levelSchema, evidenceIds: z.array(z.string().min(1).max(200)).max(3),
+  reason: z.string().min(1).max(1_000), confidence: z.number().min(0).max(1),
 }).strict();
-
 export const listingAiScoreOutputSchema = z.object({
-  assessments: z.array(assessmentSchema).length(criterionIds.length),
-  summary: z.string().min(1).max(2_000),
-  suggestions: z.array(z.string().min(1).max(1_000)).max(8),
+  assessments: z.array(assessmentSchema).length(criterionIds.length), summary: z.string().min(1).max(2_000), suggestions: z.array(z.string().min(1).max(1_000)).max(8),
 }).strict().superRefine((value, context) => {
   const ids = value.assessments.map((item) => item.criterionId);
-  for (const id of criterionIds) {
-    const count = ids.filter((candidate) => candidate === id).length;
-    if (count !== 1) context.addIssue({ code: 'custom', path: ['assessments'], message: `criterion ${id} must appear exactly once` });
-  }
+  for (const id of criterionIds) if (ids.filter((candidate) => candidate === id).length !== 1) context.addIssue({ code: 'custom', path: ['assessments'], message: `criterion ${id} must appear exactly once` });
 });
-
 export type ListingAiScoreOutput = z.infer<typeof listingAiScoreOutputSchema>;
 
 export function parseListingAiScoreOutput(content: string): ListingAiScoreOutput | null {
   const trimmed = content.trim().replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/i, '');
-  const start = trimmed.indexOf('{');
-  const end = trimmed.lastIndexOf('}');
+  const start = trimmed.indexOf('{'); const end = trimmed.lastIndexOf('}');
   if (start < 0 || end <= start) return null;
-  let parsed: unknown;
-  try { parsed = JSON.parse(trimmed.slice(start, end + 1)); } catch { return null; }
+  let parsed: unknown; try { parsed = JSON.parse(trimmed.slice(start, end + 1)); } catch { return null; }
   if (!parsed || typeof parsed !== 'object') return null;
   const root = parsed as Record<string, unknown>;
   const nested = root['result'] && typeof root['result'] === 'object' ? root['result'] as Record<string, unknown> : root;
-  const assessmentContainer = nested['assessments'] ?? nested['criteria'] ?? nested['scores'];
-  const rawAssessments = Array.isArray(assessmentContainer)
-    ? assessmentContainer
-    : assessmentContainer && typeof assessmentContainer === 'object'
-      ? Object.entries(assessmentContainer as Record<string, unknown>).map(([criterionId, value]) => (
-        value && typeof value === 'object' ? { criterionId, ...(value as Record<string, unknown>) } : value
-      ))
-      : [];
+  const container = nested['assessments'] ?? nested['criteria'] ?? nested['scores'];
+  const raw = Array.isArray(container) ? container : container && typeof container === 'object'
+    ? Object.entries(container as Record<string, unknown>).map(([criterionId, value]) => value && typeof value === 'object' ? { criterionId, ...(value as Record<string, unknown>) } : value) : [];
   const byId = new Map<string, Record<string, unknown>>();
-  for (const item of rawAssessments) {
+  for (const item of raw) {
     if (!item || typeof item !== 'object') continue;
     const value = item as Record<string, unknown>;
-    const candidateId = value['criterionId'] ?? value['criterion_id'] ?? value['id'];
-    const rawId = typeof candidateId === 'string' ? candidateId.replace(/^ai\./, '') : '';
-    if (criterionIds.includes(rawId as ListingAiCriterionId) && !byId.has(rawId)) byId.set(rawId, value);
+    const candidate = value['criterionId'] ?? value['criterion_id'] ?? value['id'];
+    const id = typeof candidate === 'string' ? candidate.replace(/^ai\./, '') : '';
+    if (criterionIds.includes(id as ListingAiCriterionId) && !byId.has(id)) byId.set(id, value);
   }
-  // A fixed rubric is only reproducible when every criterion is explicitly returned.
-  // Do not silently turn a truncated/model-shaped response into a valid all-unknown score.
   if (byId.size !== criterionIds.length) return null;
   const assessments = criterionIds.map((criterionId) => {
-    const value = byId.get(criterionId);
-    if (!value) return { criterionId, level: 'unknown', evidence: [], reason: '模型未返回该固定评分项', confidence: 0 };
-    const rawEvidence = value['evidence'] ?? value['evidences'] ?? value['citations'];
-    const evidence = typeof rawEvidence === 'string'
-      ? rawEvidence.trim() ? [rawEvidence.trim()] : []
-      : Array.isArray(rawEvidence) ? rawEvidence.filter((item): item is string => typeof item === 'string' && Boolean(item.trim())).map((item) => item.trim()).slice(0, 3) : [];
-    const rawLevel = typeof value['level'] === 'string' ? value['level'].toLowerCase() : 'unknown';
-    const level = ['unknown', 'fail', 'weak', 'pass', 'strong'].includes(rawLevel) ? rawLevel : 'unknown';
+    const value = byId.get(criterionId)!;
+    const rawEvidence = value['evidenceIds'] ?? value['evidence_ids'];
+    const evidenceIds = typeof rawEvidence === 'string' ? rawEvidence.trim() ? [rawEvidence.trim()] : [] : Array.isArray(rawEvidence)
+      ? rawEvidence.filter((item): item is string => typeof item === 'string' && Boolean(item.trim())).map((item) => item.trim()).slice(0, 3) : [];
+    const candidateLevel = typeof value['level'] === 'string' ? value['level'].toLowerCase() : 'fail';
+    const level = ['fail', 'weak', 'pass', 'strong'].includes(candidateLevel) ? candidateLevel : 'fail';
     const rawConfidence = typeof value['confidence'] === 'number' && Number.isFinite(value['confidence']) ? value['confidence'] : 0;
-    const confidence = Math.max(0, Math.min(1, rawConfidence > 1 ? rawConfidence / 100 : rawConfidence));
-    return {
-      criterionId,
-      level,
-      evidence,
-      reason: typeof (value['reason'] ?? value['rationale']) === 'string' && String(value['reason'] ?? value['rationale']).trim()
-        ? String(value['reason'] ?? value['rationale']).trim().slice(0, 1_000)
-        : '模型未提供判定理由',
-      confidence,
-    };
+    return { criterionId, level, evidenceIds, reason: typeof (value['reason'] ?? value['rationale']) === 'string' && String(value['reason'] ?? value['rationale']).trim() ? String(value['reason'] ?? value['rationale']).trim().slice(0, 1_000) : '模型未提供判定理由', confidence: Math.max(0, Math.min(1, rawConfidence > 1 ? rawConfidence / 100 : rawConfidence)) };
   });
-  const normalized = {
-    assessments,
-    summary: typeof nested['summary'] === 'string' && nested['summary'].trim() ? nested['summary'].slice(0, 2_000) : 'AI 已按固定 Rubric 完成评分',
-    suggestions: Array.isArray(nested['suggestions']) ? nested['suggestions'].filter((item): item is string => typeof item === 'string' && Boolean(item.trim())).map((item) => item.trim()).slice(0, 8) : [],
-  };
+  const normalized = { assessments, summary: typeof nested['summary'] === 'string' && nested['summary'].trim() ? nested['summary'].slice(0, 2_000) : 'AI 已按固定 Rubric 完成评分', suggestions: Array.isArray(nested['suggestions']) ? nested['suggestions'].filter((item): item is string => typeof item === 'string' && Boolean(item.trim())).map((item) => item.trim()).slice(0, 8) : [] };
   const validated = listingAiScoreOutputSchema.safeParse(normalized);
   return validated.success ? validated.data : null;
 }
 
-const hardMaximum: Record<ListingDimension, number> = {
-  title: 8,
-  selling_points: 4,
-  images: 20,
-  description: 6,
-  specifications: 10,
-};
-
-const levelFactor: Record<Exclude<ListingAiLevel, 'unknown'>, number> = {
-  fail: 0,
-  weak: 0.35,
-  pass: 0.7,
-  strong: 1,
-};
-
+const levelFactor: Record<ListingAiLevel, number> = { fail: 0, weak: 0.35, pass: 0.7, strong: 1 };
 function half(value: number): number { return Math.round(value * 2) / 2; }
 
-function hardEvidence(dimension: ListingDimension, points: number, maximum: number): ListingRuleEvidence {
+function aiEvidence(definition: ListingAiCriterionDefinition, assessment: ListingAiScoreOutput['assessments'][number], catalog: Map<string, ListingAiEvidenceEntry>): ListingRuleEvidence {
+  const points = half(definition.maxPoints * levelFactor[assessment.level]);
   return {
-    ruleId: `baseline.${dimension}`,
-    fieldPath: dimension,
-    outcome: points >= maximum * 0.7 ? 'pass' : 'fail',
-    delta: half(points - maximum),
-    message: `结构化规则部分:${points} / ${maximum} 分`,
-    source: 'rule',
-    pointsAwarded: points,
-    maxPoints: maximum,
+    ruleId: `ai.${definition.id}`, fieldPath: `ai.${definition.dimension}`, outcome: ['pass', 'strong'].includes(assessment.level) ? 'pass' : 'fail',
+    delta: half(points - definition.maxPoints), message: `${definition.label}:${assessment.reason}`, source: 'ai', level: assessment.level,
+    pointsAwarded: points, maxPoints: definition.maxPoints, confidence: assessment.confidence,
+    citations: assessment.evidenceIds.map((id) => catalog.get(id)).filter((item): item is ListingAiEvidenceEntry => Boolean(item)).map((item) => `${item.path}=${item.value}`),
   };
 }
 
-function aiEvidence(definition: ListingAiCriterionDefinition, assessment: ListingAiScoreOutput['assessments'][number]): ListingRuleEvidence {
-  const points = assessment.level === 'unknown' ? 0 : half(definition.maxPoints * levelFactor[assessment.level]);
-  return {
-    ruleId: `ai.${definition.id}`,
-    fieldPath: `ai.${definition.dimension}`,
-    outcome: assessment.level === 'unknown' ? 'unknown' : ['pass', 'strong'].includes(assessment.level) ? 'pass' : 'fail',
-    delta: assessment.level === 'unknown' ? 0 : half(points - definition.maxPoints),
-    message: `${definition.label}:${assessment.reason}`,
-    source: 'ai',
-    level: assessment.level,
-    pointsAwarded: points,
-    maxPoints: definition.maxPoints,
-    confidence: assessment.confidence,
-    citations: assessment.evidence,
+export interface ListingAiEvidenceEntry { id: string; path: string; value: string; }
+
+export function listingAiEvidenceCatalog(source: ListingSourceSnapshot): ListingAiEvidenceEntry[] {
+  const rows: ListingAiEvidenceEntry[] = [];
+  const add = (path: string, value: unknown) => {
+    if (value === null || value === undefined || !String(value).trim()) return;
+    rows.push({ id: path, path, value: String(value).replace(/\s+/g, ' ').trim().slice(0, 500) });
   };
+  add('title', source.title); add('brand.name', source.brand.name);
+  source.categoryContext?.coreTerms.forEach((value, index) => add(`categoryContext.coreTerms[${index}]`, value));
+  source.marketing?.sellingPoints.forEach((value, index) => add(`marketing.sellingPoints[${index}]`, value.value));
+  source.attributes.forEach((attribute, index) => add(`attributes[${index}].${attribute.name}`, attribute.values.join('、')));
+  source.skus.slice(0, 15).forEach((sku, index) => { add(`skus[${index}].name`, sku.name); sku.attributes.forEach((attribute) => add(`skus[${index}].${attribute.name}`, attribute.values.join('、'))); });
+  Object.entries(source.dimensions).forEach(([key, value]) => add(`dimensions.${key}`, value));
+  if (source.descriptionStructure) Object.entries(source.descriptionStructure).forEach(([key, value]) => add(`descriptionStructure.${key}`, value));
+  return [...new Map(rows.map((row) => [row.id, row])).values()];
 }
 
-export function composeListingAiScore(input: {
-  baseline: ListingScoreResult;
-  output: ListingAiScoreOutput;
-  model: string;
-  now: string;
-}): ListingScoreResult {
-  const assessmentById = new Map(input.output.assessments.map((item) => [item.criterionId, item]));
-  const dimensions = input.baseline.dimensions.map<ListingDimensionScore>((baselineDimension) => {
-    if (baselineDimension.dimension === 'images') {
-      const score = baselineDimension.score;
-      return {
-        ...baselineDimension,
-        evidence: score === null ? baselineDimension.evidence : [
-          hardEvidence('images', score, 20),
-          {
-            ruleId: 'ai.images.visual_unavailable', fieldPath: 'images', outcome: 'unknown', delta: 0,
-            message: '当前模型未验证多模态视觉能力;本维度仅使用图片结构规则评分', source: 'ai', level: 'unknown',
-            pointsAwarded: 0, maxPoints: 0, confidence: 0, citations: [],
-          },
-        ],
-        suggestions: score === null ? baselineDimension.suggestions : ['接入并验证多模态模型后,再评估构图、清晰度和文字可读性'],
-      };
-    }
-
-    const maximum = hardMaximum[baselineDimension.dimension];
-    if (baselineDimension.score === null) return baselineDimension;
-    const hardPoints = half((baselineDimension.score / 20) * maximum);
-    const definitions = LISTING_AI_CRITERIA.filter((item) => item.dimension === baselineDimension.dimension);
-    const rows = definitions.map((definition) => aiEvidence(definition, assessmentById.get(definition.id)!));
-    const hasUnknown = rows.some((row) => row.level === 'unknown');
-    const semanticPoints = rows.reduce((sum, row) => sum + (row.pointsAwarded ?? 0), 0);
-    const score = hasUnknown ? null : half(Math.min(20, hardPoints + semanticPoints));
+export function composeListingAiScore(input: { source: ListingSourceSnapshot; baseline: ListingScoreResult; output: ListingAiScoreOutput; model: string; now: string }): ListingScoreResult {
+  const assessments = new Map(input.output.assessments.map((item) => [item.criterionId, item]));
+  const evidenceCatalog = new Map(listingAiEvidenceCatalog(input.source).map((item) => [item.id, item]));
+  const assessmentFor = (definition: ListingAiCriterionDefinition): ListingAiScoreOutput['assessments'][number] => {
+    const assessment = assessments.get(definition.id)!;
+    if (!assessment.evidenceIds.length || assessment.evidenceIds.some((id) => !evidenceCatalog.has(id))) throw new Error('listing_ai_evidence_invalid');
+    return assessment;
+  };
+  const dimensions = input.baseline.dimensions.map<ListingDimensionScore>((baseline) => {
+    const definitions = LISTING_AI_CRITERIA.filter((item) => item.dimension === baseline.dimension);
+    const aiRows = definitions.map((definition) => aiEvidence(definition, assessmentFor(definition), evidenceCatalog));
+    const evidence = [...baseline.evidence, ...aiRows];
+    const known = evidence.filter((row) => row.outcome !== 'unknown');
+    const knownScore = half(known.reduce((sum, row) => sum + (row.pointsAwarded ?? 0), 0));
+    const knownMaxScore = known.reduce((sum, row) => sum + (row.maxPoints ?? 0), 0);
+    const complete = knownMaxScore === LISTING_DIMENSION_MAX[baseline.dimension];
     return {
-      dimension: baselineDimension.dimension,
-      score,
-      maxScore: 20,
-      coverage: baselineDimension.coverage,
-      status: hasUnknown ? 'partial' : baselineDimension.status,
-      evidence: [hardEvidence(baselineDimension.dimension, hardPoints, maximum), ...rows],
-      suggestions: rows.filter((row) => row.outcome === 'fail').map((row) => row.message),
+      dimension: baseline.dimension, score: complete ? knownScore : null, maxScore: LISTING_DIMENSION_MAX[baseline.dimension], knownScore, knownMaxScore,
+      coverage: Math.round(knownMaxScore / LISTING_DIMENSION_MAX[baseline.dimension] * 100), status: knownMaxScore === 0 ? 'blocked' : complete ? 'scored' : 'partial', evidence,
+      suggestions: evidence.filter((row) => row.outcome === 'fail').map((row) => row.message),
     };
   });
-  const numeric = dimensions.map((item) => item.score).filter((value): value is number => value !== null);
-  const confidences = input.output.assessments.filter((item) => item.level !== 'unknown').map((item) => item.confidence);
+  const knownOverallScore = half(dimensions.reduce((sum, item) => sum + (item.knownScore ?? item.score ?? 0), 0));
+  const knownOverallMaxScore = dimensions.reduce((sum, item) => sum + (item.knownMaxScore ?? (item.score === null ? 0 : item.maxScore)), 0);
+  const complete = dimensions.every((item) => item.status === 'scored' && item.score !== null);
+  const confidences = input.output.assessments.map((item) => item.confidence);
   return {
-    ...input.baseline,
-    rubricVersion: LISTING_AI_RUBRIC_VERSION,
-    overallScore: numeric.length === dimensions.length ? half(numeric.reduce((sum, value) => sum + value, 0)) : null,
-    dimensions,
-    aiStatus: 'completed',
-    aiSuggestions: input.output.suggestions.map((item) => item.trim()).filter(Boolean).slice(0, 8),
-    aiCandidate: null,
-    model: input.model,
-    promptVersion: LISTING_AI_PROMPT_VERSION,
-    scoreKind: 'hybrid_ai',
-    baselineOverallScore: input.baseline.overallScore,
-    aiConfidence: confidences.length ? Math.round((confidences.reduce((sum, value) => sum + value, 0) / confidences.length) * 1000) / 1000 : null,
-    createdAt: input.now,
+    ...input.baseline, rubricVersion: LISTING_AI_RUBRIC_VERSION, overallScore: complete ? knownOverallScore : null, knownOverallScore, knownOverallMaxScore, dimensions,
+    aiStatus: 'completed', aiSuggestions: input.output.suggestions.map((item) => item.trim()).filter(Boolean).slice(0, 8), aiCandidate: null, model: input.model,
+    promptVersion: LISTING_AI_PROMPT_VERSION, scoreKind: 'hybrid_ai', baselineOverallScore: input.baseline.overallScore,
+    aiConfidence: confidences.length ? Math.round(confidences.reduce((sum, value) => sum + value, 0) / confidences.length * 1000) / 1000 : null, unknownCriteria: [], createdAt: input.now,
   };
 }
 
 export function listingAiRubricPrompt(): string {
   const criteria = LISTING_AI_CRITERIA.map((item) => `- ${item.id}(${item.label},${item.maxPoints}分)`).join('\n');
   return [
-    '你是京东 Listing 质量评分裁判,不是文案生成器。只能依据输入 JSON,不得使用外部知识补造商品事实。',
-    '你不能直接给总分,也不能改变标准和权重。必须对下列每个 criterionId 恰好返回一次判定:',
-    criteria,
-    '等级锚点固定:strong=证据充分且表现优秀;pass=基本达标且有明确证据;weak=部分满足或表达较弱;fail=明确缺失、矛盾或明显不合格;unknown=输入没有足够证据。',
-    'evidence 必须逐字引用输入中的短文本或规格;没有证据时使用空数组并判定 unknown。禁止把销量、评价、竞品、认证或功效当成事实,除非输入明确提供。',
-    `只返回合法 JSON:{"assessments":[{"criterionId":"${criterionIds[0]}","level":"pass","evidence":["原文"],"reason":"理由","confidence":0.8}],"summary":"总体说明","suggestions":["改进建议"]}。assessments 必须包含全部 ${criterionIds.length} 项且不得重复。`,
+    '你是京东 Listing 五维质量评分裁判,不是文案生成器。只能依据输入 JSON,不得使用外部知识补造商品事实。',
+    '权重固定为标题30、核心卖点25、图片资产完整度20、详情15、规格10。图片当前不做视觉评分。你不能直接给总分,也不能改变标准和权重。',
+    '必须对下列每个 criterionId 恰好返回一次判定:', criteria,
+    'strong=证据充分且表现优秀;pass=基本达标;weak=部分满足;fail=输入明确证明缺失、矛盾或不合格。每项必须给出数值等级,不允许 unknown。',
+    '只评价标题和当前 API 返回的商品广告语、有效商品规格短标题。不得评价用户反馈、竞品、图片视觉内容、详情图片文字或未验证的类目必填项。',
+    '每条判定必须从 evidenceCatalog 选择 1–3 个稳定 id;不得返回证据原文、不得发明 id。禁止补造销量、评价、竞品、认证、功效或消费者痛点。',
+    `只返回合法 JSON:{"assessments":[{"criterionId":"${criterionIds[0]}","level":"pass","evidenceIds":["title"],"reason":"理由","confidence":0.8}],"summary":"总体说明","suggestions":["改进建议"]}。assessments 必须包含全部 ${criterionIds.length} 项且不得重复。`,
   ].join('\n');
 }

+ 90 - 0
src/modules/listing-ai/scoring/jd-category-rules.ts

@@ -0,0 +1,90 @@
+export const JD_CATEGORY_RULE_VERSION = 'jd-category-rules-2026-08-v3';
+
+export interface JdCategoryRule {
+  categoryId: string | null;
+  pathNames: string[];
+  displayName: string | null;
+  coreTerms: string[];
+  aliases: string[];
+  requiredSpecificationNames: string[];
+  qualificationNames: string[];
+  ruleVersion: string | null;
+}
+
+const COMMON_SPECIFICATIONS = ['品牌', '型号', '材质', '尺寸', '重量', '产地'];
+const TERM_SEPARATOR = /[\//、||>>,,]+/u;
+const GENERIC_CATEGORIES = new Set(['其它商用电器', '特殊商品', '商用电器配件']);
+const REVIEWED_ALIASES: Readonly<Record<string, readonly string[]>> = {
+  '商用净水设备': ['净水器', '净水机', '直饮机', '直饮水机', '饮水机', '净饮机', '开水器', '开水机'],
+  '商用消毒柜': ['消毒柜', '消毒机'],
+  '商用开水器/蒸气奶泡机': ['开水器', '开水机', '烧水机', '饮水机', '奶泡机', '蒸气奶泡机', '蒸汽奶泡机'],
+  '商用台式电磁炉/电陶炉': ['电磁炉', '电磁灶', '电池炉', '电陶炉'],
+  '商用豆浆机/破壁机': ['豆浆机', '破壁机', '料理机'],
+  '商用立式电磁炉/大锅灶': ['电磁炉', '电磁灶', '大锅灶', '炒炉', '电炒炉'],
+  '蒸柜/蒸饭车': ['蒸柜', '蒸饭柜', '蒸饭车'],
+  '商用绞肉机/切肉机/切片机': ['绞肉机', '切肉机', '切片机'],
+  '电炸炉/炸锅': ['电炸炉', '炸炉', '炸锅'],
+  '商用和面机/打蛋机': ['和面机', '揉面机', '搅面机', '打蛋机', '厨师机'],
+  '展示柜': ['展示柜'],
+  '商用烤箱/烘烤炉': ['烤箱', '烘烤炉', '烤炉'],
+  '制冰机': ['制冰机'],
+  '保温售饭台': ['保温售饭台', '售饭台', '保温台'],
+  '商用冰箱': ['商用冰箱', '冷柜', '冷藏柜', '冷冻柜'],
+  '商用面条/压面机': ['面条机', '压面机', '面机'],
+  '保鲜工作台': ['保鲜工作台', '冷藏工作台', '冷冻工作台', '工作台冰箱'],
+  '刨冰/沙冰机': ['刨冰机', '沙冰机'],
+  '商用洗碗机': ['洗碗机'],
+  '商用电饭煲': ['电饭煲', '电饭锅'],
+  '商用油烟机': ['油烟机'],
+  '商用电饼铛/煎包锅贴机/烤饼机': ['电饼铛', '煎包锅贴机', '锅贴机', '烤饼机'],
+  '商用咖啡机': ['咖啡机'],
+  '商用电压力锅': ['电压力锅', '压力锅'],
+  '商用家电清洗机': ['清洗机'],
+  '醒发箱/发酵设备': ['醒发箱', '发酵箱', '发酵设备'],
+  '商用洗衣/干衣设备': ['洗衣机', '干衣机', '烘干机'],
+  '饮料机': ['饮料机'],
+  '封口/封杯机': ['封口机', '封杯机'],
+  '蒸炉/蒸包炉': ['蒸炉', '蒸包炉'],
+  '煮面桶/汤桶': ['煮面桶', '汤桶'],
+  '商用磨豆机': ['磨豆机'],
+  '商用切菜机': ['切菜机'],
+  '油烟净化器': ['油烟净化器', '净化器'],
+  '破壁机': ['破壁机'],
+  '扒炉/铁板烧/手抓饼炉': ['扒炉', '铁板烧', '手抓饼炉'],
+  '果糖机': ['果糖机'],
+};
+
+function normalize(value: string): string { return value.normalize('NFKC').replace(/\s+/g, ' ').trim(); }
+
+export function jdCategoryTerms(categoryNames: string[]): string[] {
+  const candidates = categoryNames.map(normalize).filter(Boolean)
+    .flatMap((name) => name.split(TERM_SEPARATOR).map(normalize))
+    .filter((term) => term.length >= 2 && term.length <= 40);
+  return [...new Set(candidates)];
+}
+
+export function resolveJdCategoryRule(categoryNames: string[], categoryId: string | null = null): JdCategoryRule {
+  const pathNames = [...new Set(categoryNames.map(normalize).filter(Boolean))];
+  if (!pathNames.length) return { categoryId, pathNames, displayName: null, coreTerms: [], aliases: [], requiredSpecificationNames: [], qualificationNames: [], ruleVersion: null };
+  const category = pathNames.at(-1)!;
+  const coreTerms = GENERIC_CATEGORIES.has(category) ? [] : jdCategoryTerms([category]);
+  const aliases = GENERIC_CATEGORIES.has(category) ? [] : [...new Set([...(REVIEWED_ALIASES[category] ?? []), ...coreTerms])];
+  const qualifications: string[] = [];
+  const required = [...COMMON_SPECIFICATIONS];
+  if (/(医疗|医用|器械)/u.test(category)) qualifications.push('医疗器械注册证');
+  if (/(保健|营养补充)/u.test(category)) qualifications.push('蓝帽子', 'SC');
+  if (/(食品|饮料|酒|茶|零食|生鲜)/u.test(category)) qualifications.push('SC', '营养成分表');
+  if (/(化妆|护肤|彩妆|洗护)/u.test(category)) qualifications.push('备案号');
+  if (/(家电|电器|冰箱|冷柜|空调|洗衣机|热水器|厨电)/u.test(category)) qualifications.push('3C认证', '能效');
+  if (/(服装|鞋|靴|箱包)/u.test(category)) required.push('尺码对照');
+  return {
+    categoryId,
+    pathNames,
+    displayName: category,
+    coreTerms,
+    aliases,
+    requiredSpecificationNames: [...new Set(required)],
+    qualificationNames: [...new Set(qualifications)],
+    ruleVersion: JD_CATEGORY_RULE_VERSION,
+  };
+}

+ 137 - 134
src/modules/listing-ai/scoring/rule-engine.ts

@@ -1,187 +1,190 @@
 import { createHash, randomUUID } from 'node:crypto';
-import type {
-  ListingCoverage,
-  ListingDimension,
-  ListingDimensionScore,
-  ListingRuleEvidence,
-  ListingScoreResult,
-  ListingSourceSnapshot,
-} from '../domain.js';
-
-export const LISTING_RUBRIC_VERSION = 'listing-jd-v2';
+import type { ListingComplianceFinding, ListingComplianceResult, ListingCoverage, ListingDimension, ListingDimensionScore, ListingRuleEvidence, ListingScoreResult, ListingSourceSnapshot } from '../domain.js';
+import { resolveJdCategoryRule } from './jd-category-rules.js';
+
+export const LISTING_RUBRIC_VERSION = 'listing-jd-v7';
+export const LISTING_RULE_SET_VERSION = 'jd-five-dimension-2026-08-v4';
+export const LISTING_DIMENSION_MAX: Readonly<Record<ListingDimension, number>> = { title: 30, selling_points: 25, images: 20, description: 15, specifications: 10 };
+export const LISTING_HARD_MAX: Readonly<Record<ListingDimension, number>> = { title: 14, selling_points: 5, images: 20, description: 15, specifications: 10 };
 const DIMENSIONS: ListingDimension[] = ['title', 'selling_points', 'images', 'description', 'specifications'];
 
 function textFromHtml(value: string | null): string {
-  return (value ?? '').replace(/<script[\s\S]*?<\/script>/gi, ' ').replace(/<style[\s\S]*?<\/style>/gi, ' ')
-    .replace(/<[^>]+>/g, ' ').replace(/&nbsp;/gi, ' ').replace(/\s+/g, ' ').trim();
+  return (value ?? '').replace(/<script[\s\S]*?<\/script>/gi, ' ').replace(/<style[\s\S]*?<\/style>/gi, ' ').replace(/<[^>]+>/g, ' ').replace(/&nbsp;/gi, ' ').replace(/\s+/g, ' ').trim();
 }
 
-function evidence(
-  ruleId: string,
-  fieldPath: string,
-  pass: boolean | null,
-  penalty: number,
-  message: string,
-): ListingRuleEvidence {
-  return { ruleId, fieldPath, outcome: pass === null ? 'unknown' : pass ? 'pass' : 'fail', delta: pass === false ? -penalty : 0, message };
-}
+export function normalizeListingTitle(value: string | null): string { return (value ?? '').normalize('NFKC').replace(/\s+/g, ' ').trim(); }
+export function listingTitleVisibleCharacters(value: string): number { return Array.from(normalizeListingTitle(value)).length; }
+/** JD title length is measured in normalized Unicode visible characters. */
+export function jdTitleDisplayUnits(value: string): number { return listingTitleVisibleCharacters(value); }
 
-function finish(dimension: ListingDimension, rows: ListingRuleEvidence[], covered: number, total: number): ListingDimensionScore {
-  const coverage = Math.round((covered / total) * 100);
-  if (covered === 0) {
-    return { dimension, score: null, maxScore: 20, coverage: 0, status: 'blocked', evidence: rows, suggestions: rows.filter((row) => row.outcome !== 'pass').map((row) => row.message) };
+function titleAfterLeadingBrand(title: string, brands: string[]): string {
+  let output = title;
+  for (const brand of [...new Set(brands.map(normalizeListingTitle).filter(Boolean))].sort((a, b) => b.length - a.length)) {
+    if (output.toLocaleLowerCase().startsWith(brand.toLocaleLowerCase())) { output = output.slice(brand.length); break; }
   }
-  const score = Math.max(0, Math.min(20, 20 + rows.reduce((sum, row) => sum + row.delta, 0)));
+  return output
+    .replace(/^\s*(?:[((][A-Za-z0-9 .&+_-]{1,40}[))])?\s*/u, '')
+    .replace(/^[-—–·||::]+\s*/u, '')
+    .trim();
+}
+
+function criterion(ruleId: string, fieldPath: string, outcome: 'pass' | 'fail' | 'unknown', maxPoints: number, message: string): ListingRuleEvidence {
+  const pointsAwarded = outcome === 'pass' ? maxPoints : 0;
+  return { ruleId, fieldPath, outcome, delta: outcome === 'unknown' ? 0 : pointsAwarded - maxPoints, message, source: 'rule', pointsAwarded, maxPoints };
+}
+
+function finish(dimension: ListingDimension, rows: ListingRuleEvidence[], blocked = false): ListingDimensionScore {
+  const known = rows.filter((row) => row.outcome !== 'unknown');
+  const knownMaxScore = known.reduce((sum, row) => sum + (row.maxPoints ?? 0), 0);
+  const knownScore = known.reduce((sum, row) => sum + (row.pointsAwarded ?? 0), 0);
+  const dimensionMax = LISTING_DIMENSION_MAX[dimension];
+  const coverage = dimensionMax ? Math.round(knownMaxScore / dimensionMax * 100) : 0;
+  const complete = knownMaxScore === dimensionMax;
   return {
-    dimension,
-    score,
-    maxScore: 20,
-    coverage,
-    status: coverage < 60 ? 'partial' : 'scored',
-    evidence: rows,
+    dimension, score: blocked || !complete ? null : knownScore, maxScore: LISTING_DIMENSION_MAX[dimension], knownScore, knownMaxScore, coverage,
+    status: blocked || knownMaxScore === 0 ? 'blocked' : complete ? 'scored' : 'partial', evidence: rows,
     suggestions: rows.filter((row) => row.outcome === 'fail').map((row) => row.message),
   };
 }
 
 function scoreTitle(source: ListingSourceSnapshot): ListingDimensionScore {
-  const title = source.title?.trim() ?? '';
-  const brand = source.brand.name?.trim() ?? source.titleBrandName?.trim() ?? '';
-  const rows = [
-    evidence('title.present', 'title', Boolean(title), 20, '补充商品标题'),
-    evidence('title.length', 'title', title ? title.length >= 12 && title.length <= 60 : null, 5, '标题建议保持在 12–60 个字符'),
-    evidence('title.brand', 'brand.name', title && brand ? title.toLocaleLowerCase().includes(brand.toLocaleLowerCase()) : null, 3, '在标题中准确包含品牌'),
-    evidence('title.no_repeated_tokens', 'title', title ? !/(.{2,8})\1{2,}/.test(title) : null, 4, '删除标题中的重复词组'),
-    evidence('title.no_excess_symbols', 'title', title ? !/[!!]{2,}|[★☆]{2,}/.test(title) : null, 3, '减少连续营销符号'),
-  ];
-  return finish('title', rows, title ? (brand ? 5 : 4) : 0, 5);
+  const title = normalizeListingTitle(source.title);
+  const observed = source.detailStatus === 'available' || source.title !== null;
+  const brand = normalizeListingTitle(source.brand.name ?? source.titleBrandName);
+  const categoryRule = resolveJdCategoryRule(source.categoryContext?.names ?? [], source.categoryContext?.categoryId ?? source.categoryIds.at(-1) ?? null);
+  const terms = [...new Set([...categoryRule.coreTerms, ...categoryRule.aliases])].map((item) => normalizeListingTitle(item)).filter(Boolean);
+  const semanticTitle = titleAfterLeadingBrand(title, [brand, source.titleBrandName ?? '']);
+  const first15 = Array.from(semanticTitle).slice(0, 15).join('').toLocaleLowerCase();
+  const titleCharacters = listingTitleVisibleCharacters(title);
+  const repeated = /(.{2,8})\1{1,}/u.test(title);
+  const forbidden = /(最好|最佳|最强|第一|国家级|顶级|今日特价|限时|加微信|加微|QQ群|联系电话|★{2,}|!{2,}|!{2,})/u.test(title);
+  return finish('title', [
+    criterion('title.length', 'title', !observed ? 'unknown' : title && titleCharacters >= 30 && titleCharacters <= 60 ? 'pass' : 'fail', 4, `标题需保持 30–60 个 Unicode 可见字符;当前 ${titleCharacters}`),
+    criterion('title.brand_first', 'brand.name', !observed ? 'unknown' : brand && title.toLocaleLowerCase().startsWith(brand.toLocaleLowerCase()) ? 'pass' : 'fail', 4, '品牌应位于标题开头并与商品品牌一致'),
+    criterion('title.category_in_first_15', 'categoryContext.coreTerms', !observed ? 'unknown' : terms.length > 0 && terms.some((term) => first15.includes(term.toLocaleLowerCase())) ? 'pass' : 'fail', 4, terms.length ? `核心品类词应出现在品牌后的前 15 个字内;可识别品类:${terms.join('、')}` : '当前商品数据中没有可识别的品类名称'),
+    criterion('title.token_hygiene', 'title', !observed ? 'unknown' : title && !repeated && !forbidden ? 'pass' : 'fail', 2, '删除重复词根、极限词、失效促销词、导流信息和连续营销符号'),
+  ], !observed);
 }
 
 function scoreSellingPoints(source: ListingSourceSnapshot): ListingDimensionScore {
-  // JD productInfo.features also contains transport/control flags such as 0/1.
-  // Those are not seller-facing selling points and must not participate in
-  // duplicate detection. Until JD exposes a dedicated marketing-points field,
-  // use only human-readable descriptors and attributes as derived candidates.
-  const readableFeatures = source.features
-    .filter((item) => ['nameWithoutBrand', 'model'].includes(item.key) || /[\u4e00-\u9fff]/.test(item.key))
-    .map((item) => item.value.trim())
-    .filter((value) => value && !/^[01]$/.test(value));
-  const attributePoints = source.attributes
-    .filter((item) => item.name.trim() && item.values.some((value) => value.trim()))
-    .map((item) => `${item.name.trim()}:${item.values.map((value) => value.trim()).filter(Boolean).join('、')}`);
-  const values = [...readableFeatures, ...attributePoints];
-  const hasService = Object.keys(source.afterService).length > 0;
-  const rows = [
-    evidence('selling_points.present', 'derivedSellingPoints', values.length > 0, 20, '补充结构化核心卖点'),
-    evidence('selling_points.count', 'derivedSellingPoints', values.length ? values.length >= 3 : null, 5, '至少提供 3 条可读的卖点信息'),
-    evidence('selling_points.unique', 'derivedSellingPoints', null, 0, '上游未返回独立营销卖点,暂不执行重复性扣分'),
-    evidence('selling_points.specific', 'derivedSellingPoints', values.length ? values.some((value) => /\d/.test(value)) : null, 3, '卖点中加入可验证的规格或数字'),
-    evidence('selling_points.service', 'afterService', hasService, 2, '补充售后或履约承诺'),
-  ];
-  return finish('selling_points', rows, values.length ? 3 + Number(hasService) : 0, 5);
+  const observed = source.detailStatus === 'available';
+  const points = source.marketing?.sellingPoints ?? [];
+  const values = points.map((item) => item.value.normalize('NFKC').replace(/\s+/g, ' ').trim()).filter(Boolean);
+  const traceable = points.length > 0 && points.every((item) => item.fieldPath && (item.source !== 'sku_short_title' || item.skuId));
+  const skuIds = new Set(source.skus.map((sku) => sku.skuId));
+  const coveredSkuIds = new Set((source.marketing?.skuShortTitles ?? []).map((item) => item.skuId).filter((id) => skuIds.has(id)));
+  const skuCoverage = source.skus.length ? coveredSkuIds.size / source.skus.length : null;
+  return finish('selling_points', [
+    criterion('selling_points.source_observed', 'marketing', observed ? 'pass' : 'unknown', 1, '必须明确采集商品广告词和 SKU 短标题字段'),
+    criterion('selling_points.present', 'marketing.sellingPoints', !observed ? 'unknown' : values.length ? 'pass' : 'fail', 1, '至少提供一个真实营销候选文本'),
+    criterion('selling_points.provenance', 'marketing.sellingPoints[].fieldPath', !observed ? 'unknown' : traceable ? 'pass' : 'fail', 1, '每条卖点必须可追溯到字段路径和 SKU'),
+    criterion('selling_points.exact_dedup', 'marketing.sellingPoints', !observed ? 'unknown' : values.length && new Set(values).size === values.length && values.length === points.length ? 'pass' : 'fail', 1, '清理空白、传输标记和完全重复卖点'),
+    criterion('selling_points.sku_coverage', 'marketing.skuShortTitles', !observed ? 'unknown' : skuCoverage !== null && skuCoverage >= 0.7 ? 'pass' : 'fail', 1, '有效商品规格的短标题覆盖率应达到 70%'),
+  ], !observed);
 }
 
 function scoreImages(source: ListingSourceSnapshot): ListingDimensionScore {
+  const observed = source.detailStatus === 'available' || source.images.length > 0;
   const images = source.images;
-  const validUrls = images.filter((item) => /^https?:\/\//i.test(item.url));
-  const unique = new Set(images.map((item) => item.url));
-  const primary = images.some((item) => item.isPrimary === true);
-  const ordered = images.every((item, index) => item.order === null || index === 0 || (item.order ?? 0) >= (images[index - 1]?.order ?? 0));
-  const rows = [
-    evidence('images.present', 'images', images.length > 0, 20, '至少提供一张主图'),
-    evidence('images.count', 'images', images.length ? images.length >= 5 : null, 5, '建议提供至少 5 张不同角度的图片'),
-    evidence('images.primary', 'images[].isPrimary', images.length ? primary : null, 4, '明确设置主图'),
-    evidence('images.valid_url', 'images[].url', images.length ? validUrls.length === images.length : null, 4, '修复不可识别的图片 URL'),
-    evidence('images.unique_ordered', 'images[].order', images.length ? unique.size === images.length && ordered : null, 3, '去除重复图片并校正顺序'),
-  ];
-  return finish('images', rows, images.length ? 5 : 0, 5);
+  const urls = images.map((item) => item.url.trim().toLocaleLowerCase());
+  const primary = images.filter((item) => item.isPrimary === true);
+  const primaryObserved = images.length > 0 && images.every((item) => item.isPrimary !== null) && images.some((item) => item.primarySource !== 'unknown');
+  const primaryFirst = primary.length === 1 && (primary[0]?.order === 0 || images[0]?.url === primary[0]?.url);
+  const ordered = images.every((item, index) => index === 0 || item.order === null || images[index - 1]?.order === null || item.order >= (images[index - 1]?.order ?? 0));
+  const metadataKnown = images.length > 0 && images.every((item) => item.mediaType === 'image' || /\.(?:jpe?g|png|webp|gif)(?:\?|$)/i.test(item.url));
+  return finish('images', [
+    criterion('images.count', 'images', !observed ? 'unknown' : new Set(urls).size >= 5 ? 'pass' : 'fail', 6, '至少提供 5 个有效且唯一的图片资产'),
+    criterion('images.primary', 'images[].isPrimary', !observed ? 'unknown' : primaryObserved && primaryFirst ? 'pass' : 'fail', 5, '默认商品图片组应恰好设置一个主图并放在首位'),
+    criterion('images.url_integrity', 'images[].url', !observed ? 'unknown' : images.length && images.every((item) => /^https:\/\//i.test(item.url)) && new Set(urls).size === images.length ? 'pass' : 'fail', 4, '图片必须使用安全 HTTPS URL 且归一化后不重复'),
+    criterion('images.order', 'images[].order', !observed ? 'unknown' : images.length && ordered ? 'pass' : 'fail', 3, '图片顺序必须稳定、连续且无冲突'),
+    criterion('images.asset_metadata', 'images[].mediaType', !observed ? 'unknown' : metadataKnown ? 'pass' : 'fail', 2, '图片应具有可验证的媒体类型和资产信息'),
+  ], !observed);
 }
 
 function scoreDescription(source: ListingSourceSnapshot): ListingDimensionScore {
+  const observed = source.detailStatus === 'available';
   const desktopRaw = source.descriptions.desktopHtml ?? '';
   const mobileRaw = source.descriptions.mobileHtml ?? '';
-  const desktop = textFromHtml(desktopRaw);
-  const mobile = textFromHtml(mobileRaw);
-  const desktopPresent = Boolean(desktop || /<img\b/i.test(desktopRaw));
-  const mobilePresent = Boolean(mobile || /<img\b/i.test(mobileRaw));
-  const combinedPresent = desktopPresent || mobilePresent;
+  const desktopPresent = Boolean(textFromHtml(desktopRaw) || /<img\b/i.test(desktopRaw));
+  const mobilePresent = Boolean(textFromHtml(mobileRaw) || /<img\b/i.test(mobileRaw));
   const unsafe = /<script|on\w+\s*=|javascript:/i.test(`${desktopRaw}${mobileRaw}`);
-  const rows = [
-    evidence('description.present', 'descriptions', combinedPresent, 20, '补充商品详情'),
-    evidence('description.desktop', 'descriptions.desktopHtml', desktopPresent ? desktopRaw.length >= 120 : null, 5, '完善桌面端详情内容'),
-    evidence('description.mobile', 'descriptions.mobileHtml', mobilePresent ? mobileRaw.length >= 80 : null, 4, '完善移动端详情内容'),
-    evidence('description.safe_html', 'descriptions', combinedPresent ? !unsafe : null, 6, '移除不安全 HTML'),
-    evidence('description.consistent', 'descriptions', desktopPresent && mobilePresent ? Math.min(desktopRaw.length, mobileRaw.length) / Math.max(desktopRaw.length, mobileRaw.length) >= 0.25 : null, 3, '保持桌面端与移动端信息一致'),
-  ];
-  return finish('description', rows, combinedPresent ? 3 + Number(desktopPresent) + Number(mobilePresent) : 0, 5);
+  return finish('description', [
+    criterion('description.desktop_present', 'descriptions.desktopHtml', !observed ? 'unknown' : desktopPresent ? 'pass' : 'fail', 4, '应提供桌面端商品详情资产'),
+    criterion('description.mobile_present', 'descriptions.mobileHtml', !observed ? 'unknown' : mobilePresent ? 'pass' : 'fail', 3, '应提供移动端商品详情资产'),
+    criterion('description.safe_html', 'descriptions', !observed ? 'unknown' : !unsafe ? 'pass' : 'fail', 3, '详情中不得包含脚本、事件属性或危险链接'),
+    criterion('description.assets_present', 'descriptionStructure.imageCount', !observed ? 'unknown' : (source.descriptionStructure?.imageCount ?? 0) > 0 || Boolean(textFromHtml(desktopRaw) || textFromHtml(mobileRaw)) ? 'pass' : 'fail', 5, '详情应包含可用的图文资产'),
+  ], !observed);
 }
 
 function scoreSpecifications(source: ListingSourceSnapshot): ListingDimensionScore {
-  const attributes = source.attributes.filter((item) => item.name && item.values.length);
-  const skuAttributes = source.skus.flatMap((sku) => sku.attributes);
-  const dimensions = Object.values(source.dimensions).filter((value) => value !== null && value > 0);
-  const skuIds = new Set(source.skus.map((sku) => sku.skuId));
-  const rows = [
-    evidence('specifications.present', 'attributes', attributes.length > 0, 20, '补充商品规格属性'),
-    evidence('specifications.count', 'attributes', attributes.length ? attributes.length >= 3 : null, 5, '至少提供 3 个有效规格属性'),
-    evidence('specifications.sku_attrs', 'skus[].attributes', source.skus.length ? skuAttributes.length > 0 : null, 4, '补充 SKU 维度属性'),
-    evidence('specifications.dimensions', 'dimensions', dimensions.length > 0, 3, '补充尺寸或重量'),
-    evidence('specifications.unique_skus', 'skus[].skuId', source.skus.length ? skuIds.size === source.skus.length : null, 4, '修复重复 SKU 标识'),
+  const observed = source.detailStatus === 'available';
+  const attributes = source.attributes.filter((item) => item.name.trim() && item.values.some((value) => value.trim()));
+  const normalized = attributes.length > 0 && attributes.every((item) => new Set(item.values.map((value) => value.normalize('NFKC').trim()).filter(Boolean)).size === item.values.filter((value) => value.trim()).length);
+  const skuAttributes = source.skus.flatMap((sku) => sku.attributes).filter((item) => item.name && item.values.length);
+  const dimensionValues = Object.values(source.dimensions).filter((value) => value !== null && value > 0);
+  const skuIds = source.skus.map((sku) => sku.skuId);
+  return finish('specifications', [
+    criterion('specifications.present', 'attributes', !observed ? 'unknown' : attributes.length ? 'pass' : 'fail', 3, '应提供有效商品属性'),
+    criterion('specifications.normalized', 'attributes', !observed ? 'unknown' : normalized ? 'pass' : 'fail', 2, '规格名称和值应非空、去重且格式清晰'),
+    criterion('specifications.sku_attributes', 'skus[].attributes', !observed ? 'unknown' : !source.skus.length || skuAttributes.length ? 'pass' : 'fail', 2, '有商品规格时应提供对应属性'),
+    criterion('specifications.dimensions_weight', 'dimensions', !observed ? 'unknown' : dimensionValues.length ? 'pass' : 'fail', 2, '应提供尺寸或重量信息'),
+    criterion('specifications.unique_skus', 'skus[].skuId', !observed ? 'unknown' : new Set(skuIds).size === skuIds.length ? 'pass' : 'fail', 1, '商品规格标识应唯一且映射无冲突'),
+  ], !observed);
+}
+
+export function listingCompliance(source: ListingSourceSnapshot): ListingComplianceResult {
+  const findings: ListingComplianceFinding[] = [];
+  const fields: Array<[string, string]> = [['title', normalizeListingTitle(source.title)], ...(source.marketing?.sellingPoints ?? []).map((item) => [item.fieldPath, item.value] as [string, string])];
+  const rules: Array<{ id: string; severity: ListingComplianceFinding['severity']; pattern: RegExp; message: string }> = [
+    { id: 'compliance.extreme_claim', severity: 'high', pattern: /(最好|最佳|最强|第一|国家级|顶级)/u, message: '发现极限或绝对化用语,需要合规复核' },
+    { id: 'compliance.expiring_promotion', severity: 'medium', pattern: /(今日特价|限时|仅限今天|最后一天)/u, message: '发现可能失效的促销时效用语' },
+    { id: 'compliance.external_redirect', severity: 'critical', pattern: /(加微信|加微|QQ群|https?:\/\/|www\.|联系电话\s*[::]?\s*1\d{10})/iu, message: '发现第三方导流或外部联系信息' },
   ];
-  return finish('specifications', rows, attributes.length ? 3 + Number(source.skus.length > 0) + Number(dimensions.length > 0) : 0, 5);
+  for (const [fieldPath, value] of fields) for (const rule of rules) {
+    const match = value.match(rule.pattern);
+    if (match) findings.push({ ruleId: rule.id, severity: rule.severity, fieldPath, evidence: [match[0]], message: rule.message });
+  }
+  const qualifications = source.categoryContext?.qualificationNames ?? [];
+  const haystack = source.attributes.flatMap((item) => [item.name, ...item.values]).join(' ');
+  for (const qualification of qualifications.filter((item) => !haystack.includes(item))) findings.push({ ruleId: 'compliance.qualification_missing', severity: 'high', fieldPath: 'attributes', evidence: [qualification], message: `类目规则要求展示资质:${qualification}` });
+  const status = findings.some((item) => item.severity === 'critical') ? 'blocked' : findings.some((item) => item.severity === 'high') ? 'needs_review' : findings.length ? 'warning' : 'normal';
+  return { status, ruleSetVersion: source.categoryContext?.ruleVersion ?? LISTING_RULE_SET_VERSION, findings };
 }
 
 export function listingCoverage(source: ListingSourceSnapshot): ListingCoverage {
   const required = [
-    ['title', Boolean(source.title?.trim())],
-    ['features', source.features.some((item) => item.value.trim())],
-    ['images', source.images.length > 0],
+    ['title', Boolean(source.title?.trim())], ['marketing', Boolean(source.marketing?.sellingPoints.length)], ['images', source.images.length > 0],
     ['descriptions', Boolean(textFromHtml(source.descriptions.desktopHtml) || textFromHtml(source.descriptions.mobileHtml) || /<img\b/i.test(`${source.descriptions.desktopHtml ?? ''}${source.descriptions.mobileHtml ?? ''}`))],
     ['attributes', source.attributes.length > 0],
   ] as const;
   const present = required.filter(([, available]) => available).length;
-  const percent = Math.round((present / required.length) * 100);
-  return {
-    percent,
-    missing: required.filter(([, available]) => !available).map(([name]) => name),
-    status: source.detailStatus !== 'available' || present <= 1 ? 'blocked' : percent < 60 ? 'partial' : 'eligible',
-  };
+  const percent = Math.round(present / required.length * 100);
+  return { percent, missing: required.filter(([, available]) => !available).map(([name]) => name), status: source.detailStatus !== 'available' ? 'blocked' : 'eligible' };
+}
+
+export function listingUnknownCriteria(dimensions: ListingDimensionScore[]): NonNullable<ListingScoreResult['unknownCriteria']> {
+  void dimensions;
+  return [];
 }
 
-export function scoreListing(
-  source: ListingSourceSnapshot,
-  options: { id?: string; now?: string; rubricVersion?: string } = {},
-): ListingScoreResult {
+export function scoreListing(source: ListingSourceSnapshot, options: { id?: string; now?: string; rubricVersion?: string } = {}): ListingScoreResult {
   const dimensions = [scoreTitle(source), scoreSellingPoints(source), scoreImages(source), scoreDescription(source), scoreSpecifications(source)];
   const coverage = listingCoverage(source);
-  const scores = dimensions.map((item) => item.score).filter((score): score is number => score !== null);
-  const canTotal = coverage.status !== 'blocked' && dimensions.every((item) => item.score !== null);
+  const knownOverallScore = dimensions.reduce((sum, item) => sum + (item.knownScore ?? item.score ?? 0), 0);
+  const knownOverallMaxScore = dimensions.reduce((sum, item) => sum + (item.knownMaxScore ?? (item.score === null ? 0 : item.maxScore)), 0);
+  const complete = coverage.status !== 'blocked' && dimensions.every((item) => item.status === 'scored' && item.score !== null);
   return {
-    id: options.id ?? randomUUID(),
-    workspaceId: source.workspaceId,
-    productId: source.productId,
-    sourceHash: source.sourceHash,
-    rubricVersion: options.rubricVersion ?? LISTING_RUBRIC_VERSION,
-    overallScore: canTotal ? scores.reduce((sum, value) => sum + value, 0) : null,
-    coverage,
-    dimensions,
-    aiStatus: 'not_requested',
-    aiSuggestions: [],
-    aiCandidate: null,
-    model: null,
-    promptVersion: null,
-    scoreKind: 'rules',
-    baselineOverallScore: null,
-    aiConfidence: null,
-    createdAt: options.now ?? new Date().toISOString(),
+    id: options.id ?? randomUUID(), workspaceId: source.workspaceId, productId: source.productId, sourceHash: source.sourceHash,
+    rubricVersion: options.rubricVersion ?? LISTING_RUBRIC_VERSION, overallScore: complete ? knownOverallScore : null, knownOverallScore, knownOverallMaxScore,
+    coverage, dimensions, compliance: listingCompliance(source), unknownCriteria: listingUnknownCriteria(dimensions), aiStatus: 'not_requested', aiSuggestions: [], aiCandidate: null, model: null, promptVersion: null,
+    scoreKind: 'rules', baselineOverallScore: null, aiConfidence: null, createdAt: options.now ?? new Date().toISOString(),
   };
 }
 
 export function canonicalHash(value: unknown): string {
   const canonical = (input: unknown): unknown => {
     if (Array.isArray(input)) return input.map(canonical);
-    if (input && typeof input === 'object') {
-      return Object.fromEntries(Object.entries(input as Record<string, unknown>).sort(([a], [b]) => a.localeCompare(b)).map(([key, child]) => [key, canonical(child)]));
-    }
+    if (input && typeof input === 'object') return Object.fromEntries(Object.entries(input as Record<string, unknown>).sort(([a], [b]) => a.localeCompare(b)).map(([key, child]) => [key, canonical(child)]));
     return input;
   };
   return createHash('sha256').update(JSON.stringify(canonical(value))).digest('hex');

+ 40 - 0
src/modules/listing-ai/scoring/score-status.ts

@@ -0,0 +1,40 @@
+import type { ListingAiScoreStatus, ListingProductScoreStatus, ListingScoreResult, ListingSourceSnapshot } from '../domain.js';
+import { LISTING_AI_RUBRIC_VERSION } from './ai-rubric.js';
+import { LISTING_RUBRIC_VERSION, listingCoverage } from './rule-engine.js';
+
+export function selectCurrentListingScore(scores: ListingScoreResult[], sourceHash: string): {
+  latestAttempt: ListingScoreResult | null;
+  latestEffectiveRuleScore: ListingScoreResult | null;
+  latestEffectiveAiScore: ListingScoreResult | null;
+  displayScore: ListingScoreResult | null;
+} {
+  const current = scores.filter((score) => score.sourceHash === sourceHash && isListingV7Score(score));
+  const latestEffectiveRuleScore = current.find((score) => score.rubricVersion === LISTING_RUBRIC_VERSION && (score.scoreKind ?? 'rules') === 'rules')
+    ?? null;
+  const latestEffectiveAiScore = current.find((score) => score.rubricVersion === LISTING_AI_RUBRIC_VERSION && score.scoreKind === 'hybrid_ai' && score.aiStatus === 'completed')
+    ?? null;
+  const displayScore = latestEffectiveAiScore?.overallScore !== null && latestEffectiveAiScore?.overallScore !== undefined ? latestEffectiveAiScore
+    : latestEffectiveRuleScore?.overallScore !== null && latestEffectiveRuleScore?.overallScore !== undefined ? latestEffectiveRuleScore
+      : latestEffectiveAiScore ?? latestEffectiveRuleScore;
+  return { latestAttempt: displayScore, latestEffectiveRuleScore, latestEffectiveAiScore, displayScore };
+}
+
+export function isListingV7Score(score: ListingScoreResult): boolean {
+  return score.scoreKind === 'hybrid_ai'
+    ? score.rubricVersion === LISTING_AI_RUBRIC_VERSION
+    : score.rubricVersion === LISTING_RUBRIC_VERSION;
+}
+
+export function listingProductScoreStatus(source: ListingSourceSnapshot, score: ListingScoreResult | null, latestAttempt: ListingScoreResult | null = score): ListingProductScoreStatus {
+  if (listingCoverage(source).status === 'blocked' || score?.coverage.status === 'blocked') return 'blocked';
+  if (!score || score.sourceHash !== source.sourceHash) return latestAttempt?.aiStatus === 'failed' || latestAttempt?.aiStatus === 'budget_exceeded' ? 'failed' : 'unscored';
+  if (score.overallScore === null || score.dimensions.some((dimension) => dimension.status !== 'scored')) return 'partial';
+  return 'scored';
+}
+
+export function listingAiScoreStatus(score: ListingScoreResult | null, latestAttempt: ListingScoreResult | null = score): ListingAiScoreStatus {
+  if (latestAttempt?.scoreKind === 'hybrid_ai' && (latestAttempt.aiStatus === 'failed' || latestAttempt.aiStatus === 'budget_exceeded')) return 'failed';
+  if (!score || score.aiStatus === 'not_requested' || score.aiStatus === 'pending') return 'not_scored';
+  if (score.aiStatus === 'failed' || score.aiStatus === 'budget_exceeded') return 'failed';
+  return score.overallScore === null || score.dimensions.some((dimension) => dimension.status !== 'scored') ? 'partial' : 'completed';
+}

+ 17 - 6
src/server.ts

@@ -5,7 +5,7 @@ import { createApp } from './app.js';
 import { loadConfig } from './config/env.js';
 import { createParseServer } from './config/parse.js';
 import { createDatabasePool } from './db/pool.js';
-import { ParseRestClient } from './db/parse-rest.client.js';
+import { ParseRestClient, ParseRestError } from './db/parse-rest.client.js';
 import { ensureVocParseSchemas } from './db/parse-rest.schema.js';
 import { startParseRestSyncWorker } from './modules/domestic-voc/jobs/parse-rest-sync-worker.js';
 import { startSyncWorker } from './modules/domestic-voc/jobs/sync-worker.js';
@@ -36,19 +36,30 @@ async function main(): Promise<void> {
       masterKey: config.parse.masterKey,
       timeoutMs: config.parse.timeoutMs,
     });
-    await ensureVocParseSchemas(client);
+    const skipParseStartupReconciliation = process.env.PARSE_SKIP_STARTUP_RECONCILIATION === 'true';
+    if (!skipParseStartupReconciliation) {
+      try {
+        await ensureVocParseSchemas(client);
+      } catch (error) {
+        if (!(error instanceof ParseRestError && error.status === 404)) throw error;
+        console.warn('[server] Parse schema management endpoint is unavailable; continuing with existing classes');
+      }
+    } else {
+      console.warn('[server] skipped Parse startup reconciliation; using existing Parse classes');
+    }
     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, {
+    try { if (!skipParseStartupReconciliation) await promptConfigs.ensureDefaults(DEFAULT_DOMESTIC_AI_PROMPT_CONFIGS); }
+    catch (error) { if (!(error instanceof ParseRestError && error.status === 404)) throw error; console.warn('[server] skipped prompt reconciliation because Parse gateway is unavailable'); }
+    if (bootstrapUserId && !skipParseStartupReconciliation) {
+      try { await repository.bootstrapAdmin(config.auth.defaultWorkspaceId, {
         userId: bootstrapUserId,
         email: config.auth.bootstrapAdminEmail
           || (config.auth.mode === 'disabled' ? config.auth.localUserEmail : ''),
         displayName: config.auth.bootstrapAdminName
           || (config.auth.mode === 'disabled' ? config.auth.localUserName : bootstrapUserId),
-      });
+      }); } catch (error) { if (!(error instanceof ParseRestError && error.status === 404)) throw error; console.warn('[server] skipped local admin reconciliation because Parse gateway is unavailable'); }
     }
     app = createApp({
       config,

+ 70 - 8
test/listing-ai.ai-rubric.test.ts

@@ -3,17 +3,22 @@ import test from 'node:test';
 import type { ListingSourceSnapshot } from '../src/modules/listing-ai/domain.js';
 import { ListingAiService, type ListingAiScoringProvider } from '../src/modules/listing-ai/listing-ai.service.js';
 import { InMemoryListingAiRepository } from '../src/modules/listing-ai/repositories/in-memory-listing-ai.repository.js';
-import { LISTING_AI_CRITERIA, LISTING_AI_PROMPT_VERSION, LISTING_AI_RUBRIC_VERSION, parseListingAiScoreOutput, type ListingAiScoreOutput } from '../src/modules/listing-ai/scoring/ai-rubric.js';
+import { composeListingAiScore, LISTING_AI_CRITERIA, LISTING_AI_PROMPT_VERSION, LISTING_AI_RUBRIC_VERSION, parseListingAiScoreOutput, type ListingAiScoreOutput } from '../src/modules/listing-ai/scoring/ai-rubric.js';
+import { scoreListing } from '../src/modules/listing-ai/scoring/rule-engine.js';
 
 const source: ListingSourceSnapshot = {
   id: 'source-ai-1', workspaceId: 'demashi', platform: 'jd', shopId: 'shop', productId: 'ai-1001', sourceHash: 'e'.repeat(64),
-  title: '星星 299L 一级能效风冷无霜商用冷藏展示柜', titleBrandName: '星星', brand: { id: '1', name: '星星' }, categoryIds: ['10'], itemStatus: '1',
+  title: '星星 商用冷藏展示柜 299L 一级能效风冷无霜便利店商超适用', titleBrandName: '星星', brand: { id: '1', name: '星星' }, categoryIds: ['10'],
+  categoryContext: { names: ['商用冷藏展示柜'], coreTerms: ['商用冷藏展示柜'], requiredSpecificationNames: ['容量', '制冷方式'], qualificationNames: [], ruleVersion: 'test-category-v1' }, itemStatus: '1',
   price: { jd: 1049, cost: 800 }, descriptions: { desktopHtml: `<p>${'299L大容量,一级能效,风冷无霜,适合便利店使用。'.repeat(20)}</p>`, mobileHtml: `<p>${'299L大容量,一级能效,风冷无霜。'.repeat(20)}</p>` },
+  descriptionStructure: { observed: true, imageCount: 8, videoCount: 1, headingCount: 4, faqCandidateCount: 5 },
   features: [{ key: 'nameWithoutBrand', value: '299L 一级能效风冷无霜展示柜' }, { key: 'model', value: 'BC-299' }],
   attributes: [{ id: '1', name: '容量', values: ['299L'] }, { id: '2', name: '制冷方式', values: ['风冷'] }, { id: '3', name: '能效等级', values: ['一级'] }],
   images: Array.from({ length: 5 }, (_, index) => ({ url: `https://img.test/${index}.jpg`, order: index + 1, isPrimary: index === 0, gptFlag: null })),
   skus: [{ skuId: 'sku-1', name: '299L', price: 1049, stock: 10, status: '1', attributes: [{ id: '1', name: '容量', values: ['299L'] }] }],
   dimensions: { length: 600, width: 620, height: 1900, weight: 60 }, logistics: {}, afterService: { return7Days: true },
+  marketing: { adword: '299L 大容量 一级能效 风冷无霜', skuShortTitles: [{ skuId: 'sku-1', value: '299L 高效冷藏' }], sellingPoints: [{ value: '299L 大容量 一级能效 风冷无霜', source: 'product_adword', fieldPath: 'productInfo.adword', skuId: null }, { value: '299L 高效冷藏', source: 'sku_short_title', fieldPath: 'skuList[].features[key=shortTitle]', skuId: 'sku-1' }] },
+  vocEvidence: [{ id: 'voc-1', text: '容量不足和结霜是主要顾虑', sourceVersion: 'voc-v1', collectedAt: '2026-08-20T00:00:00.000Z' }],
   sourceModifiedAt: null, syncedAt: '2026-08-21T00:00:00.000Z', detailStatus: 'available',
 };
 
@@ -25,7 +30,7 @@ class StableAiJudge implements ListingAiScoringProvider {
     this.calls += 1;
     return {
       assessments: LISTING_AI_CRITERIA.map((criterion) => ({
-        criterionId: criterion.id, level: 'strong' as const, evidence: ['输入中的可验证事实'], reason: '证据充分', confidence: 0.9,
+        criterionId: criterion.id, level: 'strong' as const, evidenceIds: ['title'], reason: '证据充分', confidence: 0.9,
       })),
       summary: '结构和语义均完整', suggestions: ['保持标题、卖点与规格一致'],
     };
@@ -47,24 +52,81 @@ test('AI rubric uses fixed criteria, server-side composition, and stable cache i
   const service = new ListingAiService(repository, judge, () => new Date('2026-08-21T10:00:00.000Z'), 1, 10);
   const first = await service.enqueueScoreJob({ workspaceId: 'demashi', platform: 'jd', scope: { mode: 'selected', productIds: [source.productId] }, includeAiSuggestions: true, idempotencyKey: 'ai-score-first', requestedBy: 'test' });
   await waitForTerminal(service, first.id);
-  const result = await repository.getLatestScore('demashi', source.productId, LISTING_AI_RUBRIC_VERSION);
+  const result = await repository.getCurrentScore('demashi', source.productId, 'formal_ai');
   assert.equal(result?.overallScore, 100);
   assert.equal(result?.scoreKind, 'hybrid_ai');
   assert.equal(result?.promptVersion, LISTING_AI_PROMPT_VERSION);
   assert.equal(result?.aiCandidate, null);
-  assert.equal(result?.dimensions.find((item) => item.dimension === 'images')?.evidence.some((item) => item.ruleId === 'ai.images.visual_unavailable'), true);
+  assert.equal(result?.dimensions.find((item) => item.dimension === 'images')?.evidence.some((item) => item.ruleId.startsWith('ai.')), false);
   assert.equal(judge.calls, 1);
 
   const second = await service.enqueueScoreJob({ workspaceId: 'demashi', platform: 'jd', scope: { mode: 'selected', productIds: [source.productId] }, includeAiSuggestions: true, idempotencyKey: 'ai-score-second', requestedBy: 'test' });
   await waitForTerminal(service, second.id);
   assert.equal(judge.calls, 1, 'same source/rubric/model/prompt must reuse the stored AI score');
+  const beforeForce = await repository.listCurrentScores('demashi');
+
+  const forced = await service.enqueueScoreJob({ workspaceId: 'demashi', platform: 'jd', scope: { mode: 'selected', productIds: [source.productId] }, includeAiSuggestions: true, rescorePolicy: 'force', idempotencyKey: 'ai-score-forced', requestedBy: 'test' });
+  await waitForTerminal(service, forced.id);
+  assert.equal(judge.calls, 2, 'force must invoke the model even when the input fingerprint is unchanged');
+  const afterForce = await repository.listCurrentScores('demashi');
+  assert.equal(afterForce.length, beforeForce.length, 'force must overwrite the two current slots instead of appending history');
+  assert.equal(afterForce.filter((item) => item.scoreKind === 'hybrid_ai').length, 1);
 });
 
-test('AI output parser normalizes a scalar evidence field without relaxing rubric completeness', () => {
+test('AI output parser normalizes a scalar evidence ID without relaxing rubric completeness', () => {
   const content = JSON.stringify({
-    assessments: LISTING_AI_CRITERIA.map((criterion) => ({ criterionId: criterion.id, level: 'pass', evidence: '原始字段', reason: '有直接证据', confidence: 0.8 })),
+    assessments: LISTING_AI_CRITERIA.map((criterion) => ({ criterionId: criterion.id, level: 'pass', evidenceIds: 'title', reason: '有直接证据', confidence: 0.8 })),
     summary: '完成', suggestions: [],
   });
   const parsed = parseListingAiScoreOutput(content);
-  assert.deepEqual(parsed?.assessments[0]?.evidence, ['原始字段']);
+  assert.deepEqual(parsed?.assessments[0]?.evidenceIds, ['title']);
+});
+
+test('image-only descriptions are scored from observable asset structure without semantic AI criteria', async () => {
+  const imageOnly = { ...source, descriptions: { desktopHtml: '<p><img src="https://img.test/detail.jpg"></p>', mobileHtml: '<img src="https://img.test/detail-mobile.jpg">' } };
+  const output = await new StableAiJudge().score();
+  const result = composeListingAiScore({ source: imageOnly, baseline: scoreListing(imageOnly), output, model: 'test-model', now: '2026-08-24T00:00:00.000Z' });
+  const description = result.dimensions.find((item) => item.dimension === 'description');
+  assert.equal(description?.evidence.some((item) => item.ruleId.startsWith('ai.')), false);
+  assert.equal(description?.score, 15);
+  assert.equal(result.knownOverallMaxScore, 100);
+});
+
+test('an observable empty product adword receives a numeric score instead of unknown', async () => {
+  const skuOnly = { ...source, marketing: { ...source.marketing!, adword: null, sellingPoints: source.marketing!.sellingPoints.filter((item) => item.source === 'sku_short_title') } };
+  const output = await new StableAiJudge().score();
+  const result = composeListingAiScore({ source: skuOnly, baseline: scoreListing(skuOnly), output, model: 'test-model', now: '2026-08-24T00:00:00.000Z' });
+  const sellingPoints = result.dimensions.find((item) => item.dimension === 'selling_points');
+  assert.equal(sellingPoints?.evidence.some((item) => item.outcome === 'unknown'), false);
+  assert.equal(typeof sellingPoints?.score, 'number');
+});
+
+test('AI failures remain on the job and do not overwrite the current formal score', async () => {
+  const repository = new InMemoryListingAiRepository([source]);
+  const provider: ListingAiScoringProvider = { configured: true, model: 'failing-model', score: async () => { throw new Error('ai_upstream_test'); } };
+  const service = new ListingAiService(repository, provider, () => new Date('2026-08-24T00:00:00.000Z'), 1, 10);
+  const rulesJob = await service.enqueueScoreJob({ workspaceId: 'demashi', platform: 'jd', scope: { mode: 'selected', productIds: [source.productId] }, includeAiSuggestions: false, idempotencyKey: 'rules-before-ai-failure', requestedBy: 'test' });
+  await waitForTerminal(service, rulesJob.id);
+  const job = await service.enqueueScoreJob({ workspaceId: 'demashi', platform: 'jd', scope: { mode: 'selected', productIds: [source.productId] }, includeAiSuggestions: true, rescorePolicy: 'force', idempotencyKey: 'ai-score-failure', requestedBy: 'test' });
+  await waitForTerminal(service, job.id);
+  assert.equal(await repository.getCurrentScore('demashi', source.productId, 'formal_ai'), null);
+  assert.equal((await repository.getCurrentScore('demashi', source.productId, 'rule_precheck'))?.scoreKind, 'rules');
+  assert.equal((await repository.getJobItems('demashi', job.id))[0]?.status, 'failed');
+});
+
+test('VOC and unverified category requirements do not create partial V7 results', async () => {
+  const partialSource = { ...source, productId: 'ai-partial', sourceHash: 'f'.repeat(64), vocEvidence: [], categoryContext: { ...source.categoryContext!, requiredSpecificationNames: [], ruleVersion: null } };
+  const repository = new InMemoryListingAiRepository([partialSource]);
+  const judge = new StableAiJudge();
+  const service = new ListingAiService(repository, judge, () => new Date('2026-08-24T01:00:00.000Z'), 1, 10);
+  const job = await service.enqueueScoreJob({ workspaceId: 'demashi', platform: 'jd', scope: { mode: 'selected', productIds: [partialSource.productId] }, includeAiSuggestions: true, idempotencyKey: 'ai-partial-evidence', requestedBy: 'test' });
+  await waitForTerminal(service, job.id);
+  const result = await repository.getCurrentScore('demashi', partialSource.productId, 'formal_ai');
+  assert.equal(judge.calls, 1);
+  assert.equal(typeof result?.overallScore, 'number');
+  assert.equal(result?.dimensions.flatMap((item) => item.evidence).some((item) => item.ruleId.includes('voc') || item.ruleId.includes('category_completeness')), false);
+  assert.equal((await repository.getJob('demashi', job.id))?.status, 'completed');
+  const item = (await repository.getJobItems('demashi', job.id))[0];
+  assert.equal(item?.errorCode, null);
+  assert.deepEqual(item?.statusReasonCodes, []);
 });

+ 41 - 10
test/listing-ai.routes.test.ts

@@ -17,13 +17,14 @@ const dataset: DomesticDataset = {
   mappingGroups: [], relations: [], reviews: [], quality: { orphanMappings: [], mappingsWithoutCompetitor: [], brandWithoutProductId: [] },
 };
 const listingSource: ListingSourceSnapshot = {
-  id: 'source-1', workspaceId: 'demashi', platform: 'jd', shopId: 'shop', productId: '1001', sourceHash: 'b'.repeat(64), title: '星星 299L 一级能效风冷无霜展示柜', titleBrandName: '星星', brand: { id: '1', name: '星星' }, categoryIds: ['20'], itemStatus: '1', price: { jd: 1049, cost: null },
+  id: 'source-1', workspaceId: 'demashi', platform: 'jd', shopId: 'shop', productId: '1001', sourceHash: 'b'.repeat(64), title: '星星 商用冷藏展示柜 299L 一级能效风冷无霜', titleBrandName: '星星', brand: { id: '1', name: '星星' }, categoryIds: ['20'], categoryContext: { names: ['商用冷藏展示柜'], coreTerms: ['商用冷藏展示柜'], requiredSpecificationNames: ['容量'], qualificationNames: [], ruleVersion: 'test-category-v1' }, itemStatus: '1', price: { jd: 1049, cost: null },
   descriptions: { desktopHtml: '<script>alert(1)</script><p onclick="bad()">安全详情</p>', mobileHtml: '<p>移动详情</p>' },
+  descriptionStructure: { observed: true, imageCount: 5, videoCount: 0, headingCount: 1, faqCandidateCount: 0 },
   features: [{ key: 'one', value: '299L 大容量' }, { key: 'two', value: '一级能效' }, { key: 'three', value: '风冷无霜' }],
   attributes: [{ id: '1', name: '容量', values: ['299L'] }, { id: '2', name: '能效', values: ['一级'] }, { id: '3', name: '制冷', values: ['风冷'] }],
   images: Array.from({ length: 5 }, (_, i) => ({ url: `https://img.test/${i}.jpg`, order: i + 1, isPrimary: i === 0, gptFlag: null })),
   skus: [{ skuId: 'sku1', name: '299L', price: 1049, stock: 1, status: '1', attributes: [{ id: '1', name: '容量', values: ['299L'] }] }],
-  dimensions: { length: 1, width: 1, height: 1, weight: 1 }, logistics: {}, afterService: { return7Days: true }, sourceModifiedAt: null, syncedAt: '2026-08-21T00:00:00.000Z', detailStatus: 'available',
+  dimensions: { length: 1, width: 1, height: 1, weight: 1 }, logistics: {}, afterService: { return7Days: true }, marketing: { adword: '299L 大容量 一级能效', skuShortTitles: [{ skuId: 'sku1', value: '299L 高效冷藏' }], sellingPoints: [{ value: '299L 大容量 一级能效', source: 'product_adword', fieldPath: 'productInfo.adword', skuId: null }, { value: '299L 高效冷藏', source: 'sku_short_title', fieldPath: 'skuList[].features[key=shortTitle]', skuId: 'sku1' }] }, sourceModifiedAt: null, syncedAt: '2026-08-21T00:00:00.000Z', detailStatus: 'available',
 };
 
 test('listing API scores a frozen source, sanitizes HTML, and adopts an internal version', async () => {
@@ -34,9 +35,13 @@ test('listing API scores a frozen source, sanitizes HTML, and adopts an internal
     const base = `http://127.0.0.1:${(server.address() as AddressInfo).port}/api/listing-ai`;
     const products = await fetch(`${base}/products`);
     assert.equal(products.status, 200);
-    const catalog = await products.json() as { items: Array<{ productId: string }>; summary: { sourceTotal: number } };
+    const catalog = await products.json() as { items: Array<Record<string, unknown> & { productId: string }>; summary: { sourceTotal: number } };
     assert.equal(catalog.summary.sourceTotal, 1);
     assert.equal(catalog.items[0]?.productId, '1001');
+    assert.equal(catalog.items[0]?.['sourceHash'], undefined);
+    assert.equal(catalog.items[0]?.['rubricVersion'], undefined);
+    assert.equal(catalog.items[0]?.['latestScore'], undefined);
+    assert.equal(catalog.items[0]?.['scoreText'], '等待智能评分');
 
     const detail = await fetch(`${base}/products/1001`);
     const detailBody = await detail.json() as { source: { descriptions: { desktopHtml: string } } };
@@ -48,7 +53,9 @@ test('listing API scores a frozen source, sanitizes HTML, and adopts an internal
       body: JSON.stringify({ scope: { mode: 'filter', filter: {} }, includeAiSuggestions: false }),
     });
     assert.equal(jobResponse.status, 202);
-    const jobId = (await jobResponse.json() as { job: { id: string } }).job.id;
+    const createdJob = (await jobResponse.json() as { job: { id: string; statusLabel: string; rubricVersion?: string } }).job;
+    const jobId = createdJob.id;
+    assert.equal(createdJob.statusLabel, '等待处理');
     let status = '';
     for (let index = 0; index < 30; index += 1) {
       const response = await fetch(`${base}/score-jobs/${jobId}`);
@@ -56,17 +63,17 @@ test('listing API scores a frozen source, sanitizes HTML, and adopts an internal
       if (['completed', 'partial', 'failed'].includes(status)) break;
       await new Promise((resolve) => setTimeout(resolve, 10));
     }
-    assert.equal(status, 'completed');
+    assert.equal(status, 'partial');
 
     const scored = await fetch(`${base}/products/1001`);
-    const scoredBody = await scored.json() as { latestScore: { id: string; overallScore: number } };
-    assert.equal(typeof scoredBody.latestScore.overallScore, 'number');
+    const scoredBody = await scored.json() as { currentScore: { score: number | null; scoreText: string; standardLabel: string } };
+    assert.equal(scoredBody.currentScore.score, null);
+    assert.equal(scoredBody.currentScore.standardLabel, '京东五维评分 V7');
 
     const versionResponse = await fetch(`${base}/products/1001/versions`, {
       method: 'POST', headers: { 'Content-Type': 'application/json' },
       body: JSON.stringify({
         baseSourceHash: listingSource.sourceHash,
-        baseScoreResultId: scoredBody.latestScore.id,
         content: {
           title: `${listingSource.title} 优化稿`,
           sellingPoints: ['299L 大容量', '一级能效', '风冷无霜'],
@@ -89,7 +96,7 @@ test('listing API scores a frozen source, sanitizes HTML, and adopts an internal
 test('a changed source hash hides stale scores and marks prior versions stale', async () => {
   const repository = new InMemoryListingAiRepository([listingSource]);
   const now = '2026-08-21T01:00:00.000Z';
-  await repository.saveScore({
+  await repository.upsertCurrentScore({
     id: 'score-old', workspaceId: listingSource.workspaceId, productId: listingSource.productId,
     sourceHash: listingSource.sourceHash, rubricVersion: 'listing-jd-v1', overallScore: 88,
     coverage: { percent: 100, missing: [], status: 'eligible' }, dimensions: [],
@@ -97,7 +104,7 @@ test('a changed source hash hides stale scores and marks prior versions stale',
   });
   const version = await repository.createVersion({
     id: 'version-old', workspaceId: listingSource.workspaceId, productId: listingSource.productId, versionNo: 0,
-    baseSourceHash: listingSource.sourceHash, baseScoreResultId: 'score-old',
+    baseSourceHash: listingSource.sourceHash,
     content: { title: listingSource.title, sellingPoints: [], descriptionHtml: null, specifications: [], imageUrls: [] },
     status: 'draft', createdBy: 'test', createdAt: now, adoptedAt: null,
   });
@@ -125,3 +132,27 @@ test('AI jobs fail closed before enqueueing beyond the configured item budget',
     (error: unknown) => error instanceof ApiError && error.status === 429 && error.code === 'listing_ai_budget_exceeded',
   );
 });
+
+test('critical compliance findings prevent adopting an internal version', async () => {
+  const blockedSource = { ...listingSource, productId: 'blocked-1', sourceHash: '9'.repeat(64), title: '星星 商用冷藏展示柜 联系电话:13800138000' };
+  const repository = new InMemoryListingAiRepository([blockedSource]);
+  const service = new ListingAiService(repository, undefined, () => new Date('2026-08-24T02:00:00.000Z'));
+  const job = await service.enqueueScoreJob({ workspaceId: blockedSource.workspaceId, platform: 'jd', scope: { mode: 'selected', productIds: [blockedSource.productId] }, includeAiSuggestions: false, idempotencyKey: 'blocked-compliance-score', requestedBy: 'test' });
+  for (let index = 0; index < 30; index += 1) {
+    if (['completed', 'partial', 'failed'].includes((await repository.getJob(blockedSource.workspaceId, job.id))?.status ?? '')) break;
+    await new Promise((resolve) => setTimeout(resolve, 5));
+  }
+  const version = await service.createVersion({ workspaceId: blockedSource.workspaceId, productId: blockedSource.productId, platform: 'jd', baseSourceHash: blockedSource.sourceHash, content: { title: blockedSource.title, sellingPoints: [], descriptionHtml: null, specifications: blockedSource.attributes, imageUrls: blockedSource.images.map((item) => item.url) }, createdBy: 'test' });
+  await assert.rejects(service.adoptVersion(blockedSource.workspaceId, 'jd', version.id), (error: unknown) => error instanceof ApiError && error.code === 'listing_compliance_blocked');
+});
+
+test('score history routes are physically removed', async () => {
+  const repository = new InMemoryListingAiRepository([listingSource]);
+  const app = createLocalDemoApp({ dataset, corsOrigins: ['http://localhost:4200'], listingAiRepository: repository });
+  const server = await new Promise<ReturnType<typeof app.listen>>((resolve) => { const listener = app.listen(0, '127.0.0.1', () => resolve(listener)); });
+  try {
+    const base = `http://127.0.0.1:${(server.address() as AddressInfo).port}/api/listing-ai/products/1001`;
+    assert.equal((await fetch(`${base}/scores`)).status, 404);
+    assert.equal((await fetch(`${base}/scores/latest`)).status, 404);
+  } finally { await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); }
+});

+ 132 - 6
test/listing-ai.rule-engine.test.ts

@@ -1,13 +1,19 @@
 import assert from 'node:assert/strict';
+import { readFile } from 'node:fs/promises';
 import test from 'node:test';
 import type { ListingSourceSnapshot } from '../src/modules/listing-ai/domain.js';
-import { canonicalHash, scoreListing } from '../src/modules/listing-ai/scoring/rule-engine.js';
+import { canonicalHash, listingTitleVisibleCharacters, LISTING_DIMENSION_MAX, scoreListing } from '../src/modules/listing-ai/scoring/rule-engine.js';
+import { normalizeJdListing } from '../src/modules/listing-ai/normalization/jd-listing.normalizer.js';
+import { buildListingContextIndex, LISTING_CONTEXT_VERSION } from '../src/modules/listing-ai/normalization/listing-context.enricher.js';
+import { listingProductScoreStatus, selectCurrentListingScore } from '../src/modules/listing-ai/scoring/score-status.js';
 
 function source(overrides: Partial<ListingSourceSnapshot> = {}): ListingSourceSnapshot {
   return {
     id: 'source-1', workspaceId: 'demashi', platform: 'jd', shopId: 'shop-1', productId: '1001', sourceHash: 'a'.repeat(64),
-    title: '星星 299L 一级能效风冷无霜商用冷藏展示柜', titleBrandName: '星星', brand: { id: '1', name: '星星' }, categoryIds: ['10', '20'], itemStatus: 'on_shelf',
+    title: '星星 商用冷藏展示柜 299L 一级能效风冷无霜便利店商超适用', titleBrandName: '星星', brand: { id: '1', name: '星星' }, categoryIds: ['10', '20'],
+    categoryContext: { names: ['商用冷藏展示柜'], coreTerms: ['商用冷藏展示柜'], requiredSpecificationNames: ['容量'], qualificationNames: [], ruleVersion: 'test-category-v1' }, itemStatus: 'on_shelf',
     price: { jd: 1049, cost: 800 }, descriptions: { desktopHtml: `<p>${'完整商品详情与使用场景。'.repeat(30)}</p>`, mobileHtml: `<p>${'移动端商品详情。'.repeat(30)}</p>` },
+    descriptionStructure: { observed: true, imageCount: 8, videoCount: 1, headingCount: 4, faqCandidateCount: 5 },
     features: [
       { key: 'capacity', value: '299L 大容量' }, { key: 'efficiency', value: '一级能效' }, { key: 'cooling', value: '风冷无霜' },
     ],
@@ -17,6 +23,8 @@ function source(overrides: Partial<ListingSourceSnapshot> = {}): ListingSourceSn
     images: Array.from({ length: 5 }, (_, index) => ({ url: `https://img.test/${index}.jpg`, order: index + 1, isPrimary: index === 0, gptFlag: false })),
     skus: [{ skuId: 'sku-1', name: '299L', price: 1049, stock: 20, status: '1', attributes: [{ id: '1', name: '容量', values: ['299L'] }] }],
     dimensions: { length: 600, width: 620, height: 1900, weight: 60 }, logistics: { delivery: '京东物流' }, afterService: { return7Days: true },
+    marketing: { adword: '299L 大容量 一级能效 风冷无霜', skuShortTitles: [{ skuId: 'sku-1', value: '299L 高效冷藏' }], sellingPoints: [{ value: '299L 大容量 一级能效 风冷无霜', source: 'product_adword', fieldPath: 'productInfo.adword', skuId: null }, { value: '299L 高效冷藏', source: 'sku_short_title', fieldPath: 'skuList[].features[key=shortTitle]', skuId: 'sku-1' }] },
+    vocEvidence: [{ id: 'voc-1', text: '容量不足和结霜是主要顾虑', sourceVersion: 'voc-v1', collectedAt: '2026-08-20T00:00:00.000Z' }],
     sourceModifiedAt: '2026-08-21T00:00:00.000Z', syncedAt: '2026-08-21T00:00:00.000Z', detailStatus: 'available', ...overrides,
   };
 }
@@ -25,9 +33,29 @@ test('rule engine is deterministic for the same source and rubric', () => {
   const first = scoreListing(source(), { id: 'same', now: '2026-08-21T00:00:00.000Z' });
   const second = scoreListing(source(), { id: 'same', now: '2026-08-21T00:00:00.000Z' });
   assert.deepEqual(first, second);
-  assert.equal(first.overallScore, 100);
+  assert.equal(first.overallScore, null);
+  assert.equal(first.knownOverallScore, 64);
+  assert.equal(first.knownOverallMaxScore, 64);
   assert.equal(first.coverage.status, 'eligible');
   assert.equal(first.dimensions.length, 5);
+  assert.deepEqual(Object.fromEntries(first.dimensions.map((item) => [item.dimension, item.maxScore])), LISTING_DIMENSION_MAX);
+});
+
+test('V7 title length counts Unicode visible characters and missing observable category is a numeric failure', () => {
+  assert.equal(listingTitleVisibleCharacters('京东ABC😀'), 6);
+  assert.equal(scoreListing(source({ title: '德玛仕(DEMASHI)商用电饼铛大型双面加热全自动电热大号烤饼机' })).dimensions.find((item) => item.dimension === 'title')?.evidence.find((item) => item.ruleId === 'title.length')?.outcome, 'pass');
+  const withoutCategory = source();
+  delete withoutCategory.categoryContext;
+  const result = scoreListing(withoutCategory);
+  const title = result.dimensions.find((item) => item.dimension === 'title');
+  assert.equal(title?.evidence.find((item) => item.ruleId === 'title.category_in_first_15')?.outcome, 'fail');
+  assert.equal(title?.status, 'partial');
+});
+
+test('compliance is independent from quality points and blocks critical redirects', () => {
+  const result = scoreListing(source({ title: '星星 商用冷藏展示柜 联系电话:13800138000' }));
+  assert.equal(result.compliance?.status, 'blocked');
+  assert.equal(result.compliance?.findings.some((item) => item.ruleId === 'compliance.external_redirect'), true);
 });
 
 test('missing detail is blocked instead of receiving a zero quality score', () => {
@@ -50,12 +78,110 @@ test('JD transport flags are not misclassified as duplicate selling points', ()
     ],
   }));
   const sellingPoints = result.dimensions.find((item) => item.dimension === 'selling_points');
-  const duplicateRule = sellingPoints?.evidence.find((item) => item.ruleId === 'selling_points.unique');
-  assert.equal(result.rubricVersion, 'listing-jd-v2');
-  assert.equal(duplicateRule?.outcome, 'unknown');
+  const duplicateRule = sellingPoints?.evidence.find((item) => item.ruleId === 'selling_points.exact_dedup');
+  assert.equal(result.rubricVersion, 'listing-jd-v7');
+  assert.equal(duplicateRule?.outcome, 'pass');
   assert.equal(duplicateRule?.delta, 0);
 });
 
 test('canonical source hash ignores object key insertion order', () => {
   assert.equal(canonicalHash({ a: 1, b: { c: 2 } }), canonicalHash({ b: { c: 2 }, a: 1 }));
 });
+
+test('JD adword and SKU shortTitle are normalized as attributed marketing points', () => {
+  const normalized = normalizeJdListing({
+    workspaceId: 'demashi', shopId: 'shop', row: { productId: '2001' },
+    detail: { productInfo: { productId: '2001', adword: '商用大容量', features: [] }, skuList: [
+      { skuId: 'sku-1', features: [{ key: 'shortTitle', value: '70L 高效供水' }] },
+      { skuId: 'sku-2', features: [{ key: 'shortTitle', value: '70L 高效供水' }] },
+    ] },
+    syncedAt: '2026-08-24T00:00:00.000Z',
+  });
+  assert.equal(normalized.normalizerVersion, 'jd-listing-v5');
+  assert.deepEqual(normalized.marketing?.sellingPoints.map((item) => [item.source, item.value]), [
+    ['product_adword', '商用大容量'], ['sku_short_title', '70L 高效供水'],
+  ]);
+});
+
+test('composite JD category labels publish independently matchable core terms', () => {
+  const result = scoreListing(source({
+    title: '星星 商用电饼铛 大号双面加热',
+    categoryContext: { names: ['商用电饼铛/煎包锅贴机/烤饼机'], coreTerms: ['商用电饼铛', '煎包锅贴机', '烤饼机'], aliases: ['商用电饼铛', '煎包锅贴机', '烤饼机'], requiredSpecificationNames: ['品牌'], qualificationNames: [], ruleVersion: 'jd-category-rules-2026-08-v2' },
+  }));
+  assert.equal(result.dimensions.find((item) => item.dimension === 'title')?.evidence.find((item) => item.ruleId === 'title.category_in_first_15')?.outcome, 'pass');
+});
+
+test('V6 category position starts after a confirmed bilingual leading brand', () => {
+  const result = scoreListing(source({
+    title: '德玛仕(DEMASHI)商用电饼铛大型双面加热全自动电热大号烤饼机',
+    titleBrandName: '德玛仕', brand: { id: '1', name: '德玛仕' },
+    categoryContext: { names: ['商用电饼铛/煎包锅贴机/烤饼机'], coreTerms: ['商用电饼铛'], aliases: [], requiredSpecificationNames: [], qualificationNames: [], ruleVersion: 'legacy' },
+  }));
+  assert.equal(result.dimensions.find((item) => item.dimension === 'title')?.evidence.find((item) => item.ruleId === 'title.category_in_first_15')?.outcome, 'pass');
+});
+
+test('normalizer preserves image groups and uses only the default group primary as product primary', () => {
+  const normalized = normalizeJdListing({
+    workspaceId: 'demashi', shopId: 'shop', row: { productId: 'image-groups' },
+    detail: { productInfo: { productId: 'image-groups' }, material: { mainImages: [
+      { uuid: '0000000000', imageInfoList: [{ imgUrl: 'default-1.jpg', orderSort: 1, primaryFlag: true }, { imgUrl: 'default-2.jpg', orderSort: 2, primaryFlag: false }] },
+      { uuid: 'sku-1', imageInfoList: [{ imgUrl: 'sku-1.jpg', orderSort: 1, primaryFlag: true }] },
+    ] } },
+  });
+  assert.equal(normalized.images.filter((item) => item.isPrimary === true).length, 1);
+  assert.equal(normalized.images[0]?.groupId, '0000000000');
+  assert.equal(normalized.images[0]?.primarySource, 'authoritative_field');
+  assert.equal(scoreListing(normalized).dimensions.find((item) => item.dimension === 'images')?.evidence.find((item) => item.ruleId === 'images.primary')?.outcome, 'pass');
+});
+
+test('ambiguous image groups produce a numeric primary failure instead of a dynamic denominator', () => {
+  const normalized = normalizeJdListing({
+    workspaceId: 'demashi', shopId: 'shop', row: { productId: 'ambiguous-groups' },
+    detail: { productInfo: { productId: 'ambiguous-groups' }, material: { mainImages: [
+      { uuid: 'sku-1', imageInfoList: [{ imgUrl: 'sku-1.jpg', orderSort: 1, primaryFlag: true }] },
+      { uuid: 'sku-2', imageInfoList: [{ imgUrl: 'sku-2.jpg', orderSort: 1, primaryFlag: true }] },
+    ] } },
+  });
+  const result = scoreListing(normalized);
+  assert.equal(normalized.images.length, 0);
+  assert.equal(result.dimensions.find((item) => item.dimension === 'images')?.evidence.find((item) => item.ruleId === 'images.primary')?.outcome, 'fail');
+  assert.deepEqual(result.unknownCriteria, []);
+});
+
+test('a result without an overall score is classified as partial instead of disappearing between filters', () => {
+  const input = source();
+  const result = scoreListing(input);
+  const partial = { ...result, overallScore: null };
+  assert.equal(listingProductScoreStatus(input, partial), 'partial');
+});
+
+test('the presentation gate never falls back to a legacy score version', () => {
+  const input=source();
+  const current=scoreListing(input);
+  const legacy={...current,rubricVersion:'listing-jd-v4',overallScore:88,knownOverallScore:88,knownOverallMaxScore:100};
+  assert.equal(selectCurrentListingScore([legacy],input.sourceHash).displayScore,null);
+  assert.equal(selectCurrentListingScore([current],input.sourceHash).displayScore?.rubricVersion,'listing-jd-v7');
+});
+
+test('V7 migration creates current slots and physically drops score history', async () => {
+  const migration = await readFile(new URL('../migrations/012_listing_remove_score_history.sql', import.meta.url), 'utf8');
+  assert.match(migration, /CREATE TABLE IF NOT EXISTS voc\.listing_current_score/);
+  assert.match(migration, /UNIQUE \(workspace_id, product_id, slot\)/);
+  assert.match(migration, /DROP TABLE voc\.listing_score_result/);
+  assert.match(migration, /DROP COLUMN IF EXISTS score_result_public_id/);
+  assert.match(migration, /DROP COLUMN IF EXISTS base_score_result_public_id/);
+});
+
+test('listing context joins SKU categories and versioned category VOC evidence', () => {
+  const index = buildListingContextIndex(
+    [{ productId: 'sku-1', category3: '商用冰箱', title: 'SKU 商品' }],
+    [{ competitorProductId: 'competitor-1', category: '商用冰箱' }],
+    [{ productId: 'competitor-1', reviewKey: 'review-1', rating: 2, content: '容易结霜,容量偏小', reviewDate: '2026-08-20T00:00:00.000Z' }],
+  );
+  const enriched = index.enrich(source(), '2026-08-24T00:00:00.000Z');
+  assert.equal(enriched.contextVersion, LISTING_CONTEXT_VERSION);
+  assert.deepEqual(enriched.categoryContext?.coreTerms, ['商用冰箱']);
+  assert.equal(enriched.categoryContext?.ruleVersion, 'jd-category-rules-2026-08-v3');
+  assert.equal(enriched.vocEvidence?.[0]?.text, '容易结霜,容量偏小');
+  assert.notEqual(enriched.sourceHash, source().sourceHash);
+});

Some files were not shown because too many files changed in this diff