| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313 |
- import { z } from 'zod';
- import { JD_VOC_DIMENSION_MAX, JD_VOC_RUBRIC_VERSION } from './domain.js';
- const listingDimensionSchema = z.enum(['title', 'selling_points', 'images', 'description', 'specifications']);
- const listingOverviewDimensionKeySchema = z.enum(['title', 'selling_points', 'images', 'description', 'specifications', 'search', 'voc', 'selling', 'facts', 'competitive', 'media']);
- const listingOverviewScoreNatureSchema = z.enum(['simulation', 'formal_ai', 'rule_precheck', 'unscored']);
- const jdVocDimensionKeySchema = z.enum(['search', 'voc', 'selling', 'facts', 'competitive', 'media']);
- const jdVocScoreKindSchema = z.enum(['jd_voc_rules', 'jd_voc_hybrid_ai']);
- const jdVocSourceHashSchema = z.string().regex(/^[a-f0-9]{64}$/, 'sourceHash_must_be_64_lowercase_hex');
- const jdVocCoverageSchema = z.object({
- percent: z.number().min(0).max(100),
- missing: z.array(z.string().min(1)),
- status: z.enum(['eligible', 'partial', 'blocked']),
- });
- const jdVocEvidenceSchema = z.object({
- id: z.string().min(1),
- ruleId: z.string().min(1),
- fieldPath: z.string().min(1),
- outcome: z.enum(['pass', 'fail', 'unknown']),
- message: z.string(),
- source: z.enum(['rule', 'ai']),
- level: z.enum(['unknown', 'fail', 'weak', 'pass', 'strong']).optional(),
- pointsAwarded: z.number().min(0).nullable(),
- maxPoints: z.number().positive(),
- confidence: z.number().min(0).max(1).nullable().optional(),
- citations: z.array(z.string()).optional(),
- });
- const jdVocDimensionSchema = z.object({
- key: jdVocDimensionKeySchema,
- score: z.number().min(0).nullable(),
- maxScore: z.number().positive(),
- fixedEarned: z.number().min(0).nullable(),
- verifiedMax: z.number().min(0),
- coverage: z.number().min(0).max(100),
- status: z.enum(['scored', 'partial', 'blocked']),
- evidence: z.array(jdVocEvidenceSchema),
- });
- const jdVocActionSchema = z.object({
- ruleId: z.string().min(1),
- priority: z.enum(['P0', 'P1', 'P2', 'DATA']),
- dimension: jdVocDimensionKeySchema,
- title: z.string().min(1),
- action: z.string().min(1),
- evidenceIds: z.array(z.string()),
- confidence: z.number().min(0).max(1).nullable(),
- potentialGain: z.number().min(0),
- });
- export const jdVocScorePayloadSchema = z.object({
- id: z.string().min(1),
- workspaceId: z.string().min(1),
- platform: z.literal('jd'),
- productId: z.string().min(1),
- sourceHash: jdVocSourceHashSchema,
- rubricVersion: z.literal(JD_VOC_RUBRIC_VERSION),
- scoreKind: jdVocScoreKindSchema,
- overallScore: z.number().min(0).max(100).nullable(),
- dimensions: z.array(jdVocDimensionSchema).length(6),
- coverage: jdVocCoverageSchema,
- voc: z.object({
- profileId: z.string().nullable(),
- productReviewCount: z.number().int().nonnegative(),
- categoryVocEvidenceCount: z.number().int().nonnegative(),
- concerns: z.array(z.object({
- id: z.string().min(1), question: z.string().min(1), severity: z.enum(['low', 'medium', 'high']),
- source: z.enum(['product-review', 'category-voc', 'category-profile', 'manual-question']), evidenceIds: z.array(z.string()),
- })),
- evidence: z.array(z.object({
- id: z.string().min(1), source: z.enum(['product-review', 'category-voc', 'category-profile', 'manual-question']),
- text: z.string(), rating: z.number().min(0).max(5).nullable().optional(), observedAt: z.string().nullable().optional(),
- })).optional(),
- }),
- compliance: z.object({
- status: z.enum(['normal', 'warning', 'blocked']), gate: z.enum(['PASS', 'WARN', 'BLOCK']),
- findings: z.array(z.object({ ruleId: z.string(), severity: z.string(), fieldPath: z.string(), message: z.string(), evidence: z.array(z.string()) })),
- }).optional(),
- competitor: z.object({
- basis: z.enum(['formal', 'category-inferred', 'none']), count: z.number().int().nonnegative(),
- comparableSurface: z.array(z.string()), productIds: z.array(z.string()), items: z.array(z.object({ productId:z.string(),title:z.string(),brand:z.string(),price:z.number().nullable(),specifications:z.array(z.string()),sellingPoints:z.array(z.string()),observedAt:z.string().nullable(),status:z.enum(['available','partial']) })).optional(),
- }),
- actions: z.array(jdVocActionSchema),
- aiReview: z.object({
- status: z.enum(['not_requested', 'pending', 'completed', 'failed']), model: z.string().nullable(), promptVersion: z.string().nullable(), baselineFingerprint: z.string().optional(), latencyMs:z.number().nonnegative().optional(), usage:z.object({promptTokens:z.number().int().nonnegative(),completionTokens:z.number().int().nonnegative(),totalTokens:z.number().int().nonnegative()}).optional(),
- baselineScore: z.number().min(0).max(100).nullable(), hybridScore: z.number().min(0).max(100).nullable(),
- assessments: z.array(z.object({ dimension: jdVocDimensionKeySchema, score: z.number().min(0).nullable(), evidenceIds: z.array(z.string()), rationale: z.string() })),
- suggestions: z.array(z.object({ title: z.string().min(1), action: z.string().min(1), evidenceIds: z.array(z.string()) })),
- }).optional(),
- imageReview: z.object({
- status: z.enum(['not_requested', 'shadow_completed', 'failed']), model: z.string().nullable(), evidence: z.array(z.object({ id: z.string().min(1), fieldPath: z.string().min(1), outcome: z.enum(['pass', 'fail', 'unknown']), message: z.string(), confidence: z.number().min(0).max(1) })), suggestions: z.array(z.string()),
- }).optional(),
- executionKey: z.string().min(1),
- inputFingerprint: z.string().min(1),
- createdAt: z.string().datetime(),
- }).superRefine((payload, context) => {
- const seen = new Set<string>();
- for (const [index, dimension] of payload.dimensions.entries()) {
- if (seen.has(dimension.key)) context.addIssue({ code: 'custom', path: ['dimensions', index, 'key'], message: 'duplicate_dimension' });
- seen.add(dimension.key);
- const expectedMax = JD_VOC_DIMENSION_MAX[dimension.key];
- if (dimension.maxScore !== expectedMax) context.addIssue({ code: 'custom', path: ['dimensions', index, 'maxScore'], message: 'dimension_max_score_mismatch' });
- if (dimension.score !== null && dimension.score > dimension.maxScore) context.addIssue({ code: 'custom', path: ['dimensions', index, 'score'], message: 'score_exceeds_max' });
- if (dimension.fixedEarned !== null && dimension.fixedEarned > dimension.maxScore) context.addIssue({ code: 'custom', path: ['dimensions', index, 'fixedEarned'], message: 'fixed_earned_exceeds_max' });
- if (dimension.verifiedMax > dimension.maxScore) context.addIssue({ code: 'custom', path: ['dimensions', index, 'verifiedMax'], message: 'verified_max_exceeds_max' });
- if (dimension.score === null && dimension.status === 'scored') context.addIssue({ code: 'custom', path: ['dimensions', index, 'status'], message: 'null_score_requires_partial_or_blocked' });
- }
- });
- // Contract name used by route/presenter code; keep payload alias for callers that
- // validate persisted Parse payloads directly.
- export const jdVocScoreResultSchema = jdVocScorePayloadSchema;
- export const listingWorkspaceQuerySchema = z.object({
- workspaceId: z.string().min(1).optional(),
- platform: z.literal('jd').default('jd'),
- });
- export const listingPageQuerySchema = listingWorkspaceQuerySchema.extend({
- limit: z.coerce.number().int().min(1).max(100).default(25),
- cursor: z.string().max(1_000).optional(),
- });
- export const listingFilterSchema = z.object({
- search: z.string().max(200).optional(),
- categoryId: z.string().max(100).optional(),
- itemStatus: z.string().max(100).optional(),
- scoreStatus: z.enum(['unscored', 'scored', 'partial', 'blocked', 'failed']).optional(),
- aiScoreStatus: z.enum(['not_scored', 'completed', 'partial', 'failed']).optional(),
- coverageStatus: z.enum(['eligible', 'partial', 'blocked']).optional(),
- minScore: z.coerce.number().min(0).max(100).optional(),
- maxScore: z.coerce.number().min(0).max(100).optional(),
- });
- export const listingProductQuerySchema = listingPageQuerySchema.extend({
- ...listingFilterSchema.shape,
- sort: z.enum(['productId', 'score_asc', 'score_desc', 'updated_desc']).default('productId'),
- });
- const categoryIdsSchema = z.preprocess((value) => {
- const values = Array.isArray(value) ? value : typeof value === 'string' ? value.split(',') : value;
- return Array.isArray(values) ? values.flatMap((item) => typeof item === 'string' ? item.split(',') : item) : values;
- }, z.array(z.string().trim().min(1).max(100)).max(50).transform((values) => [...new Set(values)]));
- export const listingOverviewQuerySchema = listingPageQuerySchema.extend({
- search: z.string().max(200).optional(),
- categoryIds: categoryIdsSchema.optional(),
- scoreNature: listingOverviewScoreNatureSchema.optional(),
- rubricVersion: z.string().min(1).max(100).optional(),
- scoreKind: z.enum(['rules', 'hybrid_ai', 'jd_voc_rules', 'jd_voc_hybrid_ai']).optional(),
- priority: z.enum(['P0', 'P1', 'P2', 'DATA']).optional(),
- coverageMin: z.coerce.number().min(0).max(100).optional(),
- coverageMax: z.coerce.number().min(0).max(100).optional(),
- minScore: z.coerce.number().min(0).max(100).optional(),
- maxScore: z.coerce.number().min(0).max(100).optional(),
- titleMin: z.coerce.number().min(0).max(30).optional(),
- titleMax: z.coerce.number().min(0).max(30).optional(),
- sellingPointsMin: z.coerce.number().min(0).max(25).optional(),
- sellingPointsMax: z.coerce.number().min(0).max(25).optional(),
- imagesMin: z.coerce.number().min(0).max(20).optional(),
- imagesMax: z.coerce.number().min(0).max(20).optional(),
- descriptionMin: z.coerce.number().min(0).max(15).optional(),
- descriptionMax: z.coerce.number().min(0).max(15).optional(),
- specificationsMin: z.coerce.number().min(0).max(10).optional(),
- specificationsMax: z.coerce.number().min(0).max(10).optional(),
- weakestDimension: listingOverviewDimensionKeySchema.optional(),
- sort: z.enum(['productId', 'overallScore', 'title', 'selling_points', 'images', 'description', 'specifications', 'search', 'voc', 'selling', 'facts', 'competitive', 'media', 'improvementPotential', 'scoredAt', 'syncedAt']).default('improvementPotential'),
- direction: z.enum(['asc', 'desc']).default('desc'),
- }).superRefine((query, context) => {
- const ranges: Array<[number | undefined, number | undefined, string]> = [
- [query.minScore, query.maxScore, 'minScore'],
- [query.titleMin, query.titleMax, 'titleMin'],
- [query.sellingPointsMin, query.sellingPointsMax, 'sellingPointsMin'],
- [query.imagesMin, query.imagesMax, 'imagesMin'],
- [query.descriptionMin, query.descriptionMax, 'descriptionMin'],
- [query.specificationsMin, query.specificationsMax, 'specificationsMin'],
- [query.coverageMin, query.coverageMax, 'coverageMin'],
- ];
- for (const [minimum, maximum, path] of ranges) {
- if (minimum !== undefined && maximum !== undefined && minimum > maximum) {
- context.addIssue({ code: 'custom', path: [path], message: 'minimum_must_not_exceed_maximum' });
- }
- }
- });
- export const scoreJobRequestSchema = z.object({
- workspaceId: z.string().min(1).optional(),
- platform: z.literal('jd').default('jd'),
- scope: z.discriminatedUnion('mode', [
- z.object({ mode: z.literal('selected'), productIds: z.array(z.string().min(1).max(100)).min(1).max(100).transform((items) => [...new Set(items)]) }),
- z.object({ mode: z.literal('filter'), filter: listingFilterSchema.default({}) }),
- ]),
- scoringMode: z.enum(['rules', 'ai']).optional(),
- rubricVersion: z.string().min(1).max(100).optional(),
- includeAiSuggestions: z.boolean().optional(),
- rescorePolicy: z.enum(['reuse', 'force']).default('reuse'),
- idempotencyKey: z.string().min(8).max(200).optional(),
- });
- const attributeSchema = z.object({ id: z.string(), name: z.string(), values: z.array(z.string()) });
- export const createVersionRequestSchema = z.object({
- workspaceId: z.string().min(1).optional(),
- platform: z.literal('jd').default('jd'),
- baseSourceHash: z.string().length(64),
- content: z.object({
- title: z.string().max(500).nullable(),
- sellingPoints: z.array(z.string().max(2_000)).max(30),
- descriptionHtml: z.string().max(200_000).nullable(),
- specifications: z.array(attributeSchema).max(500),
- imageUrls: z.array(z.url()).max(100),
- }).optional(),
- });
- const listingPresentationItemSchema = z.object({
- title: z.string(), resultLabel: z.string(), reason: z.string(), action: z.string().nullable(), impactText: z.string(), sources: z.array(z.string()),
- });
- export const listingScorePresentationSchema = z.object({
- score: z.number().min(0).max(100).nullable(), scoreText: z.string(), statusLabel: z.string(), methodLabel: z.string(), standardLabel: z.string(),
- dataUpdatedAtText: z.string(), scoredAtText: z.string(), scopeNote: z.string(), aiConfidence: z.number().min(0).max(1).nullable(), suggestions: z.array(z.string()),
- complianceStatus: z.enum(['normal', 'warning', 'needs_review', 'blocked']).nullable(),
- complianceFindings: z.array(z.object({ severity: z.enum(['low', 'medium', 'high', 'critical']), message: z.string(), evidence: z.array(z.string()) })),
- dimensions: z.array(z.object({
- key: z.enum(['title', 'selling_points', 'images', 'description', 'specifications']), name: z.string(), score: z.number().nullable(), maxScore: z.number(),
- scoreText: z.string(), conclusion: z.string(), items: z.array(listingPresentationItemSchema),
- })),
- });
- export const listingProductPresentationSchema = z.object({
- productId: z.string(), title: z.string().nullable(), imageUrl: z.string().nullable(), categoryText: z.string(), itemStatusLabel: z.string(),
- specificationCount: z.number().nullable(), dataStatusLabel: z.string(), scoreStatusLabel: z.string(), aiStatusLabel: z.string(), score: z.number().nullable(),
- scoreText: z.string(), methodLabel: z.string(), syncedAt: z.string(),
- });
- const listingOverviewDimensionValueSchema = z.object({
- score: z.number().nullable(),
- maxScore: z.number().positive(),
- rate: z.number().min(0).max(1).nullable(),
- gap: z.number().min(0).nullable(),
- });
- const listingOverviewDimensionsSchema = z.object({
- title: listingOverviewDimensionValueSchema,
- selling_points: listingOverviewDimensionValueSchema,
- images: listingOverviewDimensionValueSchema,
- description: listingOverviewDimensionValueSchema,
- specifications: listingOverviewDimensionValueSchema,
- });
- const jdVocOverviewDimensionsSchema = z.object({
- search: listingOverviewDimensionValueSchema, voc: listingOverviewDimensionValueSchema, selling: listingOverviewDimensionValueSchema,
- facts: listingOverviewDimensionValueSchema, competitive: listingOverviewDimensionValueSchema, media: listingOverviewDimensionValueSchema,
- });
- export const listingOverviewRowSchema = z.object({
- productId: z.string(),
- title: z.string().nullable(),
- imageUrl: z.string().nullable(),
- categoryId: z.string().nullable(),
- categoryName: z.string().nullable(),
- categoryPath: z.array(z.string()),
- categoryIds: z.array(z.string()),
- itemStatusLabel: z.string(),
- scoreNature: listingOverviewScoreNatureSchema,
- scoreNatureLabel: z.string(),
- overallScore: z.number().min(0).max(100).nullable(),
- overallRate: z.number().min(0).max(1).nullable(),
- dimensions: listingOverviewDimensionsSchema,
- jdVocDimensions: jdVocOverviewDimensionsSchema.optional(),
- weakestDimension: listingOverviewDimensionKeySchema.nullable(),
- improvementPotential: z.number().min(0).max(100).nullable(),
- scoredAt: z.string().nullable(),
- syncedAt: z.string(),
- rubricVersion: z.string().nullable().optional(),
- scoreKind: z.enum(['rules', 'hybrid_ai', 'jd_voc_rules', 'jd_voc_hybrid_ai']).nullable().optional(),
- coveragePercent: z.number().min(0).max(100).nullable().optional(),
- priority: z.enum(['P0', 'P1', 'P2', 'DATA']).nullable().optional(),
- });
- const listingOverviewDimensionStatSchema = z.object({
- key: listingOverviewDimensionKeySchema,
- label: z.string(),
- maxScore: z.number().positive(),
- scoredCount: z.number().int().nonnegative(),
- averageScore: z.number().nullable(),
- averageRate: z.number().min(0).max(1).nullable(),
- medianScore: z.number().nullable(),
- totalGap: z.number().nonnegative().nullable(),
- });
- export const listingOverviewResponseSchema = z.object({
- items: z.array(listingOverviewRowSchema),
- nextCursor: z.string().nullable(),
- summary: z.object({
- sourceTotal: z.number().int().nonnegative(),
- matchedTotal: z.number().int().nonnegative(),
- scoredTotal: z.number().int().nonnegative(),
- simulationTotal: z.number().int().nonnegative(),
- averageScore: z.number().min(0).max(100).nullable(),
- medianScore: z.number().min(0).max(100).nullable(),
- scoreDistribution: z.array(z.object({
- key: z.string(), label: z.string(), minScore: z.number(), maxScore: z.number(), maxScoreExclusive: z.boolean(), count: z.number().int().nonnegative(),
- })),
- dimensionStats: z.object({
- title: listingOverviewDimensionStatSchema,
- selling_points: listingOverviewDimensionStatSchema,
- images: listingOverviewDimensionStatSchema,
- description: listingOverviewDimensionStatSchema,
- specifications: listingOverviewDimensionStatSchema,
- }),
- jdVocDimensionStats: z.object({ search: listingOverviewDimensionStatSchema, voc: listingOverviewDimensionStatSchema, selling: listingOverviewDimensionStatSchema, facts: listingOverviewDimensionStatSchema, competitive: listingOverviewDimensionStatSchema, media: listingOverviewDimensionStatSchema }).optional(),
- categoryFacets: z.array(z.object({ categoryId: z.string(), categoryName: z.string(), categoryPath: z.array(z.string()), count: z.number().int().nonnegative() })),
- scoreNatureFacets: z.array(z.object({ value: listingOverviewScoreNatureSchema, label: z.string(), count: z.number().int().nonnegative() })),
- snapshotId: z.string(),
- generatedAt: z.string(),
- }),
- });
|