score-listings-codex.ts 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382
  1. import 'dotenv/config';
  2. import { mkdir, writeFile } from 'node:fs/promises';
  3. import { dirname, resolve } from 'node:path';
  4. import { loadConfig } from '../src/config/env.js';
  5. import { ParseRestClient } from '../src/db/parse-rest.client.js';
  6. import type { ListingCurrentScoreSlot, ListingScoreResult, ListingSourceSnapshot } from '../src/modules/listing-ai/domain.js';
  7. import { ParseRestListingAiRepository } from '../src/modules/listing-ai/repositories/parse-rest-listing-ai.repository.js';
  8. import {
  9. composeListingAiScore,
  10. LISTING_AI_CRITERIA,
  11. listingAiEvidenceCatalog,
  12. type ListingAiCriterionId,
  13. type ListingAiLevel,
  14. type ListingAiScoreOutput,
  15. } from '../src/modules/listing-ai/scoring/ai-rubric.js';
  16. import { canonicalHash, normalizeListingTitle, scoreListing } from '../src/modules/listing-ai/scoring/rule-engine.js';
  17. const EXPECTED = 625;
  18. const MODEL = 'codex-grounded-evaluator-v1';
  19. const REQUESTED_BY = 'codex-grounded-batch';
  20. const PROMOTION = /(今日|限时|秒杀|抢购|狂欢|开抢|钜惠|低至|到手|下单|晒图|仅需|咨询|客服|惊喜|豪礼|爆款|店长推荐|好评率|免费|免息|清单发客服)/u;
  21. const EXTREME = /(最好|最佳|最强|第一|顶级|国家级|百分百|100%|终身)/iu;
  22. const BENEFIT = /(省时|省心|耐用|高效|快速|便捷|安全|节能|防护|稳定|易清洗|免安装|大容量|适用|满足|提升|降低|减少|无忧|保温|预约|自动|静音)/u;
  23. const USE_CASE = /(学校|食堂|酒店|餐厅|饭店|商超|便利店|工厂|车间|医院|办公室|奶茶店|火锅店|后厨|家庭|家用|商用)/u;
  24. const UNIT = /\d+(?:\.\d+)?\s*(?:L|升|W|KW|kW|V|伏|kg|斤|cm|mm|米|m³\/min|Pa|℃|°C|盘|门|层|人|档|级|冰格)/giu;
  25. const BRACKET_PROMO = /[【\[].{0,24}?(?:免费|热销|爆款|好评|秒杀|低至|推荐|抢购|优惠|咨询|终身).{0,24}?[】\]]/gu;
  26. type Assessment = ListingAiScoreOutput['assessments'][number];
  27. type CriterionCounts = Record<ListingAiCriterionId, Record<ListingAiLevel, number>>;
  28. function normalized(value: string | null | undefined): string {
  29. return normalizeListingTitle(value ?? '').toLocaleLowerCase();
  30. }
  31. function occurrences(haystack: string, needle: string): number {
  32. if (!needle) return 0;
  33. let count = 0;
  34. let index = 0;
  35. while ((index = haystack.indexOf(needle, index)) >= 0) {
  36. count += 1;
  37. index += Math.max(1, needle.length);
  38. }
  39. return count;
  40. }
  41. function usefulValue(value: string): boolean {
  42. const item = normalized(value);
  43. return item.length >= 2
  44. && item.length <= 40
  45. && !/^(?:是|否|其他|其它|支持|不支持|有|无|1|0|标准|默认)$/u.test(item);
  46. }
  47. function evidenceIds(source: ListingSourceSnapshot): string[] {
  48. return listingAiEvidenceCatalog(source).map((item) => item.id);
  49. }
  50. function pickEvidence(source: ListingSourceSnapshot, prefixes: string[], fallback = 'title'): string[] {
  51. const ids = evidenceIds(source);
  52. const selected: string[] = [];
  53. for (const prefix of prefixes) {
  54. const id = ids.find((candidate) => candidate === prefix || candidate.startsWith(prefix));
  55. if (id && !selected.includes(id)) selected.push(id);
  56. if (selected.length === 3) break;
  57. }
  58. if (!selected.length) {
  59. const candidate = ids.find((id) => id === fallback) ?? ids[0];
  60. if (candidate) selected.push(candidate);
  61. }
  62. if (!selected.length) throw new Error(`listing_evidence_empty:${source.productId}`);
  63. return selected;
  64. }
  65. function assessment(
  66. criterionId: ListingAiCriterionId,
  67. level: ListingAiLevel,
  68. source: ListingSourceSnapshot,
  69. prefixes: string[],
  70. reason: string,
  71. confidence: number,
  72. ): Assessment {
  73. return { criterionId, level, evidenceIds: pickEvidence(source, prefixes), reason, confidence };
  74. }
  75. function categoryTerms(source: ListingSourceSnapshot): string[] {
  76. return [...new Set([
  77. ...(source.categoryContext?.coreTerms ?? []),
  78. ...(source.categoryContext?.aliases ?? []),
  79. source.categoryContext?.displayName ?? '',
  80. ...(source.categoryContext?.names ?? []),
  81. ].map(normalized).filter((value) => value.length >= 2 && value.length <= 40))];
  82. }
  83. function groundedValues(source: ListingSourceSnapshot): string[] {
  84. const values = [
  85. ...source.attributes.flatMap((item) => [item.name, ...item.values]),
  86. ...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])]),
  87. ...source.features.filter((item) => !/^[01]$/.test(item.value.trim())).flatMap((item) => [item.key, item.value]),
  88. ];
  89. return [...new Set(values.map(normalized).filter(usefulValue))];
  90. }
  91. function supportedTitleValues(source: ListingSourceSnapshot): string[] {
  92. const title = normalized(source.title);
  93. return groundedValues(source).filter((value) => value.length >= 2 && title.includes(value)).slice(0, 20);
  94. }
  95. function numericClaims(value: string): string[] {
  96. return [...new Set(value.match(UNIT)?.map(normalized) ?? [])];
  97. }
  98. function duplicatePressure(title: string, terms: string[]): number {
  99. const normalizedTitle = normalized(title);
  100. const repeatedTerms = terms.filter((term) => occurrences(normalizedTitle, term) > 1).length;
  101. const synonymSaturation = Math.max(0, terms.filter((term) => normalizedTitle.includes(term)).length - 2);
  102. const repeatedNgrams = new Set<string>();
  103. const chars = Array.from(normalizedTitle.replace(/[\s()()【】\[\]\/|,,、::·—-]/gu, ''));
  104. for (const width of [2, 3, 4]) {
  105. const seen = new Set<string>();
  106. for (let index = 0; index + width <= chars.length; index += 1) {
  107. const token = chars.slice(index, index + width).join('');
  108. if (seen.has(token) && !/德玛仕|商用/u.test(token)) repeatedNgrams.add(token);
  109. seen.add(token);
  110. }
  111. }
  112. return repeatedTerms + synonymSaturation + Math.min(4, repeatedNgrams.size);
  113. }
  114. function textRisk(value: string): number {
  115. return Number(PROMOTION.test(value)) + Number(EXTREME.test(value)) + (value.match(BRACKET_PROMO)?.length ?? 0);
  116. }
  117. function levelByCount(count: number, thresholds: [number, number, number]): ListingAiLevel {
  118. return count >= thresholds[2] ? 'strong' : count >= thresholds[1] ? 'pass' : count >= thresholds[0] ? 'weak' : 'fail';
  119. }
  120. function judge(source: ListingSourceSnapshot): ListingAiScoreOutput {
  121. const title = source.title ?? '';
  122. const titleText = normalized(title);
  123. const titleLength = Array.from(titleText).length;
  124. const categories = categoryTerms(source);
  125. const categoryMatches = categories.filter((term) => titleText.includes(term));
  126. const earliestCategory = categoryMatches.length ? Math.min(...categoryMatches.map((term) => titleText.indexOf(term))) : -1;
  127. const duplicates = duplicatePressure(title, categories);
  128. const titlePromo = textRisk(title);
  129. const supported = supportedTitleValues(source);
  130. const titleNumbers = numericClaims(title);
  131. const attributeSignal = [...new Set([...supported, ...titleNumbers])];
  132. const marketing = source.marketing?.sellingPoints.map((item) => item.value.trim()).filter(Boolean) ?? [];
  133. const adword = source.marketing?.adword?.trim() ?? '';
  134. const marketingText = marketing.join(';');
  135. const marketingNumbers = numericClaims(marketingText);
  136. const marketingRisk = textRisk(marketingText);
  137. const grounded = groundedValues(source);
  138. const supportedMarketing = grounded.filter((value) => marketingText.toLocaleLowerCase().includes(value)).slice(0, 20);
  139. const distinctMarketing = [...new Set(marketing.map(normalized))];
  140. const informativeMarketing = distinctMarketing.filter((value) => value.length >= 6 && !PROMOTION.test(value));
  141. const hasBenefit = BENEFIT.test(marketingText);
  142. const hasUseCase = USE_CASE.test(marketingText);
  143. const directConflict = detectDirectConflict(source, `${title} ${marketingText}`);
  144. let searchLevel: ListingAiLevel;
  145. if (!titleText) searchLevel = 'fail';
  146. else if (categoryMatches.length && earliestCategory >= 0 && earliestCategory <= 20) searchLevel = 'strong';
  147. else if (categoryMatches.length) searchLevel = 'pass';
  148. else if (categories.length) searchLevel = 'weak';
  149. else searchLevel = /机|柜|炉|锅|器|台|车|箱|槽/u.test(titleText) ? 'weak' : 'fail';
  150. let hierarchyLevel: ListingAiLevel;
  151. if (!titleText) hierarchyLevel = 'fail';
  152. else if (titleLength >= 30 && titleLength <= 50 && duplicates === 0 && titlePromo === 0) hierarchyLevel = 'strong';
  153. else if (titleLength <= 65 && duplicates <= 2 && titlePromo <= 1) hierarchyLevel = 'pass';
  154. else if (titleLength <= 90 && duplicates <= 6) hierarchyLevel = 'weak';
  155. else hierarchyLevel = 'fail';
  156. const relevanceLevel = levelByCount(attributeSignal.length, [1, 2, 4]);
  157. const differentiatingSignals = [...new Set([
  158. ...attributeSignal,
  159. ...(USE_CASE.test(title) ? ['适用场景'] : []),
  160. ...(BENEFIT.test(title) ? ['价值表达'] : []),
  161. ])];
  162. const titleDifferentiationLevel = levelByCount(differentiatingSignals.length, [1, 2, 4]);
  163. let factualLevel: ListingAiLevel;
  164. if (directConflict.length) factualLevel = 'fail';
  165. else if (supported.length >= 3 && titlePromo === 0) factualLevel = 'strong';
  166. else if (supported.length >= 1 || titleNumbers.length) factualLevel = 'pass';
  167. else if (titleText) factualLevel = 'weak';
  168. else factualLevel = 'fail';
  169. let fabLevel: ListingAiLevel;
  170. if (!marketing.length) fabLevel = 'fail';
  171. else if (adword && hasBenefit && (hasUseCase || marketingNumbers.length >= 2)) fabLevel = 'strong';
  172. else if (adword && (hasBenefit || hasUseCase || marketingNumbers.length)) fabLevel = 'pass';
  173. else fabLevel = 'weak';
  174. let credibleLevel: ListingAiLevel;
  175. if (!marketing.length) credibleLevel = 'fail';
  176. else if (directConflict.length) credibleLevel = 'fail';
  177. else if (adword && marketingRisk === 0 && supportedMarketing.length >= 2 && marketingNumbers.length) credibleLevel = 'strong';
  178. else if (marketingRisk <= 1 && (supportedMarketing.length || marketingNumbers.length)) credibleLevel = 'pass';
  179. else if (informativeMarketing.length) credibleLevel = 'weak';
  180. else credibleLevel = 'fail';
  181. const sellingDifferentiationLevel = !marketing.length ? 'fail' : !adword ? 'weak' : levelByCount(informativeMarketing.length, [1, 2, 3]);
  182. let sellingConsistencyLevel: ListingAiLevel;
  183. if (!marketing.length) sellingConsistencyLevel = 'fail';
  184. else if (directConflict.length) sellingConsistencyLevel = 'fail';
  185. else if (supportedMarketing.length >= 2 && marketingRisk === 0) sellingConsistencyLevel = 'strong';
  186. else if (supportedMarketing.length || marketingNumbers.length || distinctMarketing.every((value) => titleText.includes(value.slice(0, Math.min(8, value.length))))) sellingConsistencyLevel = 'pass';
  187. else sellingConsistencyLevel = 'weak';
  188. const rows: Assessment[] = [
  189. assessment('title.search_intent', searchLevel, source, ['title', 'categoryContext.coreTerms'], categoryMatches.length ? `标题可识别品类表达“${categoryMatches.slice(0, 2).join('、')}”,最早位置为第${earliestCategory + 1}个字符。` : '标题中未找到当前类目上下文可验证的核心品类表达。', categories.length ? 0.9 : 0.68),
  190. assessment('title.information_hierarchy', hierarchyLevel, source, ['title'], `标题共${titleLength}个可见字符,检测到${duplicates}项重复压力和${titlePromo}项促销干扰。`, 0.86),
  191. assessment('title.attribute_relevance', relevanceLevel, source, ['title', 'attributes', 'skus'], attributeSignal.length ? `标题包含${attributeSignal.length}项可由属性或规格支持的决策信息:${attributeSignal.slice(0, 4).join('、')}。` : '标题未包含可由当前属性或规格直接支持的明确决策信息。', 0.82),
  192. assessment('title.differentiation', titleDifferentiationLevel, source, ['title', 'attributes', 'skus'], differentiatingSignals.length ? `标题识别到${differentiatingSignals.length}项具体属性、场景或价值信号。` : '标题主要停留在品牌和品类层面,缺少可验证的具体差异信息。', 0.76),
  193. assessment('title.factual_consistency', factualLevel, source, ['title', 'attributes', 'skus'], directConflict.length ? `发现跨字段直接冲突:${directConflict.slice(0, 2).join(';')}。` : `未发现直接矛盾;标题有${supported.length}项属性值获得交叉支持。`, directConflict.length ? 0.94 : 0.78),
  194. assessment('selling_points.fab_benefit', fabLevel, source, ['marketing.sellingPoints', 'attributes', 'title'], !marketing.length ? '当前没有可评分的商品广告语或有效规格短标题。' : `共${marketing.length}条营销文本;产品级广告语${adword ? '存在' : '缺失'},用户收益和场景表达${hasBenefit || hasUseCase ? '可识别' : '不足'}。`, adword ? 0.86 : 0.92),
  195. assessment('selling_points.specific_credible', credibleLevel, source, ['marketing.sellingPoints', 'attributes', 'skus'], !marketing.length ? '当前没有营销文本可验证具体性与可信度。' : `识别到${marketingNumbers.length}项量化信息、${supportedMarketing.length}项属性支持和${marketingRisk}项促销或绝对化风险。`, 0.84),
  196. assessment('selling_points.differentiation', sellingDifferentiationLevel, source, ['marketing.sellingPoints', 'attributes'], !marketing.length ? '当前没有营销文本可形成差异化表达。' : `共${distinctMarketing.length}条去重文本,其中${informativeMarketing.length}条包含非纯促销的实质信息。`, 0.78),
  197. 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),
  198. ];
  199. const suggestions = suggestionsFor(source, rows, { titleLength, duplicates, titlePromo, adword, marketingRisk, directConflict });
  200. return { assessments: rows, summary: `Codex依据当前商品标题、类目、广告语、有效规格与属性完成固定九项语义判定;不使用评论、竞品或图片视觉内容。`, suggestions };
  201. }
  202. function detectDirectConflict(source: ListingSourceSnapshot, copy: string): string[] {
  203. const text = normalized(copy);
  204. const conflicts: string[] = [];
  205. const allAttributes = [
  206. ...source.attributes,
  207. ...source.skus.flatMap((sku) => [...sku.attributes, ...(sku.saleAttributes ?? [])]),
  208. ];
  209. const voltageValues = allAttributes.filter((item) => /电压/u.test(item.name)).flatMap((item) => item.values).map(normalized);
  210. const voltageClaims = [...text.matchAll(/(?:^|\D)(220|380)\s*v?(?:\D|$)/giu)].map((match) => match[1]);
  211. if (voltageValues.length && voltageClaims.length && voltageClaims.some((claim) => !voltageValues.some((value) => value.includes(claim!)))) conflicts.push(`电压宣称${[...new Set(voltageClaims)].join('/')}与属性${voltageValues.join('/')}不一致`);
  212. const stars = allAttributes.filter((item) => /消毒星级/u.test(item.name)).flatMap((item) => item.values).map(normalized);
  213. if (stars.length && /二星/u.test(text) && !stars.some((value) => /二星/u.test(value))) conflicts.push(`标题或卖点宣称二星级,但属性为${stars.join('/')}`);
  214. if (stars.length && /一星/u.test(text) && !stars.some((value) => /一星/u.test(value))) conflicts.push(`标题或卖点宣称一星级,但属性为${stars.join('/')}`);
  215. const powerValues = allAttributes.filter((item) => /功率/u.test(item.name)).flatMap((item) => item.values).map(normalized);
  216. const powerClaims = powerWatts(text);
  217. if (powerValues.length && powerClaims.length && powerClaims.some((claim) => !powerValues.some((value) => powerValueSupports(value, claim)))) {
  218. conflicts.push(`功率宣称${[...new Set(powerClaims)].map((value) => `${value}W`).join('/')}与属性${powerValues.join('/')}不一致`);
  219. }
  220. return conflicts;
  221. }
  222. function powerWatts(value: string): number[] {
  223. return [...value.matchAll(/(\d+(?:\.\d+)?)\s*(kw|w|瓦)/giu)]
  224. .map((match) => Math.round(Number(match[1]) * (match[2]?.toLocaleLowerCase() === 'kw' ? 1_000 : 1)))
  225. .filter((item) => item >= 100 && item <= 10_000_000);
  226. }
  227. function powerValueSupports(value: string, claim: number): boolean {
  228. const watts = powerWatts(value);
  229. if (!watts.length) return false;
  230. if (/以上|及以上|≥/u.test(value)) return claim >= Math.min(...watts);
  231. if (/以下|及以下|≤/u.test(value)) return claim <= Math.max(...watts);
  232. if (/[-–—~至]/u.test(value) && watts.length >= 2) return claim >= Math.min(...watts) && claim <= Math.max(...watts);
  233. return watts.some((candidate) => Math.abs(candidate - claim) <= Math.max(10, candidate * 0.02));
  234. }
  235. function suggestionsFor(
  236. source: ListingSourceSnapshot,
  237. rows: Assessment[],
  238. signals: { titleLength: number; duplicates: number; titlePromo: number; adword: string; marketingRisk: number; directConflict: string[] },
  239. ): string[] {
  240. const output: string[] = [];
  241. if (signals.titleLength > 60 || signals.duplicates >= 3) output.push('精简标题中的同义品类词和重复词根,保留品牌、核心品类、关键规格与主要场景。');
  242. if (signals.titlePromo) output.push('从标题中移除限时、免费、爆款、好评率等促销噪声,避免干扰信息层级。');
  243. if (!signals.adword) output.push('补充产品级商品广告语,用具体特性说明用户收益,不要只依赖规格短标题。');
  244. if (signals.marketingRisk) output.push('删除卖点中的时效促销、客服引导和绝对化表达,改用可由商品属性验证的事实。');
  245. if (signals.directConflict.length) output.push(`修正跨字段冲突:${signals.directConflict.join(';')}。`);
  246. if (rows.some((row) => row.criterionId === 'title.attribute_relevance' && ['fail', 'weak'].includes(row.level))) output.push('在标题中增加一至两个可由属性或规格支持的关键决策信息。');
  247. if (rows.some((row) => row.criterionId === 'selling_points.fab_benefit' && ['fail', 'weak'].includes(row.level))) output.push('按“具体特性—使用优势—用户收益”重写核心卖点,并绑定当前商品事实。');
  248. return [...new Set(output)].slice(0, 8);
  249. }
  250. function buildScore(source: ListingSourceSnapshot, now: string): ListingScoreResult {
  251. const baseline = scoreListing(source, { now });
  252. const output = judge(source);
  253. const result = composeListingAiScore({ source, baseline, output, model: MODEL, now });
  254. return {
  255. ...result,
  256. inputFingerprint: canonicalHash({ sourceHash: source.sourceHash, model: MODEL, rubricVersion: result.rubricVersion, promptVersion: result.promptVersion }),
  257. executionKey: `codex-grounded|${source.productId}|${source.sourceHash}`,
  258. requestedBy: REQUESTED_BY,
  259. rescorePolicy: 'force',
  260. };
  261. }
  262. function percentile(values: number[], ratio: number): number | null {
  263. if (!values.length) return null;
  264. const sorted = [...values].sort((left, right) => left - right);
  265. return sorted[Math.min(sorted.length - 1, Math.floor((sorted.length - 1) * ratio))]!;
  266. }
  267. function report(results: ListingScoreResult[], sources: ListingSourceSnapshot[]) {
  268. const sourceByProduct = new Map(sources.map((source) => [source.productId, source]));
  269. const scores = results.map((item) => item.overallScore).filter((value): value is number => value !== null);
  270. const criteria = Object.fromEntries(LISTING_AI_CRITERIA.map((criterion) => [criterion.id, { fail: 0, weak: 0, pass: 0, strong: 0 }])) as CriterionCounts;
  271. for (const result of results) {
  272. for (const row of result.dimensions.flatMap((dimension) => dimension.evidence).filter((item) => item.source === 'ai' && item.ruleId.startsWith('ai.'))) {
  273. const id = row.ruleId.slice(3) as ListingAiCriterionId;
  274. if (criteria[id] && row.level && row.level !== 'unknown') criteria[id][row.level] += 1;
  275. }
  276. }
  277. const distribution = scores.reduce<Record<string, number>>((output, score) => {
  278. const key = score < 60 ? '<60' : score < 70 ? '60-69.5' : score < 80 ? '70-79.5' : score < 90 ? '80-89.5' : '90-100';
  279. output[key] = (output[key] ?? 0) + 1;
  280. return output;
  281. }, {});
  282. return {
  283. model: MODEL,
  284. products: results.length,
  285. scored: scores.length,
  286. minimum: scores.length ? Math.min(...scores) : null,
  287. p25: percentile(scores, 0.25),
  288. median: percentile(scores, 0.5),
  289. p75: percentile(scores, 0.75),
  290. maximum: scores.length ? Math.max(...scores) : null,
  291. average: scores.length ? Math.round(scores.reduce((sum, value) => sum + value, 0) / scores.length * 10) / 10 : null,
  292. distribution,
  293. criteria,
  294. 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])) })),
  295. 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])) })),
  296. };
  297. }
  298. async function concurrent<T>(items: T[], worker: (item: T) => Promise<void>, concurrency = 5): Promise<void> {
  299. let cursor = 0;
  300. await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, async () => {
  301. while (cursor < items.length) await worker(items[cursor++]!);
  302. }));
  303. }
  304. async function main() {
  305. const args = new Map(process.argv.slice(2).map((arg) => { const [key, ...rest] = arg.split('='); return [key!, rest.join('=') || 'true']; }));
  306. const apply = args.get('--apply') === 'true';
  307. const expected = Number(args.get('--expected-count') ?? EXPECTED);
  308. if (expected !== EXPECTED) throw new Error(`expected_count_must_be_${EXPECTED}`);
  309. const config = loadConfig();
  310. if (config.storageDriver !== 'parse_rest') throw new Error('codex_scoring_requires_parse_rest');
  311. const client = new ParseRestClient({ serverUrl: config.parse.serverUrl, appId: config.parse.appId, masterKey: config.parse.masterKey, timeoutMs: config.parse.timeoutMs });
  312. const repository = new ParseRestListingAiRepository(client);
  313. const sources = await repository.listAllSources(config.auth.defaultWorkspaceId, 'jd');
  314. if (sources.length !== expected) throw new Error(`source_count_mismatch:${sources.length}:${expected}`);
  315. const unavailable = sources.filter((source) => source.detailStatus !== 'available');
  316. if (unavailable.length) throw new Error(`source_detail_unavailable:${unavailable.length}`);
  317. const now = new Date().toISOString();
  318. const baselines = sources.map((source) => scoreListing(source, { now }));
  319. const results = sources.map((source) => buildScore(source, now));
  320. const invalid = results.filter((result) => result.overallScore === null || result.knownOverallMaxScore !== 100 || result.dimensions.length !== 5 || result.dimensions.some((dimension) => dimension.score === null));
  321. if (invalid.length) throw new Error(`invalid_results:${invalid.length}`);
  322. const summary = report(results, sources);
  323. if (!apply) {
  324. console.log(JSON.stringify({ mode: 'dry-run', ...summary, applyRequired: '--apply=true --expected-count=625' }, null, 2));
  325. return;
  326. }
  327. const existing = await repository.listCurrentScores(config.auth.defaultWorkspaceId);
  328. const formal = existing.filter((score) => score.scoreKind === 'hybrid_ai');
  329. const rules = existing.filter((score) => score.scoreKind !== 'hybrid_ai');
  330. const backupPath = resolve(args.get('--backup') ?? `logs/listing-current-scores-backup-${now.replace(/[:.]/g, '-')}.json`);
  331. await mkdir(dirname(backupPath), { recursive: true });
  332. await writeFile(backupPath, JSON.stringify({ workspaceId: config.auth.defaultWorkspaceId, slots: ['rule_precheck', 'formal_ai'] satisfies ListingCurrentScoreSlot[], createdAt: now, scores: existing }, null, 2), 'utf8');
  333. await concurrent(baselines, async (result) => { await repository.upsertCurrentScore(result); });
  334. await concurrent(results, async (result) => { await repository.upsertCurrentScore(result); });
  335. const current = await repository.listCurrentScores(config.auth.defaultWorkspaceId);
  336. const verified = current.filter((score) => score.scoreKind === 'hybrid_ai');
  337. const verifiedByProduct = new Map(verified.map((score) => [score.productId, score]));
  338. const mismatches = sources.filter((source) => {
  339. const score = verifiedByProduct.get(source.productId);
  340. return !score || score.model !== MODEL || score.sourceHash !== source.sourceHash || score.overallScore === null;
  341. });
  342. if (mismatches.length) throw new Error(`write_verification_failed:${mismatches.length}`);
  343. console.log(JSON.stringify({ mode: 'applied', backupPath, replacedRuleScores: rules.length, replacedFormalScores: formal.length, verified: verified.length, ...summary }, null, 2));
  344. }
  345. main().catch((error) => {
  346. console.error(`[score-listings-codex] ${error instanceof Error ? error.stack ?? error.message : error}`);
  347. process.exitCode = 1;
  348. });