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>; 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(); const chars = Array.from(normalizedTitle.replace(/[\s()()【】\[\]\/|,,、::·—-]/gu, '')); for (const width of [2, 3, 4]) { const seen = new Set(); 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>((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(items: T[], worker: (item: T) => Promise, concurrency = 5): Promise { 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; });