accept-workshop-local.mjs 34 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178
  1. // Local acceptance only. Actual handlers use an isolated in-memory store; no remote calls.
  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 {rehearse} from './rehearse-unified-workshop.mjs';
  13. const root=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'..');
  14. const work=path.join(root,'docs/workshop-skill-local-acceptance-20261109');
  15. const read=p=>JSON.parse(fs.readFileSync(p,'utf8').replace(/^\uFEFF/,''));
  16. const fixture=read(path.join(work,'fixtures/innovation-scenario.json'));
  17. const data=read(path.join(root,'server/references/runtime-data.json'));
  18. const para=(title,text)=>({kind:'para',title,text});
  19. const table=(title,cols,rows)=>({kind:'table',title,data:{cols,rows:rows.map(r=>r.map(v=>v===null?'未知':String(v)))}});
  20. const rates=Object.fromEntries(['prepare','sauce','texture','rotation','satiety','price'].map(t=>[t,fixture.reviews.filter(r=>r.channel==='ecommerce'&&r.themes.includes(t)).map(r=>r.id)]));
  21. const eco=fixture.economicAssumptions;
  22. const costs=eco.packagingChanges.map(r=>({...r,cost:eco.baseCost+r.extraCost,margin:(eco.netRevenue-eco.baseCost-r.extraCost)/eco.netRevenue}));
  23. const money=n=>n.toFixed(2), pct=n=>(100*n).toFixed(2)+'%';
  24. // These are explicit local research-output drafts prepared against the candidate Skill.
  25. // This is not an autonomous or independent model evaluation.
  26. const modules=[
  27. {id:'D1-01',stage:'S1',type:'category_report',title:'品类全景:便利要拆到制作步骤',finding:'杯装能省去自备碗,但冲泡、倒水和收拾是不同任务;需要分别比较。',meaning:'优先比较怎样准备容器、怎样倒水、怎样收拾。杯装可能解决其中一环,其余环节需要分别核实,才能找准具体改进点。',next:'下一节固定证据口径,再用相同任务比较产品实现方式。',sections:[
  28. table('供给树',['父节点','子节点','实际样例','属性/边界'],[['包装拌面','热水冲泡→杯装','P1、P3','风味和滤水口作属性,不是并列品类'],['包装拌面','热水冲泡→袋装','P2','需容器;低标价不是支付意愿'],['包装拌面','需煮→袋装','P4','宽面为宣称,体验须另核']]),
  29. table('需求树',['父任务','子任务','证言','边界'],[['完成工作日一餐','倒水操作更顺手','V01、V03、V05','样例定性任务,不推人群规模'],['完成工作日一餐','减少备碗/清洗','V02;反向V08','在家愿洗碗,不统一推杯装'],['获得合适的进食体验','拌酱/口感、轮换口味、份量','V04–V11','各自语境保留,不合并成一个画像']]),
  30. table('供需连接',['供给要素','任务','依据','关系状态'],[['杯装','少自备碗','V02是袋装自述困难','研究假设,尚非杯装效果测试'],['杯盖滤水口','倒水更顺手','P3宣称,V03仍说要小心','宣称与反例并存'],['宽面需煮','口感与准备便利的取舍','V05;反向V11','同条自述支持取舍,不能推出宽面优越']])]},
  31. {id:'D1-02',stage:'S1',type:'category_report',title:'验证配置:把宣称、使用任务和效果分开',finding:'本题可以分析制作任务及取舍,尚不能判断包装方案降低了多少操作风险。',meaning:'用明确的证据层次保留研究价值,防止把卖点改写成已验证产品收益。',next:'竞品比较沿本卡使用同一计数、来源和未知处理规则。',sections:[
  32. table('验证配置卡',['配置项','实际规则'],[['资料','所有输入为合成样例;P为宣称/设定,V为构造证言'],['计数','电商11条与社媒1条分层;记录不是消费者人数'],['计算','主题成员ID可复算,多主题不互斥;无问卷/交易指标保留未知'],['追溯','完整原文→证据对象→报告引文;修订留版本'],['模型','单模型本地自检;第二模型未配置/未执行']]),
  33. table('重点核查',['判断','原件/反证','处理'],[['免煮=不沥水','P1、P3及V03明确仍倒水','不采用'],['杯装人人都更好','V08接受在家洗碗、看重价格','限定场景'],['宽面更受欢迎','V05正向、V11反向','保留分歧,不给喜好比例']])]},
  34. {id:'D1-03',stage:'S1',type:'competitor_report',title:'竞品对照:少一步操作,还是保留口感选择',finding:'包装操作和面条形态是两条可比较的路线;目前没有依据认为其中一条普遍更优。',meaning:'只有热水的办公条件下,先比较P1/P3的操作;有锅的居家条件下,再比较P4的口感与煮制代价。按条件组织产品对照,比跨场景排优劣更能指导设计。',next:'将倒水操作、酱料分布及口味轮换收敛为可证伪问题。',sections:[
  35. table('产品要素与取舍',['对象','实现方式/卖点','体验依据','代价或反例'],fixture.products.map(p=>[p.id+' '+p.brand,p.form+'/'+p.preparation+'/'+p.claim,p.id==='P4'?'V05口感正向,V11偏好相反':p.id==='P3'?'V03仍需小心倒水':p.id==='P2'?'V02备碗清洗;V08看重便宜':'V01倒水;V04杯底残酱','标价'+p.price+'元;跨包装不算工艺溢价'])),
  36. table('风味/形式矩阵',['形式','已见风味','未覆盖'],[['冲泡杯','火鸡、豆豉','其它风味/场景组合未知'],['冲泡袋','酱香','不是市场空白'],['需煮袋','油泼宽面','无上市时间/跨期,不报趋势']]),
  37. table('痛点假设初稿',['假设键','问题','支持','反证/判别'],[['H1','办公场景倒水是否影响选择','V01、V03','V08在家可接受;应比较正常说明下的操作'],['H2','怎样减少杯底残酱且保持口味','V04','V06酱多也可能腻;不先选加酱'],['H3','轮换口味是否只需改组合','V07、V10','已有混箱能回应一部分,未必开发新配方']])]},
  38. {id:'D1-04',stage:'S1',type:'pain_list',title:'调研任务:先找会改变选择的证据',finding:'先核H1的场景与替代办法;H2不要直接改成加酱,H3先检查现有组合。',meaning:'把后续研究限定到关键分歧,避免继续堆品牌和评论。',next:'按任务逐项回应;本地演练不实际投递、排程或采集。',sections:[
  39. table('增强AI自驱任务卡',['键','要回答什么','支持/反证路径','判别/停止','实际状态'],[['H1','倒水困难是场景限制还是产品问题','核完整动作、设施及可用替代;找觉得顺手的记录','若已有方案满足任务,转比较代价;无法核步骤标未知','仅本地任务,未投递'],['H2','残酱与使用方式有何关系','核搅拌/投酱原话;保留酱太腻反例','不能由残酱直接归因面型或加量','未做感官/实物'],['H3','组合能否比新品更低改动回应换口味','对照现有组合和拒绝原因','现组合足够则后置新品研究','未排程']]),
  40. para('阶段性市场小结','当前只有单期四个合成产品,形成覆盖内结构比较;不声称全网、规模或市场趋势。承接品类与竞品图谱,不重建另一套分类。')]},
  41. {id:'D1-N',stage:'S2',type:'voc_report',title:'第二轮调研:原话怎样回应假设',finding:'已给材料能支持操作便利和酱料体验的定性问题,但不能判断影响购买的大小。',meaning:'把原件复用与新采集分开,形成可回查预聚类,不用命中数量替代需求重要性。',next:'交付12条样例原话及成员清单,下一节逐用途验收。',sections:[
  42. table('逐假设回应',['键','得到什么','反例/未解','状态'],[['H1','V01/02/03/05提供办公制作限制','V08在家接受洗碗;无正常使用实测','有定性支持,影响选择待核'],['H2','V04杯底残酱;V05挂酱;V06嫌腻','无同条件性能比较,不推工艺因果','问题存在于样例,方案待验证'],['H3','V07/10提到换味和不辣需求','现有混箱回应程度未知','先比较组合']]),
  43. table('预聚类与分层提及率',['主题','电商成员','n/N','社媒'],Object.entries(rates).map(([k,v])=>[k,v.join('、'),v.length+'/11',k==='prepare'?'V12:1/1,仅该社媒记录':'0/1,仅样例集合'])),
  44. table('缺口清单',['缺口','影响','接续'],[['步骤实测','不能证明滤水设计效果','列待执行条件,不发起测试'],['可比市场/交易','不能判规模、趋势、购买转化','不以评论代指标'],['完整原文','本轮复用12条题干材料','未新增采集、未声称通宵巡检']])]},
  45. {id:'D2-01',stage:'S2',type:'voc_report',title:'战报验收:保留可用任务,限制因果判断',finding:'12条样例文字均可定位,11条电商用于产品语境,1条社媒仅作未归属的品类任务线索。',meaning:'来源未知维度限制相应判断,不因没有人数/交易就丢弃有用任务。',next:'保留分层和反例进入正式编码,原假设逐条留去向。',sections:[
  46. table('验收记录',['集合','可用用途','不能用于','数量'],[['电商V01–V11','产品任务、感受与替代取舍','真实消费者频率、购买人数、效果因果','11'],['社媒V12','宿舍设施与准备任务线索','品牌购买证言','1'],['P1–P4','合成供给及商家宣称比较','实物性能或真实市场覆盖','4']]),
  47. table('缺口修复与保留',['对象','核查结果','动作'],[['H1','支持与反例并存','继续任务研究,不认定全部人偏好杯装'],['H2','仅自述,无可比测试','不把挂酱与面型划等号'],['分母','电商11、社媒1、标签多选','从成员清单重算,不能平均批次百分比']])]},
  48. {id:'D2-02',stage:'S2',type:'pain_list',title:'需求归纳:工作日便利不等于所有场景都选杯装',finding:'办公/宿舍设施约束与居家价格取舍需要分开;同一产品不必同时满足所有场景。',meaning:'优先研究任务受阻及替代办法,不仅按提及数量排序。',next:'把需求键与供给矩阵对应,找改良和适配线索。',sections:[
  49. table('四层编码与原话',['证言','人群/场景','痛点/期待','JTBD及状态'],[['V01','人群未知/公司午休','倒水怕洒到手','完成午休一餐,减少操作顾虑;研究归纳'],['V05','人群未知/工作日中午','喜欢挂酱但煮制不便','在准备条件内获得喜欢的口感;归纳'],['V08','人群未知/居家','接受洗碗,看重便宜','在可接受劳动下控制花费;归纳'],['V10','人群未知/场景未知','希望混装不辣','保留选择余地;不补人群']]),
  50. table('需求主题/研究先后',['需求键','证言及反例','研究意义','先后理由'],[['N1准备操作',rates.prepare.join('、')+';反例V08','区分场景及替代','先查,能改变包装路线'],['N2酱料体验',rates.sauce.join('、'),'不要将残酱直接改为加量','同进问题定义,性能待核'],['N3口味轮换',rates.rotation.join('、'),'可能先改组合','现有供给能部分回应,先低改动检查']]),
  51. table('KANO与人群×场景',['维度','结果','边界'],[['KANO','N1/N2/N3均待验证','无成对问卷,不按频次定基本型'],['公司×人群未知','N1、N3:V01/02/07','不能命名白领画像'],['居家×人群未知','价格/劳动取舍:V08','不把未提及当不重要'],['宿舍×人群未知','N1:V12社媒线索','不与电商合并比例']])]},
  52. {id:'D2-03',stage:'S3',type:'assessment_report',title:'机会线索:从操作环节和现有组合找改良',finding:'当前更可执行的问题是包装操作与酱料分布;口味轮换可先检查现有组合。',meaning:'不要求先发现市场空白,已有产品同样可以做有证据的改良。',next:'将线索推演为候选,并保留差异和实现条件的未知。',sections:[
  53. table('供需矩阵与机会线索',['线索/需求','已有回应','拟研究差异','关键未知'],[['L1/N1','P1杯装,P3滤水口宣称','倒水步骤/持握设计是否减少操作负担','实际表现和新增成本'],['L2/N2','P1残酱自述,P2可调酱量','混合方式/投酱指引能否改善分布','归因与口感接受度'],['L3/N3','P1混箱、P3豆豉','是否通过组合提供不辣选项','现有供给覆盖与实际选择']]),
  54. table('独立指标初表',['指标','对象/基数','结果','含义'],[['准备任务提及','电商'+rates.prepare.join('、')+'/11',rates.prepare.length+'/11 = '+pct(rates.prepare.length/11),'仅构造材料提及率,不是需求强度'],['准备任务提及','社媒V12/1','1/1','单条线索,不与电商合并'],['第一提及','无自发顺序问卷','未知','不是评论第一句'],['考虑集','无相应调查','未知','不是出现品牌词'],['购买转化','无可关联漏斗/交易','未知','不能由评论计算']]),
  55. para('风味趋势边界','四种风味为样例单期结构。无可比跨期,不声称火鸡或豆豉增长;矩阵未覆盖不是市场空白。')]},
  56. {id:'D2-04',stage:'S3',type:'assessment_report',title:'候选清单V1:三个可证伪的研究方向',finding:'保留O1操作便利、O2酱料分布、O3轮换组合三个候选,暂不选择最终产品。',meaning:'每个候选都有任务、现有办法和决定性假设,避免只写方向名称。',next:'按全池证据比较先验证什么;不因当前排第一就自动立项。',sections:[
  57. table('候选机会卡清单V1.0',['键','S×U×C','拟改要素/收益假设','支持/反证','关键检验'],[['O1','拌面×人群待核×办公准备','包装适配→减少倒水操作负担','V01/03,V08不同取舍','正常说明下操作是否改善且代价可接受'],['O2','拌面×人群待核×拌制','指引/混合方式→改善酱料分布','V04;V06嫌腻,不直接加酱','残酱是否来自步骤,改变是否损害口感'],['O3','拌面×人群待核×储备轮换','组合选择→换味且可选不辣','V07/10;已有混箱','现有组合是否足够回应']]),
  58. table('三源共用与五力',['项','实际依据','判断边界'],[['赛道/用户/场景','P与V均来自同一构造题干','三个维度不等于三个独立来源'],['现有竞争/替代','P1–P4;V08居家袋装替代','只作样例覆盖内比较'],['供应/进入/购买方','无真实企业条件或市场研究','保持待核,不为填五力编分数']])]},
  59. {id:'D2-05',stage:'S4',type:'assessment_report',title:'优先验证:先区分任务,再判断方案效果',finding:'O1、O2可条件式推进;O3先检查现有组合,暂列本轮后置。',meaning:'先后是验证资源安排,不是成功概率。便宜可做的补证与候选价值分开。',next:'用同一全池做成本/产线/合规回推,不删除后置候选。',sections:[
  60. table('证据闭环与优先级',['键','等级/规则','支持与反证','升降级条件','验证先后'],[['O1','A:问题有支持、效果待核','V01/03;场景外V08','改善成立且成本/安全条件可接受才推进','先核步骤/比较基准'],['O2','A:体验问题有支持、原因待核','V04;V06酱多反例','混合改善有效且不增加腻感才推进','先核使用方式,再谈配方'],['O3','B:已有组合可部分回应','V07/10;需求不等于要新品','若现有组合仍不能回应则重评','先做现有组合低成本核查']]),
  61. para('规则与模型状态','采用公开标为研究草案的定性S/A/B规则,不计算分数或成功率。第二模型未执行,本地原文复核不冒充双模型通过;任何需求和产品效果主张分别核实。')]},
  62. {id:'D2-06',stage:'S4',type:'assessment_report',title:'可行性回推:包装小改有条件保留,未证明量产',finding:'按测试题干的收入/成本条件,大改方案不达目标;较小适配的情景账面可达标,但工厂和效果仍待核。',meaning:'不必在放弃问题与大改产品之间二选一,可以比较更小改动如何回应同一任务。',next:'V2保留全部候选去向;O1按条件式概念继续,不能写成企业已批准。',sections:[
  63. table('计算基准',['输入','值','状态'],[['净实现收入R',eco.netRevenue+'元/杯','样例同不含税基准,不是零售价'],['原成本',eco.baseCost+'元/杯','题干假设'],['目标毛利m',pct(eco.targetGrossMargin),'题干设定,不是企业真实目标'],['允许成本R×(1−m)',money(eco.netRevenue*(1-eco.targetGrossMargin))+'元/杯','同单位、同成本定义']]),
  64. table('小改/大改情景',['路线','情景总成本','毛利率','判断/代价'],costs.map(c=>[c.route,money(c.cost)+'元/杯',pct(c.margin),c.margin>=eco.targetGrossMargin?'仅此情景达到目标;实际报价/性能待核':'未达题干目标;需降成本或改净收入条件,不推零售价'])),
  65. table('可行性及清单V2.0',['键','V1→V2','理由','下一条件'],[['O1','修改后保留','较小适配情景可探索;未确认产线','包装适配/操作效果/实际报价/合规确认'],['O2','保留待核','原因未核,无实际成本不淘汰','先核混合与指引路径,不先加料'],['O3','暂缓新品,先核组合','已有组合可能回应,优先比较其缺口','若需新单品,再回成本与渠道约束']]),
  66. para('工厂与合规','无真实技术回复、材料准入、迁移/包装适用证明或安全测试;全部待核。没有联系工厂、采购或下试制单。')]},
  67. {id:'D2-07',stage:'S5',type:'concept',title:'价值主张:先把倒水步骤做得更从容',finding:'以O1为题干预设的概念演练对象,核心收益是假设的操作从容感,不承诺已减少烫伤或已验证安全。',meaning:'从具体任务到要素和收益,而不是只把包装特征写成形容词。',next:'带着决定性条件进入概念提案;价格、性能与接受度继续待测。',sections:[
  68. table('需求→要素→收益→表达',['需求/依据','拟研究要素','预期收益','表达及依据状态'],[['办公准备:V01/03','杯盖滤水路径/持握位置的较小适配','倒水操作更顺手,尚未测试','概念方向,不是已证性能'],['居家价格取舍:V08','保留袋装作为对照','不强推所有场景升级杯装','选择条件,非新品承诺']]),
  69. para('概念定位与文案','针对热水可得但操作空间有限的办公使用任务,探索更易完成倒水步骤的杯装方案。内部待测文案:“午休这一拌,操作更从容”。不能作为已审批包装、电商或安全宣传。'),
  70. table('验证安排',['问题','方法/前提','会改变什么'],[['收益是否存在','备齐样品后按各自说明操作,先确定指标和重复','无差异或新增困难则改写/停止此包装路线'],['文字是否被理解为安全承诺','取得真实用户与合规反馈后核理解','误解时改文案,不用脚注保留误导'],['成本是否成立','统一实际报价/批量/净收入口径','不达条件则回V2,不沿用样例毛利']])]},
  71. {id:'D2-08',stage:'S5',type:'concept',title:'概念提案与V3:有条件推进验证,不宣称结项',finding:'建议保留O1的条件式包装研究,O2继续问题核因,O3先查组合;本地演练没有真实路演或企业选择。',meaning:'提案交付的是证据、取舍和可执行验证安排,不能把课程结束当量产或正式批准。',next:'实际业务接续时由团队认领验证与企业条件;不自动采集、派发或发布。',sections:[
  72. table('机会清单V3.0全池',['键','V2→V3去向','依据/变化','仍待验证'],[['O1','保留条件式概念','加入要素→收益表达,未新增效果证据','工厂、成本、操作、接受度与合规'],['O2','保留核因','未取得新测试,不假装升级','混合方式与口感取舍'],['O3','后置新品,优先查组合','没有真实新增反馈,不假装淘汰','现有组合是否充分回应']]),
  73. table('质询与下一步',['演练问题/事项','回应','状态'],[['为什么不直接声称更安全','无安全效果依据,只能研究操作体验','预期质询,不是真人反馈'],['为什么不做宽面新品','没有证明宽面能回应此任务,另有需煮代价','保留其他方向,不作企业否决'],['首轮验证','准备比较基准、样品、指标及成本口径','待认领,无真实负责人'],['后续49天','按问题验证→条件复核→概念修订安排复盘','计划草案,未排程']]),
  74. para('成果导航与完成范围','本地样张涵盖品类/竞品、证言/场景、候选卡、验证与可行性、买点、概念及版本去向;真实编号由本地隔离保存后添加。样例原文不是实际消费者证据,正式发布、真人确认、真实质询、工厂验证均未执行。')]}
  75. ];
  76. function memory(){let db={},seq=0,writes=0;return{
  77. get writes(){return writes;},async transaction(fn){const before=structuredClone(db),n=writes;try{return await fn(this);}catch(e){db=before;writes=n;throw e;}},async lock(){},
  78. async get(c,id){return structuredClone(db[c]?.find(r=>r.objectId===id)||null);},
  79. async list(c,{where={},afterId,limit=200,search}={}){return structuredClone((db[c]||[]).filter(r=>Object.entries(where).every(([k,v])=>JSON.stringify(r[k]??null)===JSON.stringify(v))).filter(r=>!afterId||r.objectId>afterId).filter(r=>!search||JSON.stringify(r).includes(search)).sort((a,b)=>a.objectId.localeCompare(b.objectId)).slice(0,limit));},
  80. async create(c,p){for(const k of Object.keys(p))assert.ok(data.tables[c]?.[k],c+'.'+k);const r={...structuredClone(p),objectId:'local07'+String(++seq).padStart(7,'0'),createdAt:new Date().toISOString(),updatedAt:new Date().toISOString()};(db[c]??=[]).push(r);writes++;return structuredClone(r);},
  81. async update(c,id,p){for(const k of Object.keys(p))assert.ok(data.tables[c]?.[k],c+'.'+k);const r=db[c]?.find(x=>x.objectId===id);assert.ok(r);Object.assign(r,structuredClone(p));writes++;return structuredClone(r);}
  82. };}
  83. const sectionText=s=>s.kind==='table'?[s.title,s.data.cols.join(' | '),...s.data.rows.map(r=>r.join(' | '))].join('\n'):s.title+'\n'+s.text;
  84. function preflight(p){
  85. assert.ok(Array.isArray(p.report.executive));assert.ok(p.report.chapters.length);
  86. for(const ch of p.report.chapters){assert.ok(ch.id&&ch.stage&&Array.isArray(ch.sections));for(const s of ch.sections){
  87. if(s.kind==='table'){assert.ok(Array.isArray(s.data?.cols)&&s.data.cols.length);assert.ok(Array.isArray(s.data.rows));for(const row of s.data.rows)assert.equal(row.length,s.data.cols.length);}
  88. else assert.ok(['para','longpara'].includes(s.kind)&&typeof s.text==='string'&&s.text.trim());
  89. assert.ok(p.rawText.includes(sectionText(s)),'rawText must preserve substantive section, not only introduction');
  90. }}
  91. }
  92. export async function accept(){
  93. fs.mkdirSync(work,{recursive:true});
  94. const previous=await rehearse({outputDir:path.join(work,'baseline-rehearsal')});
  95. const temp=fs.mkdtempSync(path.join(os.tmpdir(),'ksf-local-acceptance-'));
  96. const opts={profileDir:path.join(temp,'agents'),participantDir:path.join(temp,'people')};
  97. const agent=readAgentProfileByKey(registerAgentProfile({name:'本地样例执行者',role:'workshop',identityBasis:'operator_registration',runtimePlatform:'local-test',channel:'local-test',account:{platform:'local-test',accountId:'fixture-agent'}},opts).profile.key,opts.profileDir);
  98. const person=readParticipantProfile(registerParticipantProfile({name:'本地样例学员',identityBasis:'participant_registration',account:{platform:'local-test'}},opts).profile.key,opts.participantDir);
  99. const store=memory(),app=createApplication({store,crypto,Parse:{},data,settings:{signingSecret:'local-fixture-only'},files:{}});attachWorkshopHandlers(app,{store,data});
  100. const actor={id:'local-fixture-service',kind:'service',scopes:['ingest:write','data:read','source:write'],projectIds:['*']};
  101. const events=[],checks=[],outputs=[];let seq=0,forbiddenNetworkAttempts=0;
  102. const run=async(cmd,input,id='local07:'+String(++seq))=>{events.push({command:cmd,requestId:id});return runWorkshop(cmd,structuredClone(input),{requestId:id,agentProfile:agent,participantProfile:person,call:async(n,p)=>(await app.dispatch(n,structuredClone(p),actor)).data});};
  103. const verify=(name,condition)=>{assert.ok(condition,name);checks.push(name);};
  104. const reject=async(name,cmd,input)=>{const before=store.writes;await assert.rejects(()=>run(cmd,input),{code:'VALIDATION_FAILED'});verify(name,store.writes===before);};
  105. const oldFetch=globalThis.fetch;globalThis.fetch=()=>{forbiddenNetworkAttempts++;throw Error('LOCAL_ACCEPTANCE_NETWORK_FORBIDDEN');};
  106. try{
  107. const sourceText=JSON.stringify(fixture,null,2);
  108. const imported=await run('workshop-import',{source:{title:'本地构造测试材料,不是真实调研',sourceKind:'dataset',dataMode:'sample',rawText:sourceText},records:fixture.reviews.map(r=>({itemKey:r.id,className:'KfEvidence',externalKey:'local07:fixture:'+r.id,data:{dataMode:'sample',quote:r.text,evidenceKind:'synthetic_test_only',quoteForm:'verbatim_excerpt',verificationState:'synthetic_only',platform:r.channel,locator:{fixture:'innovation-scenario.json',recordId:r.id}}}))});
  109. verify('12条样例原话保存为独立可回读证据',imported.items.filter(r=>r.className==='KfEvidence').length===12);
  110. for(const item of imported.items){const r=await run('workshop-read',{className:item.className,objectId:item.objectId});verify('来源逐字核对 '+item.itemKey,sourceText.includes(r.record.quote));}
  111. const ctx=await run('workshop-start',{workshopKey:'local07-workshop',participant:{profileKey:person.key},conversation:{key:'local07-conversation',channel:'local-test'},dataMode:'sample'});
  112. const evidence=Object.fromEntries(fixture.reviews.map(r=>['ev_'+r.id,{sourceId:imported.sourceId,claim:r.id+'中的任务自述,仅构造样例',quotes:[{text:r.text,ref:'本地构造材料 '+r.id}]}]));
  113. let firstInput;const cardLinks=[];
  114. const inputFor=m=>{
  115. const chapters=[{id:m.id.toLowerCase(),title:m.title,stage:m.stage,sections:m.sections}];
  116. const body=[fixture.notice,m.finding,m.meaning,...m.sections.map(sectionText),m.next].join('\n\n');
  117. const used=new Set(body.match(/V\d{2}/g)||[]);
  118. for(const range of body.matchAll(/V(\d{2})[–—-]V(\d{2})/g))for(let n=Number(range[1]);n<=Number(range[2]);n++)used.add('V'+String(n).padStart(2,'0'));
  119. const relevantEvidence=Object.fromEntries([...used].map(id=>['ev_'+id,evidence['ev_'+id]]));
  120. return {sessionId:ctx.sessionId,taskKey:'local07:'+m.id,version:1,stageCode:m.stage,title:'本地样例/'+m.id+'/'+m.title,artifactType:m.type,category:'拌面',rawText:body,sourceIds:[imported.sourceId],pendingQuestions:fixture.constraints,report:{executive:[m.finding,m.meaning,m.next],chapters,evidence:relevantEvidence}};
  121. };
  122. for(const original of modules){
  123. const m=structuredClone(original);
  124. if(m.id==='D2-04')for(const row of m.sections[0].data.rows){
  125. const key=row[0];const one={...m,id:'D2-04-'+key,title:key+'/'+row[2],finding:row[2],meaning:'候选任务与支持/反证:'+row[3],sections:[table('独立候选卡',m.sections[0].data.cols,[row]),m.sections[1]]};
  126. const card=inputFor(one);card.taskKey='local07:card:'+key;card.artifactType='opportunity_card';card.opportunity={key:'local07:'+key,title:'本地样例/'+key,demandSummary:row[2],targetAudience:'人群待核',usageScenario:key==='O3'?'储备轮换':'准备与拌制',fusionDefinition:row[1]};
  127. preflight(card);const s=await run('workshop-save',card);const o=await run('workshop-read',{className:'KfOpportunity',objectId:s.opportunityId});const a=await run('workshop-read',{className:'KfArtifact',objectId:s.artifactId});
  128. verify(key+' 独立卡关联真实本地报告',o.record.linkedArtifacts.includes(s.artifactId));assert.deepEqual(a.record.report.chapters,card.report.chapters);verify(key+' 独立卡只呈现本候选',a.record.report.chapters[0].sections[0].data.rows.length===1);
  129. cardLinks.push({key,opportunityId:s.opportunityId,artifactId:s.artifactId,version:1});
  130. }
  131. if(['D2-04','D2-05','D2-06','D2-08'].includes(m.id))m.sections.push(table('候选真实本地编号与版本',['候选','机会编号','报告编号','API版本'],cardLinks.map(c=>[c.key,c.opportunityId,c.artifactId,c.version])));
  132. if(m.id==='D2-08'){
  133. const find=id=>outputs.find(o=>o.id===id).artifactId;
  134. m.sections.push(table('七类成果导航',['成果','实际本地定位','范围'],[['市场结构小结',find('D1-04'),'单期样例,趋势未验证'],['社媒场景线索',find('D2-02'),'仅V12样例,不当完整画像'],['证言池与资格',find('D2-01'),'12条构造材料,非真实用户'],['三方候选卡',cardLinks.map(c=>c.opportunityId).join('、'),'三个待验证候选'],['优先级与可行性',find('D2-05')+';'+find('D2-06'),'含全池、规则与V2去向'],['场景买点方案',find('D2-07'),'收益与表达待测'],['概念提案','本报告及前一节;独立方案未在本题创建','没有真人路演、批准或正式发布']]));
  135. }
  136. const p=inputFor(m);
  137. preflight(p);firstInput??=structuredClone(p);
  138. const saved=await run('workshop-save',p,'local07:save:'+m.id),r=await run('workshop-read',{className:'KfArtifact',objectId:saved.artifactId});
  139. assert.deepEqual(r.record.report.chapters,p.report.chapters);assert.equal(r.record.rawText,p.rawText);
  140. verify(m.id+' 完整核心内容精确回读',r.record.version===1&&r.record.stageCode===m.stage);
  141. verify(m.id+' 样例未升级为真实/正式成果',r.record.dataMode==='sample'&&r.record.dashboardState==='workshop'&&saved.formalReportPublished===false&&saved.employeeConfirmed===false);
  142. outputs.push({...m,artifactId:saved.artifactId,automaticSourceId:saved.sourceId,report:r.record.report,rawText:r.record.rawText,coverage:'local-manually-prepared-synthetic-output'});
  143. }
  144. const bad=(mutate)=>{const p=structuredClone(firstInput);p.taskKey+=':fault:'+String(++seq);mutate(p);return p;};
  145. await reject('错误executive类型被拒且事务无残留','workshop-save',bad(p=>p.report.executive='错误字符串'));
  146. await reject('非法artifactType被拒','workshop-save',bad(p=>p.artifactType='atlas'));
  147. await reject('顶层sources字段被拒','workshop-save',bad(p=>p.sources=[]));
  148. await reject('伪造引用被拒','workshop-save',bad(p=>p.report.evidence.ev_V01.quotes[0].text='样例没有声称的真实安全效果'));
  149. await reject('来源未列入sourceIds被拒','workshop-save',bad(p=>p.sourceIds=[]));
  150. assert.throws(()=>preflight(bad(p=>p.rawText='一句简介')));checks.push('简介替代全文在本地准备检查中被发现');
  151. assert.throws(()=>preflight(bad(p=>{const t=p.report.chapters[0].sections[0];t.data.columns=t.data.cols;delete t.data.cols;})));checks.push('columns误写在本地准备检查中被发现');
  152. const replay=await run('workshop-save',firstInput,'local07:save:D1-01');verify('相同请求回读同一报告',replay.artifactId===outputs[0].artifactId);
  153. const firstSource=await run('workshop-read',{className:'KfSource',objectId:outputs[0].automaticSourceId});verify('报告来源不是第二份报告',!firstSource.record.report&&firstSource.record.rawText===firstInput.rawText);
  154. const board=(await app.dispatch('ksfGetPublicBoard',{limit:500},null)).data;
  155. verify('13节及3份独立候选均可本地公开读取',outputs.every(o=>board.artifact.some(a=>a.objectId===o.artifactId))&&board.opportunity.length===3);
  156. verify('V1/V2/V3不丢失候选',modules.filter(m=>['D2-04','D2-06','D2-08'].includes(m.id)).every(m=>['O1','O2','O3'].every(k=>m.sections.some(s=>s.kind==='table'&&s.data.rows.some(r=>r[0]===k)))));
  157. verify('多标签成员提及率可复算',rates.prepare.length===4&&rates.sauce.length===3&&rates.texture.length===3&&rates.rotation.length===2);
  158. verify('经济情景毛利/成本边界正确',Math.abs(costs[0].margin-.3375)<1e-12&&Math.abs(costs[1].margin-.355)<1e-12&&Math.abs(eco.netRevenue*(1-eco.targetGrossMargin)-2.6)<1e-12);
  159. const received=path.join(root,'docs/workshop-case-20261109/rounds/R05/received');
  160. const matched=['o_r02_k.json','o_r02_s.json','o_r02_baixiang.json','o_r02_youniyimian.json','o_r02_akuan.json'].map(n=>read(path.join(received,'q',n)).totalMatched);
  161. verify('真实归档总匹配复算380而非480',matched.reduce((a,b)=>a+b,0)===380);
  162. const realReviews=read(path.join(received,'q/o_k_r.json')).records;
  163. const lookup=id=>realReviews.find(r=>r.record_id===id).match.text;
  164. verify('同商品不同评价保持独立语境',lookup('jd:100005702440:106432060233186774').includes('不用洗碗')&&!lookup('jd:100005702440:106432060233186774').includes('汤头')&&lookup('jd:100005702440:106704650232532922').includes('汤头'));
  165. verify('真实放公司/换味原文绑定JD完整ID',lookup('jd:100005702440:106632660240017943').includes('放在公司'));
  166. verify('没有发起外部请求',forbiddenNetworkAttempts===0);
  167. const report={schema:'ksf.local-skill-acceptance.v1',checkedAt:new Date().toISOString(),ok:true,scope:'local current handlers, manually prepared synthetic research outputs, archived-data checks; no autonomous Agent evaluation',courseModules:13,coreOutputRoutes:7,checksPassed:checks.length,checks,baselineRehearsalChecks:previous.checksPassed,totalChecks:checks.length+previous.checksPassed,localMemoryWrites:store.writes,remoteCaseContinued:false,agentBehaviorIndependentlyVerified:false,remoteSkillInstalled:false,productionDatabaseWrites:0,networkCallsDuringRehearsal:0,paidCalls:0,formalReportSubmitted:false};
  168. fs.writeFileSync(path.join(work,'local-acceptance.json'),JSON.stringify(report,null,2)+'\n');
  169. fs.writeFileSync(path.join(work,'sample-outputs.json'),JSON.stringify({notice:fixture.notice,fixture,outputs},null,2)+'\n');
  170. fs.writeFileSync(path.join(work,'local-board.json'),JSON.stringify(board,null,2)+'\n');
  171. return report;
  172. }finally{globalThis.fetch=oldFetch;const resolved=fs.realpathSync(temp);assert.ok(resolved.startsWith(fs.realpathSync(os.tmpdir())+path.sep)&&path.basename(resolved).startsWith('ksf-local-acceptance-'));fs.rmSync(resolved,{recursive:true});}
  173. }
  174. if(process.argv[1]&&path.resolve(process.argv[1])===fileURLToPath(import.meta.url)){const r=await accept();console.log(JSON.stringify({ok:r.ok,modules:r.courseModules,checks:r.checksPassed,baseline:r.baselineRehearsalChecks,total:r.totalChecks,productionWrites:r.productionDatabaseWrites}));}