schemas.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313
  1. import { z } from 'zod';
  2. import { JD_VOC_DIMENSION_MAX, JD_VOC_RUBRIC_VERSION } from './domain.js';
  3. const listingDimensionSchema = z.enum(['title', 'selling_points', 'images', 'description', 'specifications']);
  4. const listingOverviewDimensionKeySchema = z.enum(['title', 'selling_points', 'images', 'description', 'specifications', 'search', 'voc', 'selling', 'facts', 'competitive', 'media']);
  5. const listingOverviewScoreNatureSchema = z.enum(['simulation', 'formal_ai', 'rule_precheck', 'unscored']);
  6. const jdVocDimensionKeySchema = z.enum(['search', 'voc', 'selling', 'facts', 'competitive', 'media']);
  7. const jdVocScoreKindSchema = z.enum(['jd_voc_rules', 'jd_voc_hybrid_ai']);
  8. const jdVocSourceHashSchema = z.string().regex(/^[a-f0-9]{64}$/, 'sourceHash_must_be_64_lowercase_hex');
  9. const jdVocCoverageSchema = z.object({
  10. percent: z.number().min(0).max(100),
  11. missing: z.array(z.string().min(1)),
  12. status: z.enum(['eligible', 'partial', 'blocked']),
  13. });
  14. const jdVocEvidenceSchema = z.object({
  15. id: z.string().min(1),
  16. ruleId: z.string().min(1),
  17. fieldPath: z.string().min(1),
  18. outcome: z.enum(['pass', 'fail', 'unknown']),
  19. message: z.string(),
  20. source: z.enum(['rule', 'ai']),
  21. level: z.enum(['unknown', 'fail', 'weak', 'pass', 'strong']).optional(),
  22. pointsAwarded: z.number().min(0).nullable(),
  23. maxPoints: z.number().positive(),
  24. confidence: z.number().min(0).max(1).nullable().optional(),
  25. citations: z.array(z.string()).optional(),
  26. });
  27. const jdVocDimensionSchema = z.object({
  28. key: jdVocDimensionKeySchema,
  29. score: z.number().min(0).nullable(),
  30. maxScore: z.number().positive(),
  31. fixedEarned: z.number().min(0).nullable(),
  32. verifiedMax: z.number().min(0),
  33. coverage: z.number().min(0).max(100),
  34. status: z.enum(['scored', 'partial', 'blocked']),
  35. evidence: z.array(jdVocEvidenceSchema),
  36. });
  37. const jdVocActionSchema = z.object({
  38. ruleId: z.string().min(1),
  39. priority: z.enum(['P0', 'P1', 'P2', 'DATA']),
  40. dimension: jdVocDimensionKeySchema,
  41. title: z.string().min(1),
  42. action: z.string().min(1),
  43. evidenceIds: z.array(z.string()),
  44. confidence: z.number().min(0).max(1).nullable(),
  45. potentialGain: z.number().min(0),
  46. });
  47. export const jdVocScorePayloadSchema = z.object({
  48. id: z.string().min(1),
  49. workspaceId: z.string().min(1),
  50. platform: z.literal('jd'),
  51. productId: z.string().min(1),
  52. sourceHash: jdVocSourceHashSchema,
  53. rubricVersion: z.literal(JD_VOC_RUBRIC_VERSION),
  54. scoreKind: jdVocScoreKindSchema,
  55. overallScore: z.number().min(0).max(100).nullable(),
  56. dimensions: z.array(jdVocDimensionSchema).length(6),
  57. coverage: jdVocCoverageSchema,
  58. voc: z.object({
  59. profileId: z.string().nullable(),
  60. productReviewCount: z.number().int().nonnegative(),
  61. categoryVocEvidenceCount: z.number().int().nonnegative(),
  62. concerns: z.array(z.object({
  63. id: z.string().min(1), question: z.string().min(1), severity: z.enum(['low', 'medium', 'high']),
  64. source: z.enum(['product-review', 'category-voc', 'category-profile', 'manual-question']), evidenceIds: z.array(z.string()),
  65. })),
  66. evidence: z.array(z.object({
  67. id: z.string().min(1), source: z.enum(['product-review', 'category-voc', 'category-profile', 'manual-question']),
  68. text: z.string(), rating: z.number().min(0).max(5).nullable().optional(), observedAt: z.string().nullable().optional(),
  69. })).optional(),
  70. }),
  71. compliance: z.object({
  72. status: z.enum(['normal', 'warning', 'blocked']), gate: z.enum(['PASS', 'WARN', 'BLOCK']),
  73. findings: z.array(z.object({ ruleId: z.string(), severity: z.string(), fieldPath: z.string(), message: z.string(), evidence: z.array(z.string()) })),
  74. }).optional(),
  75. competitor: z.object({
  76. basis: z.enum(['formal', 'category-inferred', 'none']), count: z.number().int().nonnegative(),
  77. 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(),
  78. }),
  79. actions: z.array(jdVocActionSchema),
  80. aiReview: z.object({
  81. 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(),
  82. baselineScore: z.number().min(0).max(100).nullable(), hybridScore: z.number().min(0).max(100).nullable(),
  83. assessments: z.array(z.object({ dimension: jdVocDimensionKeySchema, score: z.number().min(0).nullable(), evidenceIds: z.array(z.string()), rationale: z.string() })),
  84. suggestions: z.array(z.object({ title: z.string().min(1), action: z.string().min(1), evidenceIds: z.array(z.string()) })),
  85. }).optional(),
  86. imageReview: z.object({
  87. 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()),
  88. }).optional(),
  89. executionKey: z.string().min(1),
  90. inputFingerprint: z.string().min(1),
  91. createdAt: z.string().datetime(),
  92. }).superRefine((payload, context) => {
  93. const seen = new Set<string>();
  94. for (const [index, dimension] of payload.dimensions.entries()) {
  95. if (seen.has(dimension.key)) context.addIssue({ code: 'custom', path: ['dimensions', index, 'key'], message: 'duplicate_dimension' });
  96. seen.add(dimension.key);
  97. const expectedMax = JD_VOC_DIMENSION_MAX[dimension.key];
  98. if (dimension.maxScore !== expectedMax) context.addIssue({ code: 'custom', path: ['dimensions', index, 'maxScore'], message: 'dimension_max_score_mismatch' });
  99. if (dimension.score !== null && dimension.score > dimension.maxScore) context.addIssue({ code: 'custom', path: ['dimensions', index, 'score'], message: 'score_exceeds_max' });
  100. if (dimension.fixedEarned !== null && dimension.fixedEarned > dimension.maxScore) context.addIssue({ code: 'custom', path: ['dimensions', index, 'fixedEarned'], message: 'fixed_earned_exceeds_max' });
  101. if (dimension.verifiedMax > dimension.maxScore) context.addIssue({ code: 'custom', path: ['dimensions', index, 'verifiedMax'], message: 'verified_max_exceeds_max' });
  102. if (dimension.score === null && dimension.status === 'scored') context.addIssue({ code: 'custom', path: ['dimensions', index, 'status'], message: 'null_score_requires_partial_or_blocked' });
  103. }
  104. });
  105. // Contract name used by route/presenter code; keep payload alias for callers that
  106. // validate persisted Parse payloads directly.
  107. export const jdVocScoreResultSchema = jdVocScorePayloadSchema;
  108. export const listingWorkspaceQuerySchema = z.object({
  109. workspaceId: z.string().min(1).optional(),
  110. platform: z.literal('jd').default('jd'),
  111. });
  112. export const listingPageQuerySchema = listingWorkspaceQuerySchema.extend({
  113. limit: z.coerce.number().int().min(1).max(100).default(25),
  114. cursor: z.string().max(1_000).optional(),
  115. });
  116. export const listingFilterSchema = z.object({
  117. search: z.string().max(200).optional(),
  118. categoryId: z.string().max(100).optional(),
  119. itemStatus: z.string().max(100).optional(),
  120. scoreStatus: z.enum(['unscored', 'scored', 'partial', 'blocked', 'failed']).optional(),
  121. aiScoreStatus: z.enum(['not_scored', 'completed', 'partial', 'failed']).optional(),
  122. coverageStatus: z.enum(['eligible', 'partial', 'blocked']).optional(),
  123. minScore: z.coerce.number().min(0).max(100).optional(),
  124. maxScore: z.coerce.number().min(0).max(100).optional(),
  125. });
  126. export const listingProductQuerySchema = listingPageQuerySchema.extend({
  127. ...listingFilterSchema.shape,
  128. sort: z.enum(['productId', 'score_asc', 'score_desc', 'updated_desc']).default('productId'),
  129. });
  130. const categoryIdsSchema = z.preprocess((value) => {
  131. const values = Array.isArray(value) ? value : typeof value === 'string' ? value.split(',') : value;
  132. return Array.isArray(values) ? values.flatMap((item) => typeof item === 'string' ? item.split(',') : item) : values;
  133. }, z.array(z.string().trim().min(1).max(100)).max(50).transform((values) => [...new Set(values)]));
  134. export const listingOverviewQuerySchema = listingPageQuerySchema.extend({
  135. search: z.string().max(200).optional(),
  136. categoryIds: categoryIdsSchema.optional(),
  137. scoreNature: listingOverviewScoreNatureSchema.optional(),
  138. rubricVersion: z.string().min(1).max(100).optional(),
  139. scoreKind: z.enum(['rules', 'hybrid_ai', 'jd_voc_rules', 'jd_voc_hybrid_ai']).optional(),
  140. priority: z.enum(['P0', 'P1', 'P2', 'DATA']).optional(),
  141. coverageMin: z.coerce.number().min(0).max(100).optional(),
  142. coverageMax: z.coerce.number().min(0).max(100).optional(),
  143. minScore: z.coerce.number().min(0).max(100).optional(),
  144. maxScore: z.coerce.number().min(0).max(100).optional(),
  145. titleMin: z.coerce.number().min(0).max(30).optional(),
  146. titleMax: z.coerce.number().min(0).max(30).optional(),
  147. sellingPointsMin: z.coerce.number().min(0).max(25).optional(),
  148. sellingPointsMax: z.coerce.number().min(0).max(25).optional(),
  149. imagesMin: z.coerce.number().min(0).max(20).optional(),
  150. imagesMax: z.coerce.number().min(0).max(20).optional(),
  151. descriptionMin: z.coerce.number().min(0).max(15).optional(),
  152. descriptionMax: z.coerce.number().min(0).max(15).optional(),
  153. specificationsMin: z.coerce.number().min(0).max(10).optional(),
  154. specificationsMax: z.coerce.number().min(0).max(10).optional(),
  155. weakestDimension: listingOverviewDimensionKeySchema.optional(),
  156. sort: z.enum(['productId', 'overallScore', 'title', 'selling_points', 'images', 'description', 'specifications', 'search', 'voc', 'selling', 'facts', 'competitive', 'media', 'improvementPotential', 'scoredAt', 'syncedAt']).default('improvementPotential'),
  157. direction: z.enum(['asc', 'desc']).default('desc'),
  158. }).superRefine((query, context) => {
  159. const ranges: Array<[number | undefined, number | undefined, string]> = [
  160. [query.minScore, query.maxScore, 'minScore'],
  161. [query.titleMin, query.titleMax, 'titleMin'],
  162. [query.sellingPointsMin, query.sellingPointsMax, 'sellingPointsMin'],
  163. [query.imagesMin, query.imagesMax, 'imagesMin'],
  164. [query.descriptionMin, query.descriptionMax, 'descriptionMin'],
  165. [query.specificationsMin, query.specificationsMax, 'specificationsMin'],
  166. [query.coverageMin, query.coverageMax, 'coverageMin'],
  167. ];
  168. for (const [minimum, maximum, path] of ranges) {
  169. if (minimum !== undefined && maximum !== undefined && minimum > maximum) {
  170. context.addIssue({ code: 'custom', path: [path], message: 'minimum_must_not_exceed_maximum' });
  171. }
  172. }
  173. });
  174. export const scoreJobRequestSchema = z.object({
  175. workspaceId: z.string().min(1).optional(),
  176. platform: z.literal('jd').default('jd'),
  177. scope: z.discriminatedUnion('mode', [
  178. z.object({ mode: z.literal('selected'), productIds: z.array(z.string().min(1).max(100)).min(1).max(100).transform((items) => [...new Set(items)]) }),
  179. z.object({ mode: z.literal('filter'), filter: listingFilterSchema.default({}) }),
  180. ]),
  181. scoringMode: z.enum(['rules', 'ai']).optional(),
  182. rubricVersion: z.string().min(1).max(100).optional(),
  183. includeAiSuggestions: z.boolean().optional(),
  184. rescorePolicy: z.enum(['reuse', 'force']).default('reuse'),
  185. idempotencyKey: z.string().min(8).max(200).optional(),
  186. });
  187. const attributeSchema = z.object({ id: z.string(), name: z.string(), values: z.array(z.string()) });
  188. export const createVersionRequestSchema = z.object({
  189. workspaceId: z.string().min(1).optional(),
  190. platform: z.literal('jd').default('jd'),
  191. baseSourceHash: z.string().length(64),
  192. content: z.object({
  193. title: z.string().max(500).nullable(),
  194. sellingPoints: z.array(z.string().max(2_000)).max(30),
  195. descriptionHtml: z.string().max(200_000).nullable(),
  196. specifications: z.array(attributeSchema).max(500),
  197. imageUrls: z.array(z.url()).max(100),
  198. }).optional(),
  199. });
  200. const listingPresentationItemSchema = z.object({
  201. title: z.string(), resultLabel: z.string(), reason: z.string(), action: z.string().nullable(), impactText: z.string(), sources: z.array(z.string()),
  202. });
  203. export const listingScorePresentationSchema = z.object({
  204. score: z.number().min(0).max(100).nullable(), scoreText: z.string(), statusLabel: z.string(), methodLabel: z.string(), standardLabel: z.string(),
  205. dataUpdatedAtText: z.string(), scoredAtText: z.string(), scopeNote: z.string(), aiConfidence: z.number().min(0).max(1).nullable(), suggestions: z.array(z.string()),
  206. complianceStatus: z.enum(['normal', 'warning', 'needs_review', 'blocked']).nullable(),
  207. complianceFindings: z.array(z.object({ severity: z.enum(['low', 'medium', 'high', 'critical']), message: z.string(), evidence: z.array(z.string()) })),
  208. dimensions: z.array(z.object({
  209. key: z.enum(['title', 'selling_points', 'images', 'description', 'specifications']), name: z.string(), score: z.number().nullable(), maxScore: z.number(),
  210. scoreText: z.string(), conclusion: z.string(), items: z.array(listingPresentationItemSchema),
  211. })),
  212. });
  213. export const listingProductPresentationSchema = z.object({
  214. productId: z.string(), title: z.string().nullable(), imageUrl: z.string().nullable(), categoryText: z.string(), itemStatusLabel: z.string(),
  215. specificationCount: z.number().nullable(), dataStatusLabel: z.string(), scoreStatusLabel: z.string(), aiStatusLabel: z.string(), score: z.number().nullable(),
  216. scoreText: z.string(), methodLabel: z.string(), syncedAt: z.string(),
  217. });
  218. const listingOverviewDimensionValueSchema = z.object({
  219. score: z.number().nullable(),
  220. maxScore: z.number().positive(),
  221. rate: z.number().min(0).max(1).nullable(),
  222. gap: z.number().min(0).nullable(),
  223. });
  224. const listingOverviewDimensionsSchema = z.object({
  225. title: listingOverviewDimensionValueSchema,
  226. selling_points: listingOverviewDimensionValueSchema,
  227. images: listingOverviewDimensionValueSchema,
  228. description: listingOverviewDimensionValueSchema,
  229. specifications: listingOverviewDimensionValueSchema,
  230. });
  231. const jdVocOverviewDimensionsSchema = z.object({
  232. search: listingOverviewDimensionValueSchema, voc: listingOverviewDimensionValueSchema, selling: listingOverviewDimensionValueSchema,
  233. facts: listingOverviewDimensionValueSchema, competitive: listingOverviewDimensionValueSchema, media: listingOverviewDimensionValueSchema,
  234. });
  235. export const listingOverviewRowSchema = z.object({
  236. productId: z.string(),
  237. title: z.string().nullable(),
  238. imageUrl: z.string().nullable(),
  239. categoryId: z.string().nullable(),
  240. categoryName: z.string().nullable(),
  241. categoryPath: z.array(z.string()),
  242. categoryIds: z.array(z.string()),
  243. itemStatusLabel: z.string(),
  244. scoreNature: listingOverviewScoreNatureSchema,
  245. scoreNatureLabel: z.string(),
  246. overallScore: z.number().min(0).max(100).nullable(),
  247. overallRate: z.number().min(0).max(1).nullable(),
  248. dimensions: listingOverviewDimensionsSchema,
  249. jdVocDimensions: jdVocOverviewDimensionsSchema.optional(),
  250. weakestDimension: listingOverviewDimensionKeySchema.nullable(),
  251. improvementPotential: z.number().min(0).max(100).nullable(),
  252. scoredAt: z.string().nullable(),
  253. syncedAt: z.string(),
  254. rubricVersion: z.string().nullable().optional(),
  255. scoreKind: z.enum(['rules', 'hybrid_ai', 'jd_voc_rules', 'jd_voc_hybrid_ai']).nullable().optional(),
  256. coveragePercent: z.number().min(0).max(100).nullable().optional(),
  257. priority: z.enum(['P0', 'P1', 'P2', 'DATA']).nullable().optional(),
  258. });
  259. const listingOverviewDimensionStatSchema = z.object({
  260. key: listingOverviewDimensionKeySchema,
  261. label: z.string(),
  262. maxScore: z.number().positive(),
  263. scoredCount: z.number().int().nonnegative(),
  264. averageScore: z.number().nullable(),
  265. averageRate: z.number().min(0).max(1).nullable(),
  266. medianScore: z.number().nullable(),
  267. totalGap: z.number().nonnegative().nullable(),
  268. });
  269. export const listingOverviewResponseSchema = z.object({
  270. items: z.array(listingOverviewRowSchema),
  271. nextCursor: z.string().nullable(),
  272. summary: z.object({
  273. sourceTotal: z.number().int().nonnegative(),
  274. matchedTotal: z.number().int().nonnegative(),
  275. scoredTotal: z.number().int().nonnegative(),
  276. simulationTotal: z.number().int().nonnegative(),
  277. averageScore: z.number().min(0).max(100).nullable(),
  278. medianScore: z.number().min(0).max(100).nullable(),
  279. scoreDistribution: z.array(z.object({
  280. key: z.string(), label: z.string(), minScore: z.number(), maxScore: z.number(), maxScoreExclusive: z.boolean(), count: z.number().int().nonnegative(),
  281. })),
  282. dimensionStats: z.object({
  283. title: listingOverviewDimensionStatSchema,
  284. selling_points: listingOverviewDimensionStatSchema,
  285. images: listingOverviewDimensionStatSchema,
  286. description: listingOverviewDimensionStatSchema,
  287. specifications: listingOverviewDimensionStatSchema,
  288. }),
  289. jdVocDimensionStats: z.object({ search: listingOverviewDimensionStatSchema, voc: listingOverviewDimensionStatSchema, selling: listingOverviewDimensionStatSchema, facts: listingOverviewDimensionStatSchema, competitive: listingOverviewDimensionStatSchema, media: listingOverviewDimensionStatSchema }).optional(),
  290. categoryFacets: z.array(z.object({ categoryId: z.string(), categoryName: z.string(), categoryPath: z.array(z.string()), count: z.number().int().nonnegative() })),
  291. scoreNatureFacets: z.array(z.object({ value: listingOverviewScoreNatureSchema, label: z.string(), count: z.number().int().nonnegative() })),
  292. snapshotId: z.string(),
  293. generatedAt: z.string(),
  294. }),
  295. });