verify-listing-rollout.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130
  1. import 'dotenv/config';
  2. import { z } from 'zod';
  3. import { ParseRestClient } from '../src/db/parse-rest.client.js';
  4. import { VOC_PARSE_CLASSES } from '../src/db/parse-rest.schema.js';
  5. import type { JdVocScoreResult, ListingDimension, ListingScoreJob, ListingScoreJobItem, ListingScoreResult, ListingSourceSnapshot } from '../src/modules/listing-ai/domain.js';
  6. import { LISTING_DIMENSION_MAX, LISTING_RUBRIC_VERSION } from '../src/modules/listing-ai/scoring/rule-engine.js';
  7. import { LISTING_AI_RUBRIC_VERSION } from '../src/modules/listing-ai/scoring/ai-rubric.js';
  8. import { isListingV7Score } from '../src/modules/listing-ai/scoring/score-status.js';
  9. const CODEX_GROUNDED_MODEL = 'codex-grounded-evaluator-v1';
  10. interface Stored<T> { workspaceId: string; productId?: string; jobId?: string; idempotencyKey?: string; slot?: 'rule_precheck' | 'formal_ai' | 'jd_voc_rules' | 'jd_voc_hybrid_ai'; model?: string; payload: T }
  11. async function main() {
  12. const env = z.object({ PARSE_SERVER_URL: z.url(), PARSE_APP_ID: z.string().min(1), PARSE_MASTER_KEY: z.string().min(1), SAAS_DEFAULT_WORKSPACE_ID: z.string().default('demashi') }).parse(process.env);
  13. const client = new ParseRestClient({ serverUrl: env.PARSE_SERVER_URL, appId: env.PARSE_APP_ID, masterKey: env.PARSE_MASTER_KEY });
  14. const workspaceId = env.SAAS_DEFAULT_WORKSPACE_ID;
  15. const [sourceRows, allResultRows, itemRows, jobRows] = await Promise.all([
  16. client.findAll<Stored<ListingSourceSnapshot>>(VOC_PARSE_CLASSES.listingSourceSnapshot, { workspaceId, platform: 'jd', isCurrent: true, catalogIncluded: true, catalogCohort: 'listing-jd-v3-formal-625' }),
  17. client.findAll<Stored<ListingScoreResult | JdVocScoreResult>>(VOC_PARSE_CLASSES.listingCurrentScore, { workspaceId }),
  18. client.findAll<Stored<ListingScoreJobItem>>(VOC_PARSE_CLASSES.listingScoreItem, { workspaceId }),
  19. client.findAll<Stored<ListingScoreJob> & { idempotencyKey: string }>(VOC_PARSE_CLASSES.listingScoreJob, { workspaceId }),
  20. ]);
  21. const resultRows = allResultRows.filter((row) => !String(row.payload.scoreKind ?? '').startsWith('jd_voc_')) as Array<Stored<ListingScoreResult> & { objectId: string; createdAt: string; updatedAt: string }>;
  22. const jdVocRows = allResultRows.filter((row) => String(row.payload.scoreKind ?? '').startsWith('jd_voc_')) as Array<Stored<JdVocScoreResult> & { objectId: string; createdAt: string; updatedAt: string }>;
  23. const schemas = await client.schemas();
  24. const legacyScoreSchemaPresent = schemas.some((schema) => schema.className === 'VocListingScoreResult');
  25. const slotKeys = allResultRows.map((row) => `${row.workspaceId}|${row.productId ?? row.payload.productId}|${row.slot ?? (row.payload.scoreKind === 'hybrid_ai' ? 'formal_ai' : row.payload.scoreKind === 'jd_voc_hybrid_ai' ? 'jd_voc_hybrid_ai' : row.payload.scoreKind === 'jd_voc_rules' ? 'jd_voc_rules' : 'rule_precheck')}`);
  26. const duplicateCurrentSlots = slotKeys.length - new Set(slotKeys).size;
  27. const latestSources = new Map<string, ListingSourceSnapshot>();
  28. for (const row of sourceRows) { const value = row.payload; const current = latestSources.get(value.productId); if (!current || value.syncedAt > current.syncedAt) latestSources.set(value.productId, value); }
  29. const jdVocInvalid = jdVocRows.filter((row) => row.payload.rubricVersion !== 'jd-voc-v0.5' || row.payload.dimensions.length !== 6 || row.payload.sourceHash.length !== 64 || !row.payload.executionKey || !row.payload.inputFingerprint).length;
  30. const jdVocStale = jdVocRows.filter((row) => latestSources.get(row.payload.productId)?.sourceHash !== row.payload.sourceHash).length;
  31. const jdVocAiEvidenceInvalid = jdVocRows.filter((row) => row.payload.scoreKind === 'jd_voc_hybrid_ai').filter((row) => row.payload.aiReview?.assessments.some((assessment) => (assessment.score ?? 0) > 0 && !assessment.evidenceIds.length)).length;
  32. const jdVocHybridRows = jdVocRows.filter((row) => row.payload.scoreKind === 'jd_voc_hybrid_ai');
  33. const jdVocAiLatencies = jdVocHybridRows.map((row) => row.payload.aiReview?.latencyMs).filter((value): value is number => typeof value === 'number');
  34. const jdVocAiUsage = jdVocHybridRows.reduce((sum,row)=>({promptTokens:sum.promptTokens+(row.payload.aiReview?.usage?.promptTokens??0),completionTokens:sum.completionTokens+(row.payload.aiReview?.usage?.completionTokens??0),totalTokens:sum.totalTokens+(row.payload.aiReview?.usage?.totalTokens??0)}),{promptTokens:0,completionTokens:0,totalTokens:0});
  35. const jdVocAiEstimatedPublicCostUsd = Math.round((jdVocAiUsage.promptTokens * 0.15 / 1_000_000 + jdVocAiUsage.completionTokens * 0.60 / 1_000_000) * 1_000_000) / 1_000_000;
  36. const jdVocImageLeak = jdVocRows.filter((row) => row.payload.imageReview && /https?:\/\//i.test(JSON.stringify(row.payload.imageReview))).length;
  37. const ruleResults = new Map<string, ListingScoreResult>();
  38. const aiResults = new Map<string, ListingScoreResult>();
  39. for (const row of resultRows) {
  40. const value = row.payload;
  41. if (!isListingV7Score(value)) continue;
  42. const target = row.slot === 'formal_ai' || value.scoreKind === 'hybrid_ai' ? aiResults : ruleResults;
  43. const current = target.get(value.productId);
  44. if (!current || value.createdAt > current.createdAt) target.set(value.productId, value);
  45. }
  46. const results = [...ruleResults.values()];
  47. const orphanResults = results.filter((result) => !latestSources.has(result.productId));
  48. const missingResults = [...latestSources.values()].filter((source) => !ruleResults.has(source.productId));
  49. const invalidDimensions = results.filter((result) => result.dimensions.length !== 5);
  50. const staleResults = results.filter((result) => latestSources.get(result.productId)?.sourceHash !== result.sourceHash);
  51. const invalidWeights = results.filter((result) => result.dimensions.some((item) => item.maxScore !== LISTING_DIMENSION_MAX[item.dimension]));
  52. const knownScores = results.map((result) => result.knownOverallScore).filter((value): value is number => typeof value === 'number');
  53. const staleAiResults = [...aiResults.values()].filter((result) => latestSources.get(result.productId)?.sourceHash !== result.sourceHash);
  54. const invalidAiResults = [...aiResults.values()].filter((result) => result.rubricVersion !== LISTING_AI_RUBRIC_VERSION || result.scoreKind !== 'hybrid_ai' || result.aiStatus !== 'completed' || !result.model || result.model === 'listing-showcase-ai');
  55. const simulatedScores = [...aiResults.values()].filter((result) => result.model === 'listing-v7-demo-simulation');
  56. const codexScores = [...aiResults.values()].filter((result) => result.model === CODEX_GROUNDED_MODEL);
  57. const invalidFormalScores = codexScores.filter((result) => result.overallScore === null || result.knownOverallMaxScore !== 100 || result.dimensions.some((dimension) => dimension.score === null));
  58. const formalNumericScores = codexScores.map((result) => result.overallScore).filter((value): value is number => value !== null);
  59. const simulatedAverage = simulatedScores.length ? Math.round(simulatedScores.reduce((sum, result) => sum + (result.overallScore ?? 0), 0) / simulatedScores.length * 10) / 10 : null;
  60. const simulatedMinimum = simulatedScores.length ? Math.min(...simulatedScores.map((result) => result.overallScore ?? Number.POSITIVE_INFINITY)) : null;
  61. const simulatedMaximum = simulatedScores.length ? Math.max(...simulatedScores.map((result) => result.overallScore ?? Number.NEGATIVE_INFINITY)) : null;
  62. const simulatedMinimumCount = simulatedScores.filter((result) => result.overallScore === 73).length;
  63. const simulatedMaximumCount = simulatedScores.filter((result) => result.overallScore === 100).length;
  64. const dimensions = Object.keys(LISTING_DIMENSION_MAX) as ListingDimension[];
  65. const dimensionPartial = Object.fromEntries(dimensions.map((dimension) => [dimension, results.filter((result) => result.dimensions.find((item) => item.dimension === dimension)?.status === 'partial').length]));
  66. const knownMaxDistribution = results.reduce<Record<string, number>>((output, result) => { const key = String(result.knownOverallMaxScore ?? 'legacy'); output[key] = (output[key] ?? 0) + 1; return output; }, {});
  67. const compliance = results.reduce<Record<string, number>>((output, result) => { const key = result.compliance?.status ?? 'legacy'; output[key] = (output[key] ?? 0) + 1; return output; }, {});
  68. const normalizers = [...latestSources.values()].reduce<Record<string, number>>((output, source) => { const key = source.normalizerVersion ?? 'legacy'; output[key] = (output[key] ?? 0) + 1; return output; }, {});
  69. const latestJob = jobRows.map((row) => row.payload).filter((job) => job.rubricVersion === LISTING_RUBRIC_VERSION && job.total === 625).sort((a, b) => b.requestedAt.localeCompare(a.requestedAt))[0] ?? null;
  70. const latestJobItems = latestJob ? itemRows.map((row) => row.payload).filter((item) => item.jobId === latestJob.id) : [];
  71. const ruleOutcomes = (ruleId: string) => results.reduce<Record<string, number>>((output, result) => {
  72. const evidence = result.dimensions.flatMap((dimension) => dimension.evidence).find((item) => item.ruleId === ruleId);
  73. const key = evidence?.outcome ?? 'missing';
  74. output[key] = (output[key] ?? 0) + 1;
  75. return output;
  76. }, {});
  77. const partialWithErrorCode = latestJobItems.filter((item) => item.status === 'partial' && item.errorCode !== null).length;
  78. const failedWithoutErrorCode = latestJobItems.filter((item) => item.status === 'failed' && !item.errorCode).length;
  79. const titleLengthOutcomes = ruleOutcomes('title.length');
  80. const titleCategoryOutcomes = ruleOutcomes('title.category_in_first_15');
  81. const hardFailRate = (outcomes: Record<string, number>) => {
  82. const decided = (outcomes.pass ?? 0) + (outcomes.fail ?? 0);
  83. return decided ? Math.round((outcomes.fail ?? 0) / decided * 10_000) / 10_000 : 0;
  84. };
  85. const systemicHardFailGate = {
  86. maximumAllowedRate: 0.5,
  87. titleLength: hardFailRate(titleLengthOutcomes),
  88. titleCategoryInFirst15: hardFailRate(titleCategoryOutcomes),
  89. };
  90. const report = {
  91. workspaceId, rubricVersion: `${LISTING_RUBRIC_VERSION} / ${LISTING_AI_RUBRIC_VERSION}`, weights: LISTING_DIMENSION_MAX, sources: latestSources.size, normalizers,
  92. currentScoreRows: allResultRows.length, legacyCurrentScoreRows: resultRows.length, jdVocCurrentScoreRows: jdVocRows.length,
  93. jdVocRuleScores: jdVocRows.filter((row) => row.payload.scoreKind === 'jd_voc_rules').length,
  94. jdVocHybridScores: jdVocRows.filter((row) => row.payload.scoreKind === 'jd_voc_hybrid_ai').length,
  95. jdVocImageShadowScores: jdVocRows.filter((row) => row.payload.imageReview?.status === 'shadow_completed').length,
  96. jdVocInvalid, jdVocStale, jdVocAiEvidenceInvalid, jdVocImageLeak,
  97. jdVocAiAverageLatencyMs: jdVocAiLatencies.length ? Math.round(jdVocAiLatencies.reduce((sum,value)=>sum+value,0)/jdVocAiLatencies.length) : null,
  98. jdVocAiUsage,
  99. jdVocAiEstimatedPublicCostUsd,
  100. jdVocAiEstimatedPublicCostCny: Math.round(jdVocAiEstimatedPublicCostUsd * Number(process.env.FMODE_USD_TO_CNY_RATE ?? 6.8) * 10_000) / 10_000,
  101. jdVocAiPricingBasis: 'OpenAI public gpt-4o-mini token rates; excludes Fmode gateway markup',
  102. duplicateCurrentSlots, legacyScoreSchemaPresent,
  103. ruleScores: ruleResults.size, rulePartialResults: results.filter((result) => result.overallScore === null).length,
  104. formalScores: aiResults.size, codexScores: codexScores.length, codexAverage: formalNumericScores.length ? Math.round(formalNumericScores.reduce((sum, value) => sum + value, 0) / formalNumericScores.length * 10) / 10 : null, simulatedScores: simulatedScores.length, simulatedAverage, simulatedMinimum, simulatedMaximum, simulatedMinimumCount, simulatedMaximumCount, invalidAiResults: invalidAiResults.length, invalidFormalScores: invalidFormalScores.length, staleAiResults: staleAiResults.length,
  105. averageKnownScore: knownScores.length ? Math.round(knownScores.reduce((sum, value) => sum + value, 0) / knownScores.length * 10) / 10 : null,
  106. knownMaxDistribution, dimensionPartial, compliance,
  107. ruleOutcomes: {
  108. titleLength: titleLengthOutcomes,
  109. titleCategoryInFirst15: titleCategoryOutcomes,
  110. imagePrimary: ruleOutcomes('images.primary'),
  111. },
  112. systemicHardFailGate,
  113. dataReadiness: {
  114. categoryRuleMissing: [...latestSources.values()].filter((source) => !source.categoryContext?.ruleVersion).length,
  115. vocEvidenceMissing: [...latestSources.values()].filter((source) => !source.vocEvidence?.length).length,
  116. descriptionStructureMissing: [...latestSources.values()].filter((source) => !source.descriptionStructure?.observed).length,
  117. },
  118. missingResults: missingResults.length, orphanResults: orphanResults.length, staleResults: staleResults.length, invalidDimensions: invalidDimensions.length, invalidWeights: invalidWeights.length,
  119. latestJob: latestJob ? { id: latestJob.id, status: latestJob.status, total: latestJob.total, processed: latestJob.processed, succeeded: latestJob.succeeded, partial: latestJob.partial, blocked: latestJob.blocked, failed: latestJob.failed } : null,
  120. latestJobItems: latestJobItems.length,
  121. jobItemStatusSemantics: { partialWithErrorCode, failedWithoutErrorCode },
  122. };
  123. console.log(JSON.stringify(report, null, 2));
  124. if (latestSources.size !== 625 || normalizers['jd-listing-v5'] !== 625 || resultRows.length !== 1250 || jdVocRows.filter((row) => row.payload.scoreKind === 'jd_voc_rules').length < 100 || jdVocRows.filter((row) => row.payload.scoreKind === 'jd_voc_hybrid_ai').length < 5 || jdVocRows.filter((row) => row.payload.imageReview?.status === 'shadow_completed').length < 5 || jdVocInvalid || jdVocStale || jdVocAiEvidenceInvalid || jdVocImageLeak || duplicateCurrentSlots || legacyScoreSchemaPresent || ruleResults.size !== 625 || aiResults.size !== 625 || codexScores.length !== 625 || simulatedScores.length !== 0 || invalidAiResults.length || invalidFormalScores.length || staleAiResults.length || missingResults.length || orphanResults.length || staleResults.length || invalidDimensions.length || invalidWeights.length || (latestJob !== null && latestJob.processed !== latestJob.total) || partialWithErrorCode || failedWithoutErrorCode || systemicHardFailGate.titleLength > systemicHardFailGate.maximumAllowedRate || systemicHardFailGate.titleCategoryInFirst15 > systemicHardFailGate.maximumAllowedRate) process.exitCode = 2;
  125. }
  126. main().catch((error) => { console.error(`[verify-listing-rollout] ${error instanceof Error ? error.message : error}`); process.exitCode = 1; });