|
@@ -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;
|
|
|
|
|
+});
|