rehearse-unified-workshop.mjs 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142
  1. // Offline rehearsal only: all service operations dispatch into a schema-checked memory store.
  2. import fs from 'node:fs';
  3. import path from 'node:path';
  4. import os from 'node:os';
  5. import crypto from 'node:crypto';
  6. import assert from 'node:assert/strict';
  7. import {fileURLToPath} from 'node:url';
  8. import {createApplication} from '../server/src/core.mjs';
  9. import {attachWorkshopHandlers} from '../server/src/workshop.mjs';
  10. import {runWorkshop} from '../skills/ksf-workshop-runtime/scripts/ksf-workshop.mjs';
  11. import {registerAgentProfile,registerParticipantProfile,readAgentProfileByKey,readParticipantProfile} from '../skills/ksf-workshop-runtime/scripts/ksf-identity.mjs';
  12. import {buildModel} from '../src/core/kfs/build.js';
  13. import {reportOverview} from '../src/core/report.js';
  14. const root=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'..');
  15. const hash=b=>crypto.createHash('sha256').update(b).digest('hex');
  16. const data=JSON.parse(fs.readFileSync(path.join(root,'server/references/runtime-data.json')));
  17. function memory(){
  18. let db={},seq=0,writes=0;
  19. return{
  20. get writes(){return writes;},
  21. async transaction(fn){const before=structuredClone(db),count=writes;try{return await fn(this);}catch(e){db=before;writes=count;throw e;}},async lock(){},
  22. async get(n,id){return structuredClone(db[n]?.find(r=>r.objectId===id)||null);},
  23. async list(n,{where={},afterId,limit=200,search}={}){return structuredClone((db[n]||[]).filter(r=>Object.entries(where).every(([k,v])=>JSON.stringify(r[k]??null)===JSON.stringify(v))).filter(r=>!search||JSON.stringify(r).includes(search)).filter(r=>!afterId||r.objectId>afterId).sort((a,b)=>a.objectId.localeCompare(b.objectId)).slice(0,limit));},
  24. async create(n,p){for(const k of Object.keys(p))assert.ok(data.tables[n]?.[k],n+'.'+k);const r={...structuredClone(p),objectId:'offline'+String(++seq).padStart(8,'0'),createdAt:new Date().toISOString(),updatedAt:new Date().toISOString()};(db[n]??=[]).push(r);writes++;return structuredClone(r);},
  25. async update(n,id,p){for(const k of Object.keys(p))assert.ok(data.tables[n]?.[k],n+'.'+k);const r=db[n]?.find(r=>r.objectId===id);assert.ok(r);Object.assign(r,structuredClone(p));writes++;return structuredClone(r);}
  26. };
  27. }
  28. const rows=(...args)=>args;
  29. const topic='速食拌面滤水便捷性与风味选择:供给线索验证';
  30. const gaps=['消费者原文与使用场景尚未取得','无同口径跨期数据、市场规模或完整榜单','无实际规格净重、价格、成本、工厂与合规验证'];
  31. // These are manually prepared bounded judgments, not an autonomous Agent execution.
  32. const modules=[
  33. ['D1-01','S1','category_report','品类全景图谱','已归档官网中有统一酱拌面4个、巧面馆拌面5个口味条目,非SKU数或市场覆盖。',rows(['供给主轴','速食拌面→产品线→口味;包装单列标签'],['需求树','滤水便捷/风味选择为待证任务,未取得消费者依据'],['连接','官网滤水设计宣称→便捷性假设;效果及需求待核'])],
  34. ['D1-02','S1','category_report','本题验证配置卡','来源资格限厂家产品自述;双模型入口与执行未验证。',rows(['原件','固定两份归档正文及SHA,逐字引文核对'],['统计','9个口味条目;消费者分母未知,不作频次/占比'],['模型','未配置/未执行;不把人工核对写成双模型通过'])],
  35. ['D1-03','S1','competitor_report','竞品图谱与假设初稿','可比较归档所列风味/包装和卖点;不能判断消费者偏好、增长或市场空白。',rows(['卖点原句','贴心设计:碗底防烫夹角,揭开式快捷滤水口,快速滤水干净不易烫手'],['H1','滤水操作是否影响选择?厂家宣称为线索,用户需求待证'],['H2','风味选择是否解决实际任务?缺用户原文与跨期变化'])],
  36. ['D1-04','S1','pain_list','增强AI自驱任务卡与市场小结','H1/H2分别保留支持、反证和停止条件;本轮不投递、不排程、不补采。',rows(['H1任务','先复用合格使用反馈;核是否实际滤水困难及替代操作'],['H2任务','查具体风味偏好/拒绝原因;不把供给数量当偏好'],['资源/停止','收费停止,仅使用冻结归档;无真实接收通道,不声称已运行'])],
  37. ['D1-N','S2','voc_report','归档复用与第二轮调研缺口','复用两份厂家原文;消费者与社媒正文未取得,未执行新调研。',rows(['复用','2个官网来源、9个口味条目及9条厂家摘录'],['VOC预聚类','没有合格用户原话,成员集合为空'],['提及率','N未知,不计算0%或制造分母;H1/H2未验证'])],
  38. ['D2-01','S2','voc_report','调研材料验收记录','厂家资料仅用于供给和宣称,不进入消费者需求样本。',rows(['可用','2个官网来源可核归档原文及出处'],['不适用','9条厂家证据不计9名消费者或9条VOC'],['下一步','供给比较可继续;需求强度、偏好和使用场景待补'])],
  39. ['D2-02','S2','pain_list','需求编码、KANO与场景矩阵边界','缺用户材料,H1/H2保留为供给启发的假设;KANO未测,不定需求等级。',rows(['四层编码','原话/动作/任务/解释均需真实用户依据,本轮不编'],['研究先后','先核滤水任务及替代办法,再判断风味选择问题;不是机会排名'],['KANO/人群×场景','未取得成对问卷和真实人群/场景;状态待验证'])],
  40. ['D2-03','S3','assessment_report','风味结构与产品机会初表','单期厂家风味条目不能支持趋势、增长或矩阵市场空白。',rows(['结构','酱拌面4个、巧面馆拌面5个归档口味条目'],['机会线索','滤水便捷性方向:需求、差异和产品效果均待核'],['指标','提及/首提/考虑/转化分别缺适配样本,不互相替代'])],
  41. ['D2-04','S3','opportunity_card','三源推理与候选清单V1','候选O1为待验证假设。S/U/C是分析维度,两页官网同属一个厂家,不当独立三源。',rows(['O1 S×U×C','拌面×人群待证×滤水场景待证'],['需求/五力','未获得用户与行业竞争依据;不计算分值'],['候选去向','O1保留补证,未声称机会成立或选定企业项目'])],
  42. ['D2-05','S4','assessment_report','证据核验与机会优先级','O1证据不足,等级待评;不填S/A/B,不用未知填零。',rows(['支持','厂家公开宣称滤水设计,只能证明存在该宣称'],['反证','未取得;缺反证不等于证明有效'],['决策','先补真实使用任务与差异证据;双模型未执行'])],
  43. ['D2-06','S4','assessment_report','商业供应链回推与V2','O1沿用待证状态,不因缺成本而淘汰;经济性与量产条件未知。',rows(['计算','无净重/实付/报价/损耗/渠道口径,不计算毛利'],['工厂/合规','无设备路线和适用标准原文复核,保持待验证'],['V1→V2','O1保留,决定性条件及缺口明确;未改独立卡评分'])],
  44. ['D2-07','S5','concept','价值主张与卖点边界','可形成便捷滤水的概念假设;不能把厂家宣称转写成新品已经验证的收益。',rows(['需求→要素→收益','需求待证→候选滤水设计→便捷收益待测试'],['表达','滤水便捷性待测试概念;不作防烫/性能保证'],['规格/路线','包装为设计方向,净重未知,配方与路线暂空'])],
  45. ['D2-08','S5','concept','概念提案、V3与行动附件','O1 V3保留待评,说明未新增验证。形成研究提案草案,不称已路演、获用户认可或批准立项。',rows(['V2→V3','O1全池保留,状态及理由连续,未伪造变化'],['质询','仅列待验证问题;没有现场反馈或小组真实案例'],['49天计划','仅为待认领验证计划,不编负责人,不创建收费任务或排程'])]
  46. ];
  47. export async function rehearse({outputDir}={}){
  48. const identityRoot=fs.mkdtempSync(path.join(os.tmpdir(),'ksf-unified-offline-'));
  49. const opts={profileDir:path.join(identityRoot,'agents'),participantDir:path.join(identityRoot,'people')};
  50. const agent=readAgentProfileByKey(registerAgentProfile({name:'离线演练助手',role:'workshop',identityBasis:'operator_registration',runtimePlatform:'hermes',channel:'wecom',account:{platform:'wecom',accountId:'offline-fixture-agent'}},opts).profile.key,opts.profileDir);
  51. const participant=readParticipantProfile(registerParticipantProfile({name:'离线演练学员',identityBasis:'participant_registration',account:{platform:'wecom'}},opts).profile.key,opts.participantDir);
  52. const store=memory(),app=createApplication({store,crypto,Parse:{},data,settings:{signingSecret:'offline-fixture-only'},files:{}});attachWorkshopHandlers(app,{store,data});
  53. const actor={id:'offline-only-service',kind:'service',scopes:['ingest:write','data:read','source:write'],projectIds:['*']};
  54. const events=[],checks=[],artifacts=[],imports=[];
  55. const invoke=async(command,input,requestId)=>{events.push({command,requestId});return runWorkshop(command,structuredClone(input),{requestId,agentProfile:agent,participantProfile:participant,call:async(name,params)=>(await app.dispatch(name,structuredClone(params),actor)).data});};
  56. const verify=(name,ok)=>{assert.ok(ok,name);checks.push(name);};
  57. const reject=async(name,fn,code)=>{await assert.rejects(fn,{code});checks.push(name);};
  58. const originalFetch=globalThis.fetch;globalThis.fetch=()=>{throw Error('OFFLINE_NETWORK_FORBIDDEN');};
  59. try{
  60. const file=path.join(root,'server/test/fixtures/first-live-sources-20261102.json'),bundle=JSON.parse(fs.readFileSync(file));
  61. for(const batch of bundle.batches){
  62. const r=await invoke('workshop-import',batch.task,'offline:import:'+batch.key);imports.push({...r,key:batch.key});
  63. const source=await invoke('workshop-read',{className:'KfSource',objectId:r.sourceId},'offline:read-source:'+batch.key);
  64. verify('原件回读 '+batch.key,source.record.rawText===batch.task.source.rawText);
  65. for(const item of r.items.filter(x=>x.className==='KfEvidence')){
  66. const e=await invoke('workshop-read',{className:'KfEvidence',objectId:item.objectId},'offline:read-evidence:'+item.objectId);
  67. verify('厂家证据逐字存在 '+item.objectId,source.record.rawText.includes(e.record.quote)&&e.record.sourceRef.objectId===r.sourceId);
  68. }
  69. }
  70. const first=imports[0],reused=await invoke('workshop-import',bundle.batches[0].task,'offline:reused-import');
  71. verify('同原件跨批复用',reused.sourceId===first.sourceId&&reused.counts.created===0);
  72. const ctx=await invoke('workshop-start',{workshopKey:'offline-unified-20261108',participant:{profileKey:participant.key},conversation:{key:'offline-conversation',channel:'wecom'},groupId:'offline-G1',dataMode:'sample'},'offline:start');
  73. const sourceIds=imports.map(r=>r.sourceId),evidence=Object.fromEntries(bundle.batches.map((b,i)=>['e'+i,{sourceId:sourceIds[i],claim:'归档厂家产品宣称,非消费者证言',quotes:[{text:b.task.records.find(x=>x.className==='KfEvidence').data.quote,ref:b.task.source.sourceUrl}]}]));
  74. const taskFor=(m,projectId)=>({sessionId:ctx.sessionId,...(projectId?{projectId}:{}),taskKey:'offline-topic:'+m[0],version:1,stageCode:m[1],title:'【离线演练】'+m[3],artifactType:m[2],category:'拌面',rawText:[topic,m[4],...m[5].map(r=>r.join(':')),...gaps].join('\n'),sourceIds,pendingQuestions:gaps,report:{executive:[m[4],'本地隔离演练;无生产写入,不代表实际研究或消费者验证完成。'],chapters:[{id:'main',title:m[3],stage:m[1],sections:[{kind:'para',title:'判断',text:m[4]},{kind:'table',title:'分析与交接',data:{cols:['项目','内容与状态'],rows:m[5]}},{kind:'table',title:'前序成果',data:{cols:['环节','本地演练编号'],rows:artifacts.map(a=>[a.moduleId,a.artifactId])}}]}],evidence}});
  75. let projectId,opportunityId;
  76. for(const m of modules){
  77. const input=taskFor(m,m[0]==='D2-08'?projectId:null);
  78. if(m[0]==='D2-04')input.opportunity={key:'offline:O1',title:'【离线演练】滤水便捷性假设',demandSummary:'供给启发,消费者需求未证',targetAudience:'待研究,不编人群',usageScenario:'滤水场景待用户验证',fusionDefinition:'拌面×人群待证×滤水场景待证'};
  79. const saved=await invoke('workshop-save',input,'offline:save:'+m[0]);
  80. const read=await invoke('workshop-read',{className:'KfArtifact',objectId:saved.artifactId},'offline:read:'+m[0]);
  81. verify(m[0]+' 保存及编号回读',read.record.stageCode===m[1]&&read.record.dataMode==='sample'&&read.record.dashboardState==='workshop'&&read.record.report.chapters[0].sections.every(s=>['para','table'].includes(s.kind)));
  82. verify(m[0]+' 不冒充正式成果',saved.formalReportPublished===false&&saved.employeeConfirmed===false&&read.record.evidenceLevel==='none');
  83. const replay=await invoke('workshop-save',input,'offline:save:'+m[0]);verify(m[0]+' 同请求幂等',replay.artifactId===saved.artifactId);
  84. artifacts.push({moduleId:m[0],stageCode:m[1],artifactType:m[2],artifactId:saved.artifactId,sourceId:saved.sourceId,designStatus:'bounded-offline-exercise'});
  85. if(m[0]==='D1-01'){
  86. const changed={...input,title:input.title+'修改'};await reject('同请求改内容停止',()=>invoke('workshop-save',changed,'offline:save:'+m[0]),'IDEMPOTENCY_CONFLICT');
  87. await reject('换请求但同任务版本不重复保存',()=>invoke('workshop-save',input,'offline:duplicate-version'),'IDEMPOTENCY_CONFLICT');
  88. const bad=structuredClone(input);bad.taskKey+=':bad-quote';bad.report.evidence.e0.quotes[0].text='归档原件从未出现的消费者认可';
  89. const before=(await store.list('KfArtifact')).length;await reject('伪造引文被拒',()=>invoke('workshop-save',bad,'offline:bad-quote'),'VALIDATION_FAILED');verify('失败报告未落地',(await store.list('KfArtifact')).length===before);
  90. const recovered=await invoke('workshop-search',{className:'KfArtifact',keyword:m[3],filters:{dataMode:'sample'},limit:100},'offline:recover-unknown-result');
  91. verify('保存响应丢失后可检索原成果',recovered.records.some(r=>r.objectId===saved.artifactId));
  92. const next=await invoke('workshop-save',{...input,version:2,rawText:input.rawText+'\n仅修订说明,原件和结论边界保留'},'offline:save:D1-01:v2');
  93. verify('实质修订保留旧版并新增版本',next.artifactId!==saved.artifactId&&(await store.get('KfArtifact',saved.artifactId)).version===1);
  94. }
  95. if(m[0]==='D2-04'){
  96. opportunityId=saved.opportunityId;
  97. const p=await invoke('project-create',{sessionId:ctx.sessionId,opportunityId,projectKey:'offline:project',title:'【离线演练】用于阶段守卫检查;非真实选择'},'offline:project');projectId=p.projectId;
  98. await reject('无本项目S3成果不能推进',()=>invoke('project-progress',{sessionId:ctx.sessionId,projectId,toStage:'S4',state:'in_progress',note:'检查守卫'},'offline:illegal-progress'),'STAGE_REQUIREMENTS_NOT_MET');
  99. const own=taskFor(m,projectId);own.taskKey+=':project-selection';delete own.opportunity;
  100. await invoke('workshop-save',own,'offline:project-s3');
  101. await invoke('project-progress',{sessionId:ctx.sessionId,projectId,toStage:'S4',state:'in_progress',note:'仅演练项目已有S3关联成果'},'offline:to-s4');
  102. }
  103. if(m[0]==='D2-06'){
  104. const own=taskFor(m,projectId);own.taskKey+=':project-validation';await invoke('workshop-save',own,'offline:project-s4');
  105. await invoke('project-progress',{sessionId:ctx.sessionId,projectId,toStage:'S4',state:'blocked',note:gaps.join(';')},'offline:blocked');
  106. const blocked=await invoke('project-status',{sessionId:ctx.sessionId,projectId},'offline:blocked-read');verify('缺材料可记录卡点',blocked.project.progressState==='blocked');
  107. await invoke('project-progress',{sessionId:ctx.sessionId,projectId,toStage:'S5',state:'in_progress',note:'继续sample字段与方案演练,真实需求/可行性仍未验证'},'offline:to-s5');
  108. await reject('无方案不自动结项',()=>invoke('project-progress',{sessionId:ctx.sessionId,projectId,toStage:'S5',state:'completed',note:'守卫测试'},'offline:illegal-complete'),'STAGE_REQUIREMENTS_NOT_MET');
  109. }
  110. }
  111. const concept={name:'【离线演练】滤水便捷性待测试概念',targetAudience:'待用户研究',usageScenario:'滤水任务待验证',specification:{packaging:'碗装为待测设计方向',netWeightG:null},valueProposition:'探索滤水便利,收益尚未测试',flavor:'未选定',sellingPoints:['待测试概念表达'],productionSteps:[],pendingQuestions:gaps};
  112. const c1=await invoke('concept-save',{sessionId:ctx.sessionId,projectId,version:1,concept,formulaItems:[]},'offline:concept-v1');
  113. const c2=await invoke('concept-save',{sessionId:ctx.sessionId,projectId,version:2,concept:{...concept,pendingQuestions:[...gaps,'待确认测试评价指标']},formulaItems:[]},'offline:concept-v2');
  114. const status=await invoke('project-status',{sessionId:ctx.sessionId,projectId},'offline:final-project');
  115. verify('方案连续版本与缺口回读',status.concept.objectId===c2.conceptId&&status.concept.previousVersionRef.objectId===c1.conceptId&&status.project.meta.pendingQuestions.length===4&&status.formulaItems.length===0);
  116. verify('课程走完不自动企业结项',status.project.progressState==='in_progress');
  117. const board=(await app.dispatch('ksfGetPublicBoard',{limit:500},null)).data;
  118. verify('本地公开投影含全部13环节',artifacts.every(a=>board.artifact.some(b=>b.objectId===a.artifactId))&&board.opportunity.some(o=>o.objectId===opportunityId));
  119. const model=buildModel(board);verify('既有看板模型能识别报告',artifacts.every(a=>reportOverview(model,a.artifactId)?.chapterCount===1));
  120. verify('资料不足不建假社媒场景报告',!(await store.list('KfArtifact')).some(a=>a.title.includes('社媒场景报告')));
  121. const coreOutputs=[
  122. {name:'市场研究报告',moduleId:'D1-04',state:'SUPPLY_ONLY_PARTIAL'},
  123. {name:'社媒场景报告',moduleId:null,state:'NOT_PRODUCED_NO_SOURCE',artifactId:null},
  124. {name:'用户/竞品证言池',moduleId:'D2-01',state:'MANUFACTURER_ONLY_NO_USER_VOC'},
  125. {name:'机会卡',moduleId:'D2-04',state:'HYPOTHESIS_ONLY'},
  126. {name:'验证优先级与可行性清单',moduleId:'D2-06',state:'PENDING_EVIDENCE_NO_SCORE'},
  127. {name:'场景买点方案',moduleId:'D2-07',state:'UNTESTED_CONCEPT'},
  128. {name:'产品概念提案',moduleId:'D2-08',state:'RESEARCH_DRAFT_NOT_PRESENTED'}
  129. ].map(o=>({...o,...(o.moduleId?{artifactId:artifacts.find(a=>a.moduleId===o.moduleId).artifactId}:{})}));
  130. const summary={schema:'ksf.unified-workshop-offline-rehearsal.v1',ok:true,checkedAt:new Date().toISOString(),topic,executionMode:'manually-prepared-analysis-with-real-local-handlers',agentAutonomousBehaviorVerified:false,sourceFixtureSha256:hash(fs.readFileSync(file)),sourceDocuments:2,sourceBrands:1,sourceFlavorEntries:9,consumerEvidenceObtained:0,courseModulesExercised:13,coreOutputRoutesChecked:7,checksPassed:checks.length,checks,artifacts,coreOutputs,localArtifactCount:(await store.list('KfArtifact',{limit:1000})).length,localMemoryWrites:store.writes,productionDatabaseWrites:0,paidCalls:0,upstreamCalls:0,networkCalls:0,remoteSkillInstalled:false,productionBrowserVerified:false,actualBusinessWriteVerified:false,formalReportSubmitted:false};
  131. if(outputDir){fs.mkdirSync(outputDir,{recursive:true});fs.writeFileSync(path.join(outputDir,'local-rehearsal-summary.json'),JSON.stringify(summary,null,2)+'\n');fs.writeFileSync(path.join(outputDir,'offline-artifacts.json'),JSON.stringify({offlineOnly:true,dataMode:'sample',productionWritten:false,artifacts:await store.list('KfArtifact',{limit:1000}),sources:await store.list('KfSource',{limit:1000})},null,2)+'\n');}
  132. return summary;
  133. }finally{
  134. globalThis.fetch=originalFetch;
  135. const resolved=fs.realpathSync(identityRoot);assert.ok(resolved.startsWith(fs.realpathSync(os.tmpdir())+path.sep)&&path.basename(resolved).startsWith('ksf-unified-offline-'));fs.rmSync(resolved,{recursive:true});
  136. }
  137. }
  138. if(process.argv[1]&&path.resolve(process.argv[1])===fileURLToPath(import.meta.url)){
  139. const args=process.argv.slice(2);assert.ok(args.length===2&&args[0]==='--output'&&path.isAbsolute(args[1]));
  140. const r=await rehearse({outputDir:args[1]});console.log(JSON.stringify({ok:r.ok,modules:r.courseModulesExercised,checksPassed:r.checksPassed,localArtifacts:r.localArtifactCount,productionWrites:0,networkCalls:0,paidCalls:0}));
  141. }