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