score-jd-voc-codex.ts 6.5 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768
  1. import 'dotenv/config';
  2. import { mkdir, writeFile } from 'node:fs/promises';
  3. import { dirname, resolve } from 'node:path';
  4. import { loadConfig } from '../src/config/env.js';
  5. import { ParseRestClient } from '../src/db/parse-rest.client.js';
  6. import type { JdVocAiScoreOutput, } from '../src/modules/listing-ai/scoring/jd-voc-ai-rubric.js';
  7. import type { JdVocDimensionKey, JdVocScoreResult } from '../src/modules/listing-ai/domain.js';
  8. import { ParseRestListingAiRepository } from '../src/modules/listing-ai/repositories/parse-rest-listing-ai.repository.js';
  9. import { composeJdVocHybridScore } from '../src/modules/listing-ai/scoring/jd-voc-ai-rubric.js';
  10. import { scoreJdVocRules } from '../src/modules/listing-ai/scoring/jd-voc-rule-engine.js';
  11. const EXPECTED = 625;
  12. const MODEL = 'codex-jd-voc-evaluator-v1';
  13. const SEMANTIC_DIMENSIONS: readonly Exclude<JdVocDimensionKey,'media'>[] = ['search','voc','selling','facts','competitive'];
  14. const LEVEL_FACTOR = { strong:1, pass:.9, weak:.55, fail:.2, unknown:0 } as const;
  15. const args = new Map(process.argv.slice(2).map((arg)=>{const [key,...rest]=arg.split('=');return[key!,rest.join('=')||'true'];}));
  16. const apply = args.get('--apply') === 'true';
  17. const concurrency = Math.max(1,Math.min(12,Number(args.get('--concurrency')??6)));
  18. function judge(baseline:JdVocScoreResult):JdVocAiScoreOutput{
  19. const assessments = SEMANTIC_DIMENSIONS.map((key)=>{
  20. const dimension=baseline.dimensions.find((item)=>item.key===key)!;
  21. const available=dimension.evidence.filter((item)=>item.outcome!=='unknown'&&item.maxPoints>0);
  22. const verifiedMax=available.reduce((sum,item)=>sum+item.maxPoints,0);
  23. const semanticEarned=available.reduce((sum,item)=>sum+item.maxPoints*LEVEL_FACTOR[item.level??(item.outcome==='pass'?'pass':'fail')],0);
  24. const score=verifiedMax?Math.round(semanticEarned/verifiedMax*dimension.maxScore*10)/10:0;
  25. const evidenceIds=available.sort((a,b)=>b.maxPoints-a.maxPoints||a.ruleId.localeCompare(b.ruleId)).slice(0,3).map((item)=>item.id);
  26. const counts={pass:available.filter((item)=>item.outcome==='pass').length,weak:available.filter((item)=>item.level==='weak').length,fail:available.filter((item)=>item.outcome==='fail'&&item.level!=='weak').length};
  27. return{dimension:key,score:evidenceIds.length?score:0,evidenceIds,rationale:`按固定证据校准:${counts.pass}项通过、${counts.weak}项部分满足、${counts.fail}项失败;仅使用该维度规则证据。`,confidence:Math.round(dimension.coverage)/100};
  28. });
  29. const suggestions=baseline.actions.filter((item)=>item.dimension!=='media'&&item.evidenceIds.length).slice(0,12).map((item)=>({title:item.title,action:item.action,evidenceIds:item.evidenceIds}));
  30. return{assessments,suggestions,summary:'Codex 按 jd-voc-v0.5 固定证据等级校准五个文本语义维度;media 保持规则分,未使用外部知识。',latencyMs:0};
  31. }
  32. async function concurrent<T>(items:T[],worker:(item:T,index:number)=>Promise<void>):Promise<void>{let cursor=0;await Promise.all(Array.from({length:Math.min(concurrency,items.length)},async()=>{while(cursor<items.length){const index=cursor++;await worker(items[index]!,index);}}));}
  33. const config=loadConfig();
  34. if(config.storageDriver!=='parse_rest')throw new Error('codex_jd_voc_requires_parse_rest');
  35. const client=new ParseRestClient({serverUrl:config.parse.serverUrl,appId:config.parse.appId,masterKey:config.parse.masterKey,timeoutMs:config.parse.timeoutMs});
  36. const repository=new ParseRestListingAiRepository(client);
  37. const sources=await repository.listAllSources(config.auth.defaultWorkspaceId,'jd');
  38. if(sources.length!==EXPECTED)throw new Error(`source_count_mismatch:${sources.length}:${EXPECTED}`);
  39. const existing=await repository.listJdVocScores(config.auth.defaultWorkspaceId);
  40. const existingRules=new Map(existing.filter((item)=>item.scoreKind==='jd_voc_rules').map((item)=>[item.productId,item]));
  41. const results:Array<{rules:JdVocScoreResult;hybrid:JdVocScoreResult}>=new Array(sources.length);
  42. await concurrent(sources,async(source,index)=>{
  43. const context=await repository.getJdVocRuleContext(source.workspaceId,source.platform,source.productId);
  44. let rules=scoreJdVocRules(source,context,{now:new Date().toISOString()});
  45. const previous=existingRules.get(source.productId);
  46. if(previous?.sourceHash===source.sourceHash&&previous.imageReview?.status==='shadow_completed')rules={...rules,imageReview:previous.imageReview};
  47. const hybrid=composeJdVocHybridScore({baseline:rules,output:judge(rules),model:MODEL,source,now:new Date().toISOString()});
  48. results[index]={rules,hybrid};
  49. });
  50. const invalid=results.filter(({hybrid})=>hybrid.dimensions.length!==6||hybrid.aiReview?.assessments.length!==5||hybrid.aiReview.assessments.some((item)=>(item.score??0)>0&&!item.evidenceIds.length));
  51. if(invalid.length)throw new Error(`invalid_codex_results:${invalid.length}`);
  52. const summary={mode:apply?'apply':'dry-run',model:MODEL,requested:EXPECTED,completed:results.length,invalid:invalid.length,rules:results.length,hybrid:results.length,blocked:results.filter(({rules})=>rules.coverage.status==='blocked').length,partial:results.filter(({hybrid})=>hybrid.coverage.status==='partial').length,averageHybrid:Math.round(results.reduce((sum,{hybrid})=>sum+(hybrid.overallScore??0),0)/results.length*10)/10};
  53. if(!apply){console.log(JSON.stringify(summary,null,2));process.exit();}
  54. const now=new Date().toISOString();
  55. const backupPath=resolve(args.get('--backup')??`logs/jd-voc-current-backup-${now.replace(/[:.]/g,'-')}.json`);
  56. await mkdir(dirname(backupPath),{recursive:true});
  57. await writeFile(backupPath,JSON.stringify({workspaceId:config.auth.defaultWorkspaceId,createdAt:now,scores:existing},null,2),'utf8');
  58. await concurrent(results,async({rules,hybrid})=>{await repository.upsertJdVocCurrentScore(rules);await repository.upsertJdVocCurrentScore(hybrid);});
  59. const persisted=await repository.listJdVocScores(config.auth.defaultWorkspaceId);
  60. const codex=persisted.filter((item)=>item.scoreKind==='jd_voc_hybrid_ai'&&item.aiReview?.model===MODEL);
  61. const byProduct=new Map(codex.map((item)=>[item.productId,item]));
  62. const mismatches=sources.filter((source)=>{const score=byProduct.get(source.productId);return!score||score.sourceHash!==source.sourceHash||score.dimensions.length!==6||score.aiReview?.assessments.length!==5;});
  63. console.log(JSON.stringify({...summary,backupPath,persistedRules:persisted.filter((item)=>item.scoreKind==='jd_voc_rules').length,persistedHybrid:persisted.filter((item)=>item.scoreKind==='jd_voc_hybrid_ai').length,codexVerified:codex.length,mismatches:mismatches.length},null,2));
  64. if(mismatches.length||codex.length!==EXPECTED)process.exitCode=2;