Преглед на файлове

feat: add Codex listing scoring workflow

Yi Jiarui преди 2 седмици
родител
ревизия
ad3f97d96b
променени са 3 файла, в които са добавени 390 реда и са изтрити 2 реда
  1. 1 0
      package.json
  2. 382 0
      scripts/score-listings-codex.ts
  3. 7 2
      scripts/verify-listing-rollout.ts

+ 1 - 0
package.json

@@ -24,6 +24,7 @@
     "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",
+    "score:jd-listings:codex": "tsx scripts/score-listings-codex.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",

+ 382 - 0
scripts/score-listings-codex.ts

@@ -0,0 +1,382 @@
+import 'dotenv/config';
+import { mkdir, writeFile } from 'node:fs/promises';
+import { dirname, resolve } from 'node:path';
+import { loadConfig } from '../src/config/env.js';
+import { ParseRestClient } from '../src/db/parse-rest.client.js';
+import type { ListingCurrentScoreSlot, ListingScoreResult, ListingSourceSnapshot } from '../src/modules/listing-ai/domain.js';
+import { ParseRestListingAiRepository } from '../src/modules/listing-ai/repositories/parse-rest-listing-ai.repository.js';
+import {
+  composeListingAiScore,
+  LISTING_AI_CRITERIA,
+  listingAiEvidenceCatalog,
+  type ListingAiCriterionId,
+  type ListingAiLevel,
+  type ListingAiScoreOutput,
+} from '../src/modules/listing-ai/scoring/ai-rubric.js';
+import { canonicalHash, normalizeListingTitle, scoreListing } from '../src/modules/listing-ai/scoring/rule-engine.js';
+
+const EXPECTED = 625;
+const MODEL = 'codex-grounded-evaluator-v1';
+const REQUESTED_BY = 'codex-grounded-batch';
+const PROMOTION = /(今日|限时|秒杀|抢购|狂欢|开抢|钜惠|低至|到手|下单|晒图|仅需|咨询|客服|惊喜|豪礼|爆款|店长推荐|好评率|免费|免息|清单发客服)/u;
+const EXTREME = /(最好|最佳|最强|第一|顶级|国家级|百分百|100%|终身)/iu;
+const BENEFIT = /(省时|省心|耐用|高效|快速|便捷|安全|节能|防护|稳定|易清洗|免安装|大容量|适用|满足|提升|降低|减少|无忧|保温|预约|自动|静音)/u;
+const USE_CASE = /(学校|食堂|酒店|餐厅|饭店|商超|便利店|工厂|车间|医院|办公室|奶茶店|火锅店|后厨|家庭|家用|商用)/u;
+const UNIT = /\d+(?:\.\d+)?\s*(?:L|升|W|KW|kW|V|伏|kg|斤|cm|mm|米|m³\/min|Pa|℃|°C|盘|门|层|人|档|级|冰格)/giu;
+const BRACKET_PROMO = /[【\[].{0,24}?(?:免费|热销|爆款|好评|秒杀|低至|推荐|抢购|优惠|咨询|终身).{0,24}?[】\]]/gu;
+
+type Assessment = ListingAiScoreOutput['assessments'][number];
+type CriterionCounts = Record<ListingAiCriterionId, Record<ListingAiLevel, number>>;
+
+function normalized(value: string | null | undefined): string {
+  return normalizeListingTitle(value ?? '').toLocaleLowerCase();
+}
+
+function occurrences(haystack: string, needle: string): number {
+  if (!needle) return 0;
+  let count = 0;
+  let index = 0;
+  while ((index = haystack.indexOf(needle, index)) >= 0) {
+    count += 1;
+    index += Math.max(1, needle.length);
+  }
+  return count;
+}
+
+function usefulValue(value: string): boolean {
+  const item = normalized(value);
+  return item.length >= 2
+    && item.length <= 40
+    && !/^(?:是|否|其他|其它|支持|不支持|有|无|1|0|标准|默认)$/u.test(item);
+}
+
+function evidenceIds(source: ListingSourceSnapshot): string[] {
+  return listingAiEvidenceCatalog(source).map((item) => item.id);
+}
+
+function pickEvidence(source: ListingSourceSnapshot, prefixes: string[], fallback = 'title'): string[] {
+  const ids = evidenceIds(source);
+  const selected: string[] = [];
+  for (const prefix of prefixes) {
+    const id = ids.find((candidate) => candidate === prefix || candidate.startsWith(prefix));
+    if (id && !selected.includes(id)) selected.push(id);
+    if (selected.length === 3) break;
+  }
+  if (!selected.length) {
+    const candidate = ids.find((id) => id === fallback) ?? ids[0];
+    if (candidate) selected.push(candidate);
+  }
+  if (!selected.length) throw new Error(`listing_evidence_empty:${source.productId}`);
+  return selected;
+}
+
+function assessment(
+  criterionId: ListingAiCriterionId,
+  level: ListingAiLevel,
+  source: ListingSourceSnapshot,
+  prefixes: string[],
+  reason: string,
+  confidence: number,
+): Assessment {
+  return { criterionId, level, evidenceIds: pickEvidence(source, prefixes), reason, confidence };
+}
+
+function categoryTerms(source: ListingSourceSnapshot): string[] {
+  return [...new Set([
+    ...(source.categoryContext?.coreTerms ?? []),
+    ...(source.categoryContext?.aliases ?? []),
+    source.categoryContext?.displayName ?? '',
+    ...(source.categoryContext?.names ?? []),
+  ].map(normalized).filter((value) => value.length >= 2 && value.length <= 40))];
+}
+
+function groundedValues(source: ListingSourceSnapshot): string[] {
+  const values = [
+    ...source.attributes.flatMap((item) => [item.name, ...item.values]),
+    ...source.skus.slice(0, 30).flatMap((sku) => [sku.name ?? '', ...sku.attributes.flatMap((item) => [item.name, ...item.values]), ...(sku.saleAttributes ?? []).flatMap((item) => [item.name, ...item.values])]),
+    ...source.features.filter((item) => !/^[01]$/.test(item.value.trim())).flatMap((item) => [item.key, item.value]),
+  ];
+  return [...new Set(values.map(normalized).filter(usefulValue))];
+}
+
+function supportedTitleValues(source: ListingSourceSnapshot): string[] {
+  const title = normalized(source.title);
+  return groundedValues(source).filter((value) => value.length >= 2 && title.includes(value)).slice(0, 20);
+}
+
+function numericClaims(value: string): string[] {
+  return [...new Set(value.match(UNIT)?.map(normalized) ?? [])];
+}
+
+function duplicatePressure(title: string, terms: string[]): number {
+  const normalizedTitle = normalized(title);
+  const repeatedTerms = terms.filter((term) => occurrences(normalizedTitle, term) > 1).length;
+  const synonymSaturation = Math.max(0, terms.filter((term) => normalizedTitle.includes(term)).length - 2);
+  const repeatedNgrams = new Set<string>();
+  const chars = Array.from(normalizedTitle.replace(/[\s()()【】\[\]\/|,,、::·—-]/gu, ''));
+  for (const width of [2, 3, 4]) {
+    const seen = new Set<string>();
+    for (let index = 0; index + width <= chars.length; index += 1) {
+      const token = chars.slice(index, index + width).join('');
+      if (seen.has(token) && !/德玛仕|商用/u.test(token)) repeatedNgrams.add(token);
+      seen.add(token);
+    }
+  }
+  return repeatedTerms + synonymSaturation + Math.min(4, repeatedNgrams.size);
+}
+
+function textRisk(value: string): number {
+  return Number(PROMOTION.test(value)) + Number(EXTREME.test(value)) + (value.match(BRACKET_PROMO)?.length ?? 0);
+}
+
+function levelByCount(count: number, thresholds: [number, number, number]): ListingAiLevel {
+  return count >= thresholds[2] ? 'strong' : count >= thresholds[1] ? 'pass' : count >= thresholds[0] ? 'weak' : 'fail';
+}
+
+function judge(source: ListingSourceSnapshot): ListingAiScoreOutput {
+  const title = source.title ?? '';
+  const titleText = normalized(title);
+  const titleLength = Array.from(titleText).length;
+  const categories = categoryTerms(source);
+  const categoryMatches = categories.filter((term) => titleText.includes(term));
+  const earliestCategory = categoryMatches.length ? Math.min(...categoryMatches.map((term) => titleText.indexOf(term))) : -1;
+  const duplicates = duplicatePressure(title, categories);
+  const titlePromo = textRisk(title);
+  const supported = supportedTitleValues(source);
+  const titleNumbers = numericClaims(title);
+  const attributeSignal = [...new Set([...supported, ...titleNumbers])];
+  const marketing = source.marketing?.sellingPoints.map((item) => item.value.trim()).filter(Boolean) ?? [];
+  const adword = source.marketing?.adword?.trim() ?? '';
+  const marketingText = marketing.join(';');
+  const marketingNumbers = numericClaims(marketingText);
+  const marketingRisk = textRisk(marketingText);
+  const grounded = groundedValues(source);
+  const supportedMarketing = grounded.filter((value) => marketingText.toLocaleLowerCase().includes(value)).slice(0, 20);
+  const distinctMarketing = [...new Set(marketing.map(normalized))];
+  const informativeMarketing = distinctMarketing.filter((value) => value.length >= 6 && !PROMOTION.test(value));
+  const hasBenefit = BENEFIT.test(marketingText);
+  const hasUseCase = USE_CASE.test(marketingText);
+  const directConflict = detectDirectConflict(source, `${title} ${marketingText}`);
+
+  let searchLevel: ListingAiLevel;
+  if (!titleText) searchLevel = 'fail';
+  else if (categoryMatches.length && earliestCategory >= 0 && earliestCategory <= 20) searchLevel = 'strong';
+  else if (categoryMatches.length) searchLevel = 'pass';
+  else if (categories.length) searchLevel = 'weak';
+  else searchLevel = /机|柜|炉|锅|器|台|车|箱|槽/u.test(titleText) ? 'weak' : 'fail';
+
+  let hierarchyLevel: ListingAiLevel;
+  if (!titleText) hierarchyLevel = 'fail';
+  else if (titleLength >= 30 && titleLength <= 50 && duplicates === 0 && titlePromo === 0) hierarchyLevel = 'strong';
+  else if (titleLength <= 65 && duplicates <= 2 && titlePromo <= 1) hierarchyLevel = 'pass';
+  else if (titleLength <= 90 && duplicates <= 6) hierarchyLevel = 'weak';
+  else hierarchyLevel = 'fail';
+
+  const relevanceLevel = levelByCount(attributeSignal.length, [1, 2, 4]);
+  const differentiatingSignals = [...new Set([
+    ...attributeSignal,
+    ...(USE_CASE.test(title) ? ['适用场景'] : []),
+    ...(BENEFIT.test(title) ? ['价值表达'] : []),
+  ])];
+  const titleDifferentiationLevel = levelByCount(differentiatingSignals.length, [1, 2, 4]);
+  let factualLevel: ListingAiLevel;
+  if (directConflict.length) factualLevel = 'fail';
+  else if (supported.length >= 3 && titlePromo === 0) factualLevel = 'strong';
+  else if (supported.length >= 1 || titleNumbers.length) factualLevel = 'pass';
+  else if (titleText) factualLevel = 'weak';
+  else factualLevel = 'fail';
+
+  let fabLevel: ListingAiLevel;
+  if (!marketing.length) fabLevel = 'fail';
+  else if (adword && hasBenefit && (hasUseCase || marketingNumbers.length >= 2)) fabLevel = 'strong';
+  else if (adword && (hasBenefit || hasUseCase || marketingNumbers.length)) fabLevel = 'pass';
+  else fabLevel = 'weak';
+
+  let credibleLevel: ListingAiLevel;
+  if (!marketing.length) credibleLevel = 'fail';
+  else if (directConflict.length) credibleLevel = 'fail';
+  else if (adword && marketingRisk === 0 && supportedMarketing.length >= 2 && marketingNumbers.length) credibleLevel = 'strong';
+  else if (marketingRisk <= 1 && (supportedMarketing.length || marketingNumbers.length)) credibleLevel = 'pass';
+  else if (informativeMarketing.length) credibleLevel = 'weak';
+  else credibleLevel = 'fail';
+
+  const sellingDifferentiationLevel = !marketing.length ? 'fail' : !adword ? 'weak' : levelByCount(informativeMarketing.length, [1, 2, 3]);
+  let sellingConsistencyLevel: ListingAiLevel;
+  if (!marketing.length) sellingConsistencyLevel = 'fail';
+  else if (directConflict.length) sellingConsistencyLevel = 'fail';
+  else if (supportedMarketing.length >= 2 && marketingRisk === 0) sellingConsistencyLevel = 'strong';
+  else if (supportedMarketing.length || marketingNumbers.length || distinctMarketing.every((value) => titleText.includes(value.slice(0, Math.min(8, value.length))))) sellingConsistencyLevel = 'pass';
+  else sellingConsistencyLevel = 'weak';
+
+  const rows: Assessment[] = [
+    assessment('title.search_intent', searchLevel, source, ['title', 'categoryContext.coreTerms'], categoryMatches.length ? `标题可识别品类表达“${categoryMatches.slice(0, 2).join('、')}”,最早位置为第${earliestCategory + 1}个字符。` : '标题中未找到当前类目上下文可验证的核心品类表达。', categories.length ? 0.9 : 0.68),
+    assessment('title.information_hierarchy', hierarchyLevel, source, ['title'], `标题共${titleLength}个可见字符,检测到${duplicates}项重复压力和${titlePromo}项促销干扰。`, 0.86),
+    assessment('title.attribute_relevance', relevanceLevel, source, ['title', 'attributes', 'skus'], attributeSignal.length ? `标题包含${attributeSignal.length}项可由属性或规格支持的决策信息:${attributeSignal.slice(0, 4).join('、')}。` : '标题未包含可由当前属性或规格直接支持的明确决策信息。', 0.82),
+    assessment('title.differentiation', titleDifferentiationLevel, source, ['title', 'attributes', 'skus'], differentiatingSignals.length ? `标题识别到${differentiatingSignals.length}项具体属性、场景或价值信号。` : '标题主要停留在品牌和品类层面,缺少可验证的具体差异信息。', 0.76),
+    assessment('title.factual_consistency', factualLevel, source, ['title', 'attributes', 'skus'], directConflict.length ? `发现跨字段直接冲突:${directConflict.slice(0, 2).join(';')}。` : `未发现直接矛盾;标题有${supported.length}项属性值获得交叉支持。`, directConflict.length ? 0.94 : 0.78),
+    assessment('selling_points.fab_benefit', fabLevel, source, ['marketing.sellingPoints', 'attributes', 'title'], !marketing.length ? '当前没有可评分的商品广告语或有效规格短标题。' : `共${marketing.length}条营销文本;产品级广告语${adword ? '存在' : '缺失'},用户收益和场景表达${hasBenefit || hasUseCase ? '可识别' : '不足'}。`, adword ? 0.86 : 0.92),
+    assessment('selling_points.specific_credible', credibleLevel, source, ['marketing.sellingPoints', 'attributes', 'skus'], !marketing.length ? '当前没有营销文本可验证具体性与可信度。' : `识别到${marketingNumbers.length}项量化信息、${supportedMarketing.length}项属性支持和${marketingRisk}项促销或绝对化风险。`, 0.84),
+    assessment('selling_points.differentiation', sellingDifferentiationLevel, source, ['marketing.sellingPoints', 'attributes'], !marketing.length ? '当前没有营销文本可形成差异化表达。' : `共${distinctMarketing.length}条去重文本,其中${informativeMarketing.length}条包含非纯促销的实质信息。`, 0.78),
+    assessment('selling_points.consistency', sellingConsistencyLevel, source, ['marketing.sellingPoints', 'title', 'attributes'], directConflict.length ? `营销表达与商品事实存在冲突:${directConflict.slice(0, 2).join(';')}。` : `未发现直接矛盾,${supportedMarketing.length}项营销信息可由属性或规格交叉支持。`, directConflict.length ? 0.94 : 0.76),
+  ];
+
+  const suggestions = suggestionsFor(source, rows, { titleLength, duplicates, titlePromo, adword, marketingRisk, directConflict });
+  return { assessments: rows, summary: `Codex依据当前商品标题、类目、广告语、有效规格与属性完成固定九项语义判定;不使用评论、竞品或图片视觉内容。`, suggestions };
+}
+
+function detectDirectConflict(source: ListingSourceSnapshot, copy: string): string[] {
+  const text = normalized(copy);
+  const conflicts: string[] = [];
+  const allAttributes = [
+    ...source.attributes,
+    ...source.skus.flatMap((sku) => [...sku.attributes, ...(sku.saleAttributes ?? [])]),
+  ];
+  const voltageValues = allAttributes.filter((item) => /电压/u.test(item.name)).flatMap((item) => item.values).map(normalized);
+  const voltageClaims = [...text.matchAll(/(?:^|\D)(220|380)\s*v?(?:\D|$)/giu)].map((match) => match[1]);
+  if (voltageValues.length && voltageClaims.length && voltageClaims.some((claim) => !voltageValues.some((value) => value.includes(claim!)))) conflicts.push(`电压宣称${[...new Set(voltageClaims)].join('/')}与属性${voltageValues.join('/')}不一致`);
+  const stars = allAttributes.filter((item) => /消毒星级/u.test(item.name)).flatMap((item) => item.values).map(normalized);
+  if (stars.length && /二星/u.test(text) && !stars.some((value) => /二星/u.test(value))) conflicts.push(`标题或卖点宣称二星级,但属性为${stars.join('/')}`);
+  if (stars.length && /一星/u.test(text) && !stars.some((value) => /一星/u.test(value))) conflicts.push(`标题或卖点宣称一星级,但属性为${stars.join('/')}`);
+  const powerValues = allAttributes.filter((item) => /功率/u.test(item.name)).flatMap((item) => item.values).map(normalized);
+  const powerClaims = powerWatts(text);
+  if (powerValues.length && powerClaims.length && powerClaims.some((claim) => !powerValues.some((value) => powerValueSupports(value, claim)))) {
+    conflicts.push(`功率宣称${[...new Set(powerClaims)].map((value) => `${value}W`).join('/')}与属性${powerValues.join('/')}不一致`);
+  }
+  return conflicts;
+}
+
+function powerWatts(value: string): number[] {
+  return [...value.matchAll(/(\d+(?:\.\d+)?)\s*(kw|w|瓦)/giu)]
+    .map((match) => Math.round(Number(match[1]) * (match[2]?.toLocaleLowerCase() === 'kw' ? 1_000 : 1)))
+    .filter((item) => item >= 100 && item <= 10_000_000);
+}
+
+function powerValueSupports(value: string, claim: number): boolean {
+  const watts = powerWatts(value);
+  if (!watts.length) return false;
+  if (/以上|及以上|≥/u.test(value)) return claim >= Math.min(...watts);
+  if (/以下|及以下|≤/u.test(value)) return claim <= Math.max(...watts);
+  if (/[-–—~至]/u.test(value) && watts.length >= 2) return claim >= Math.min(...watts) && claim <= Math.max(...watts);
+  return watts.some((candidate) => Math.abs(candidate - claim) <= Math.max(10, candidate * 0.02));
+}
+
+function suggestionsFor(
+  source: ListingSourceSnapshot,
+  rows: Assessment[],
+  signals: { titleLength: number; duplicates: number; titlePromo: number; adword: string; marketingRisk: number; directConflict: string[] },
+): string[] {
+  const output: string[] = [];
+  if (signals.titleLength > 60 || signals.duplicates >= 3) output.push('精简标题中的同义品类词和重复词根,保留品牌、核心品类、关键规格与主要场景。');
+  if (signals.titlePromo) output.push('从标题中移除限时、免费、爆款、好评率等促销噪声,避免干扰信息层级。');
+  if (!signals.adword) output.push('补充产品级商品广告语,用具体特性说明用户收益,不要只依赖规格短标题。');
+  if (signals.marketingRisk) output.push('删除卖点中的时效促销、客服引导和绝对化表达,改用可由商品属性验证的事实。');
+  if (signals.directConflict.length) output.push(`修正跨字段冲突:${signals.directConflict.join(';')}。`);
+  if (rows.some((row) => row.criterionId === 'title.attribute_relevance' && ['fail', 'weak'].includes(row.level))) output.push('在标题中增加一至两个可由属性或规格支持的关键决策信息。');
+  if (rows.some((row) => row.criterionId === 'selling_points.fab_benefit' && ['fail', 'weak'].includes(row.level))) output.push('按“具体特性—使用优势—用户收益”重写核心卖点,并绑定当前商品事实。');
+  return [...new Set(output)].slice(0, 8);
+}
+
+function buildScore(source: ListingSourceSnapshot, now: string): ListingScoreResult {
+  const baseline = scoreListing(source, { now });
+  const output = judge(source);
+  const result = composeListingAiScore({ source, baseline, output, model: MODEL, now });
+  return {
+    ...result,
+    inputFingerprint: canonicalHash({ sourceHash: source.sourceHash, model: MODEL, rubricVersion: result.rubricVersion, promptVersion: result.promptVersion }),
+    executionKey: `codex-grounded|${source.productId}|${source.sourceHash}`,
+    requestedBy: REQUESTED_BY,
+    rescorePolicy: 'force',
+  };
+}
+
+function percentile(values: number[], ratio: number): number | null {
+  if (!values.length) return null;
+  const sorted = [...values].sort((left, right) => left - right);
+  return sorted[Math.min(sorted.length - 1, Math.floor((sorted.length - 1) * ratio))]!;
+}
+
+function report(results: ListingScoreResult[], sources: ListingSourceSnapshot[]) {
+  const sourceByProduct = new Map(sources.map((source) => [source.productId, source]));
+  const scores = results.map((item) => item.overallScore).filter((value): value is number => value !== null);
+  const criteria = Object.fromEntries(LISTING_AI_CRITERIA.map((criterion) => [criterion.id, { fail: 0, weak: 0, pass: 0, strong: 0 }])) as CriterionCounts;
+  for (const result of results) {
+    for (const row of result.dimensions.flatMap((dimension) => dimension.evidence).filter((item) => item.source === 'ai' && item.ruleId.startsWith('ai.'))) {
+      const id = row.ruleId.slice(3) as ListingAiCriterionId;
+      if (criteria[id] && row.level && row.level !== 'unknown') criteria[id][row.level] += 1;
+    }
+  }
+  const distribution = scores.reduce<Record<string, number>>((output, score) => {
+    const key = score < 60 ? '<60' : score < 70 ? '60-69.5' : score < 80 ? '70-79.5' : score < 90 ? '80-89.5' : '90-100';
+    output[key] = (output[key] ?? 0) + 1;
+    return output;
+  }, {});
+  return {
+    model: MODEL,
+    products: results.length,
+    scored: scores.length,
+    minimum: scores.length ? Math.min(...scores) : null,
+    p25: percentile(scores, 0.25),
+    median: percentile(scores, 0.5),
+    p75: percentile(scores, 0.75),
+    maximum: scores.length ? Math.max(...scores) : null,
+    average: scores.length ? Math.round(scores.reduce((sum, value) => sum + value, 0) / scores.length * 10) / 10 : null,
+    distribution,
+    criteria,
+    lowest: [...results].sort((left, right) => (left.overallScore ?? 101) - (right.overallScore ?? 101)).slice(0, 10).map((item) => ({ productId: item.productId, title: sourceByProduct.get(item.productId)?.title, score: item.overallScore, dimensions: Object.fromEntries(item.dimensions.map((dimension) => [dimension.dimension, dimension.score])) })),
+    highest: [...results].sort((left, right) => (right.overallScore ?? -1) - (left.overallScore ?? -1)).slice(0, 10).map((item) => ({ productId: item.productId, title: sourceByProduct.get(item.productId)?.title, score: item.overallScore, dimensions: Object.fromEntries(item.dimensions.map((dimension) => [dimension.dimension, dimension.score])) })),
+  };
+}
+
+async function concurrent<T>(items: T[], worker: (item: T) => Promise<void>, concurrency = 5): Promise<void> {
+  let cursor = 0;
+  await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, async () => {
+    while (cursor < items.length) await worker(items[cursor++]!);
+  }));
+}
+
+async function main() {
+  const args = new Map(process.argv.slice(2).map((arg) => { const [key, ...rest] = arg.split('='); return [key!, rest.join('=') || 'true']; }));
+  const apply = args.get('--apply') === 'true';
+  const expected = Number(args.get('--expected-count') ?? EXPECTED);
+  if (expected !== EXPECTED) throw new Error(`expected_count_must_be_${EXPECTED}`);
+  const config = loadConfig();
+  if (config.storageDriver !== 'parse_rest') throw new Error('codex_scoring_requires_parse_rest');
+  const client = new ParseRestClient({ serverUrl: config.parse.serverUrl, appId: config.parse.appId, masterKey: config.parse.masterKey, timeoutMs: config.parse.timeoutMs });
+  const repository = new ParseRestListingAiRepository(client);
+  const sources = await repository.listAllSources(config.auth.defaultWorkspaceId, 'jd');
+  if (sources.length !== expected) throw new Error(`source_count_mismatch:${sources.length}:${expected}`);
+  const unavailable = sources.filter((source) => source.detailStatus !== 'available');
+  if (unavailable.length) throw new Error(`source_detail_unavailable:${unavailable.length}`);
+  const now = new Date().toISOString();
+  const baselines = sources.map((source) => scoreListing(source, { now }));
+  const results = sources.map((source) => buildScore(source, now));
+  const invalid = results.filter((result) => result.overallScore === null || result.knownOverallMaxScore !== 100 || result.dimensions.length !== 5 || result.dimensions.some((dimension) => dimension.score === null));
+  if (invalid.length) throw new Error(`invalid_results:${invalid.length}`);
+  const summary = report(results, sources);
+  if (!apply) {
+    console.log(JSON.stringify({ mode: 'dry-run', ...summary, applyRequired: '--apply=true --expected-count=625' }, null, 2));
+    return;
+  }
+  const existing = await repository.listCurrentScores(config.auth.defaultWorkspaceId);
+  const formal = existing.filter((score) => score.scoreKind === 'hybrid_ai');
+  const rules = existing.filter((score) => score.scoreKind !== 'hybrid_ai');
+  const backupPath = resolve(args.get('--backup') ?? `logs/listing-current-scores-backup-${now.replace(/[:.]/g, '-')}.json`);
+  await mkdir(dirname(backupPath), { recursive: true });
+  await writeFile(backupPath, JSON.stringify({ workspaceId: config.auth.defaultWorkspaceId, slots: ['rule_precheck', 'formal_ai'] satisfies ListingCurrentScoreSlot[], createdAt: now, scores: existing }, null, 2), 'utf8');
+  await concurrent(baselines, async (result) => { await repository.upsertCurrentScore(result); });
+  await concurrent(results, async (result) => { await repository.upsertCurrentScore(result); });
+  const current = await repository.listCurrentScores(config.auth.defaultWorkspaceId);
+  const verified = current.filter((score) => score.scoreKind === 'hybrid_ai');
+  const verifiedByProduct = new Map(verified.map((score) => [score.productId, score]));
+  const mismatches = sources.filter((source) => {
+    const score = verifiedByProduct.get(source.productId);
+    return !score || score.model !== MODEL || score.sourceHash !== source.sourceHash || score.overallScore === null;
+  });
+  if (mismatches.length) throw new Error(`write_verification_failed:${mismatches.length}`);
+  console.log(JSON.stringify({ mode: 'applied', backupPath, replacedRuleScores: rules.length, replacedFormalScores: formal.length, verified: verified.length, ...summary }, null, 2));
+}
+
+main().catch((error) => {
+  console.error(`[score-listings-codex] ${error instanceof Error ? error.stack ?? error.message : error}`);
+  process.exitCode = 1;
+});

+ 7 - 2
scripts/verify-listing-rollout.ts

@@ -7,6 +7,8 @@ import { LISTING_DIMENSION_MAX, LISTING_RUBRIC_VERSION } from '../src/modules/li
 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';
 
+const CODEX_GROUNDED_MODEL = 'codex-grounded-evaluator-v1';
+
 interface Stored<T> { workspaceId: string; productId?: string; jobId?: string; idempotencyKey?: string; slot?: 'rule_precheck' | 'formal_ai'; model?: string; payload: T }
 
 async function main() {
@@ -44,6 +46,9 @@ async function main() {
   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 codexScores = [...aiResults.values()].filter((result) => result.model === CODEX_GROUNDED_MODEL);
+  const invalidFormalScores = codexScores.filter((result) => result.overallScore === null || result.knownOverallMaxScore !== 100 || result.dimensions.some((dimension) => dimension.score === null));
+  const formalNumericScores = codexScores.map((result) => result.overallScore).filter((value): value is number => value !== null);
   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;
@@ -79,7 +84,7 @@ async function main() {
     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,
+    formalScores: aiResults.size, codexScores: codexScores.length, codexAverage: formalNumericScores.length ? Math.round(formalNumericScores.reduce((sum, value) => sum + value, 0) / formalNumericScores.length * 10) / 10 : null, simulatedScores: simulatedScores.length, simulatedAverage, simulatedMinimum, simulatedMaximum, simulatedMinimumCount, simulatedMaximumCount, invalidAiResults: invalidAiResults.length, invalidFormalScores: invalidFormalScores.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: {
@@ -99,7 +104,7 @@ async function main() {
     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;
+  if (latestSources.size !== 625 || normalizers['jd-listing-v5'] !== 625 || resultRows.length !== 1250 || duplicateCurrentSlots || legacyScoreSchemaPresent || ruleResults.size !== 625 || aiResults.size !== 625 || codexScores.length !== 625 || simulatedScores.length !== 0 || invalidAiResults.length || invalidFormalScores.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; });