prepare-consumer-quote-inputs.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233
  1. """Prepare fixed consumer-quote inputs and handoff files. No network or business writes."""
  2. from pathlib import Path
  3. import hashlib
  4. import json
  5. import sys
  6. import zipfile
  7. ROOT = Path(__file__).resolve().parents[1]
  8. SOURCE = ROOT / 'work/consumer-research-codex-20261102/delivery'
  9. ARCHIVE = ROOT / 'work/consumer-quotes-codex-20261102.zip'
  10. DEST = ROOT / 'work/consumer-quote-inputs-20261102'
  11. DOCS = Path(r'F:\文档\工作坊当前资料')
  12. TASK = 'KSF-消费者原话-20261102-01'
  13. ARCHIVE_SHA = 'df741b09e0540c21cc311ea81e560beb87c0ec7a85a1872ad704f17a7b5e4ac1'
  14. REMOTE_INPUTS = '/opt/data/ksf-executor-20261102/consumer-quotes-01'
  15. REMOTE_STATE = '/opt/data/ksf-workshop-private/consumer-quotes/KSF-consumer-quotes-20261102-01'
  16. REMOTE_ARCHIVE = '/opt/data/ksf-workshop-private/archive-packages/consumer-quotes-codex-20261102.zip'
  17. PACKAGE = DOCS / 'ksf-consumer-quotes-import-20261102.zip'
  18. sha = lambda blob: hashlib.sha256(blob).hexdigest()
  19. encode = lambda value: (json.dumps(value, ensure_ascii=False, indent=2) + '\n').encode('utf-8')
  20. def retain(path, blob):
  21. if path.exists():
  22. assert path.read_bytes() == blob, f'Existing file differs; preserve it: {path}'
  23. else:
  24. path.parent.mkdir(parents=True, exist_ok=True)
  25. with path.open('xb') as f:
  26. f.write(blob)
  27. def prepare():
  28. archive = ARCHIVE.read_bytes()
  29. assert len(archive) == 1287263 and sha(archive) == ARCHIVE_SHA
  30. with zipfile.ZipFile(ARCHIVE) as z:
  31. assert z.testzip() is None and len(z.namelist()) == 30
  32. manifest = z.read('FILES.sha256').decode('utf-8').splitlines()
  33. assert len(manifest) == 29
  34. for line in manifest:
  35. expected, name = line.split(' ', 1)
  36. assert sha(z.read(name)) == expected
  37. assert z.read(name) == (SOURCE / name).read_bytes()
  38. data = json.loads(z.read('consumer-quotes.json'))
  39. assert len(data['records']) == 4 and data['businessDataWritten'] is False
  40. inputs = {}
  41. for row in data['records']:
  42. key = row['id']
  43. original_locator = row['locator']
  44. raw = row['completeMainBody']
  45. start, end = original_locator['startCharacter'], original_locator['endCharacter']
  46. assert raw[start:end] == row['quote']
  47. assert sha(raw.encode('utf-8')) == row['hashes']['mainTextSha256']
  48. locator = {
  49. 'url': row['sourceUrl'], 'section': '原帖主文章正文中的使用体验自述',
  50. 'startCharacter': start, 'endCharacter': end,
  51. 'unit': 'Unicode code point', 'startBase': 0, 'endExclusive': True,
  52. 'publishedAtReported': row['publishedAtReported'], 'publicationTimezone': 'unknown',
  53. 'httpCollectedAt': row['httpCollectedAt'], 'browserReadAt': row['browserCollectedAt'],
  54. 'archiveSha256': ARCHIVE_SHA,
  55. 'textOrigin': {'file': original_locator['file'], 'sha256': row['hashes']['mainTextSha256'],
  56. 'extraction': original_locator['extraction']},
  57. 'responseOriginal': {'file': row['files']['response'], 'sha256': row['hashes']['originalResponseSha256']},
  58. 'screenshot': {'file': row['files']['screenshot'], 'sha256': row['hashes']['screenshotSha256']},
  59. 'articleDom': {'file': row['files']['articleDom'], 'sha256': row['hashes']['articleDomSha256']},
  60. 'browserReadFile': row['files']['browserRead'],
  61. 'collectionPerformedBy': 'Codex',
  62. 'productScope': row['productScope'], 'productIdentificationBasis': row['productIdentificationBasis'],
  63. 'context': row['context'], 'purchaseVerified': False, 'realIdentityVerified': False,
  64. 'commercialRelationship': 'unknown', 'visibleTopic': row['visibleTopic'],
  65. 'limitations': data['limits'],
  66. }
  67. item = {
  68. 'itemKey': key + '-experience', 'className': 'KfEvidence',
  69. 'externalKey': TASK + ':' + key + ':experience:v1',
  70. 'data': {
  71. 'dataMode': 'live', 'quote': row['quote'],
  72. 'evidenceKind': 'public_user_experience_self_report',
  73. 'platform': 'smzdm', 'authorLabel': row['authorDisplay'],
  74. 'parentObjectId': row['sourceUrl'].rstrip('/').split('/')[-1],
  75. 'quoteForm': 'verbatim_excerpt',
  76. 'verificationState': 'archived_text_and_screenshot_checked',
  77. 'publishedAt': None, 'collectedAt': row['browserCollectedAt'],
  78. 'locator': locator,
  79. },
  80. }
  81. task = {
  82. 'source': {'title': row['title'], 'sourceKind': 'social', 'dataMode': 'live',
  83. 'sourceUrl': row['sourceUrl'], 'platform': 'smzdm',
  84. 'rawText': raw, 'collectedAt': row['httpCollectedAt']},
  85. 'records': [item],
  86. }
  87. assert 'productRef' not in item['data'] and 'sourceRef' not in item['data']
  88. inputs[key + '.input.json'] = task
  89. retain(DEST / (key + '.input.json'), encode(task))
  90. review = {
  91. 'ok': True, 'operation': 'consumer-quote-draft-reviewed',
  92. 'basis': '王刚001草稿由用户转述;原话与固定本地原件独立对照。远端文件权限和其哈希验算只记为王刚001回报。',
  93. 'taskKey': TASK, 'originalArchiveSha256': ARCHIVE_SHA, 'originalArchiveBytes': len(archive),
  94. 'sourceCount': 4, 'evidenceCount': 4, 'platformCount': 1,
  95. 'corrections': [
  96. '明确表达面量少的是①cq-03、②cq-04,共2篇;③cq-02没有说量少。问题表中“③②同系列”应为“①②”。',
  97. '①既说“面量真少”“真不够吃”,也批评青豆;不能说唯一明确批评是青豆,也不将“看着还可以吧”写成整体正面。',
  98. '“作为一餐是否足够”是研究问题,不是①②的逐字原话;自加食材的动机与具体用餐场景保持待验证。',
  99. '最新帖发表于2024-10-10,距2026-11-02已超过2年。“近两年没有样本”成立;“快2年”应改为“超过2年”。',
  100. '④是作者留水改善口感的经验,不能推断所有留水量的效果,也不能因作者语气正面就断言其从未感觉干。',
  101. '“吃腻泡面想尝鲜”“元旦前买”等明确自述可保留,人口特征、真实用餐场景、动机及需求强度不能补写。',
  102. ],
  103. 'sampleObservations': {'portionComments': ['cq-03', 'cq-04'],
  104. 'boilingMentioned': ['cq-02', 'cq-03', 'cq-04'],
  105. 'unnoticedBeforePurchase': ['cq-02'],
  106. 'drainingWaterAdvice': ['cq-01'], 'peaDislike': ['cq-03']},
  107. 'sampleProportionsArePopulationEstimates': False,
  108. 'draftReadyForInputPreparation': True, 's2Saved': False,
  109. 'inputHashes': {name: sha(encode(value)) for name, value in inputs.items()},
  110. 'businessWrites': 0,
  111. }
  112. retain(DEST / 'draft-review.json', encode(review))
  113. review_text = '''消费者原话草稿核对结论
  114. 草稿可以用于继续准备资料入库,尚不是已保存的S2成果。四篇原话、出处与产品区分符合原件,保留正负上下文、小样本和历史时间边界。
  115. 保存前修正以下表述:
  116. 1. 量少的是①和②,共2篇,不是3篇;问题表中“③②”应改成“①②”。
  117. 2. ①明确批评了面量和青豆,不能说唯一批评是青豆。“看着还可以吧”指加料后的外观,不足以断定整体正面。
  118. 3. “作为一餐是否足够”是我们提出的研究问题,不是作者逐字说的场景。自加挂面、蛋、菜的实际动机待验证。
  119. 4. 最新一篇为2024年10月10日,距2026年11月2日已超过2年;“近两年没有样本”成立,“快2年”应改为“超过2年”。
  120. 5. 留水建议属于作者改善口感的经验,不推出普遍嫌干或普遍不嫌干。已有的尝鲜、元旦前购买等原话可保留,未明示的人群和用餐场景保持未知。
  121. 下一步只导入4份完整正文及4条主引文,不创建产品或分类,不把燃魂与香辣合成SKU。来源为公开用户自述,购买、真实身份及商业关系未认证。发表时间只保留页面原字符串,时区未知;本次取得时间使用原采集日志,不混淆两者。
  122. 固定输入、原件哈希与本地隔离预检完成后交运维准备,再由王刚001真实消息任务执行。入库后独立回读通过,才准备S2保存模板;S1第2版G8IrkkDG7X保持原样。
  123. '''
  124. retain(DOCS / '19-消费者原话草稿核对结果.txt', review_text.encode('utf-8'))
  125. return inputs, review, review_text
  126. def package(inputs, review, review_text):
  127. verification = ROOT / 'work/consumer-quote-inputs-verified-20261102.json'
  128. local = json.loads(verification.read_text('utf-8'))
  129. assert local['ok'] and local['productionBusinessWrites'] == 0 and len(local['checks']) == 4
  130. assert local['inputHashes'] == review['inputHashes']
  131. ops = f'''运维001:准备4篇消费者原话入库。只落地固定输入和留存目录,不代跑业务。
  132. 包:ksf-consumer-quotes-import-20261102.zip。先核FILES.sha256全部项。inputs下4份cq-01至cq-04.input.json原样放到{REMOTE_INPUTS}/,root:root 0644、hermes可读。沿用现有ksf-secure.mjs,不安装新执行器,不改Skill/Function/schema,不重启。
  133. 包内archives/consumer-quotes-codex-20261102.zip为原采集包原字节,1287263B,SHA-256={ARCHIVE_SHA}。保存到{REMOTE_ARCHIVE},hermes:hermes 0600,父目录0700。若该路径已有同字节原包,核对后直接复用;已有不同文件则保留现场并停止,不覆盖、不重打包。
  134. 王刚001已回报解压原件目录/opt/data/ksf-workshop-private/consumer-quotes-codex-20261102/;若存在,核其29项FILES.sha256与原包一致后复用,不覆盖。若不存在,用已核原包在全新目录解压,目录0700、文件0600。不得将既有0条采集目录改写或清空。
  135. 只读核对固定任务键{TASK}的历史、本地回执与在途,并按输入4个sourceUrl、source.rawText内容哈希及4个externalKey查服务端。已有相同资料可复用;如发现该任务已执行或在途,回报实际编号并暂停,不换键重跑。
  136. 以hermes(10000)、HOME=/opt/data准备留存父目录及{REMOTE_STATE},0700。目标若已存在,不清空或换目录,先核历史。本批只计划KfSource+KfEvidence,不需要新身份文件。
  137. 回报:4份输入哈希/权限、同一原包实际路径/字节/完整哈希及hermes实读、原件清单核对、历史/在途与留存目录状态。没有执行workshop-import就是预期。准备完成后才交王刚001按包内执行单入库一次;无需立即保存S2。
  138. '''
  139. wang = f'''王刚001:运维准备完成后,在一条新的真实消息任务里,使用本条已核实的现有agentProfileKey,通过现有secure入口导入4份固定消费者原话输入。你不需要重新设计或修改内容。
  140. 草稿修正:量少为①②共2篇;①同时批评面量和青豆;“作为一餐”是研究问题;最新帖距今超过2年。完整核对见包内草稿核对结果.txt。
  141. 固定任务键{TASK}。沿用身份,不登记新Agent或员工。先查本任务无既有执行回执、无在途;若有则只回报,不重放。以hermes(10000)、HOME=/opt/data执行。
  142. 按cq-01、cq-02、cq-03、cq-04顺序逐份执行。前一份导入及来源/证据精确回读都通过后才做下一份。下面以cq-01为例,其余只将命令和回执前缀中的cq-01替换为对应cq-02/03/04;不改输入、不换任务键。
  143. 1. 只读查重:
  144. node /opt/data/skills/ksf-workshop-runtime/scripts/ksf-secure.mjs workshop-dedup --input {REMOTE_INPUTS}/cq-01.input.json --request-id {TASK}:cq-01:preflight:v1
  145. 新来源no-exact-match、条目deferred/SOURCE_NOT_REGISTERED是正常组合;来源unchanged和条目reuse也是可接受组合。先确认外层ok和entryResult.ok都为true,只有以上可解释结果才继续,其他异常停止回报。
  146. 2. 导入一次:
  147. node /opt/data/skills/ksf-workshop-runtime/scripts/ksf-secure.mjs workshop-import --agent-profile <本条已核实agentProfileKey> --input {REMOTE_INPUTS}/cq-01.input.json --request-id {TASK}:cq-01:import:v1
  148. 核外层ok和entryResult.ok均为true,取entryResult.result的sourceId、sourceOperation、items[0].objectId、items[0].jobId、operation及counts。不以退出码替代两层回执核对。
  149. 3. 分别用真实返回编号独占写两份只读输入(0600),格式为{{"className":"KfSource","objectId":"<实际sourceId>"}}和{{"className":"KfEvidence","objectId":"<实际evidenceId>"}},然后:
  150. node /opt/data/skills/ksf-workshop-runtime/scripts/ksf-secure.mjs workshop-read --input <来源回读输入绝对路径> --request-id {TASK}:cq-01:read-source:v1
  151. node /opt/data/skills/ksf-workshop-runtime/scripts/ksf-secure.mjs workshop-read --input <证据回读输入绝对路径> --request-id {TASK}:cq-01:read-evidence:v1
  152. 核对来源输入各字段逐项一致:social/live/smzdm、标题、URL、完整正文、collectedAt;证据输入各字段逐项一致:quote、locator、authorLabel、parentObjectId、evidenceKind=public_user_experience_self_report、verificationState=archived_text_and_screenshot_checked、quoteForm=verbatim_excerpt、publishedAt=null和实际collectedAt。sourceRef指回本次来源,productRef为空。locator按Unicode码点切片等于quote,原包/正文/截图哈希与实际文件吻合。发表时间保留locator.publishedAtReported原字符串,时区未知;不得用采集时间替代发表时间。不能只看“读接口成功”就算内容通过。
  153. 两份回读均通过后再处理下一份。所有命令各执行一次。输入快照、只读输入、stdout/stderr、退出码和两层回执独占留存到{REMOTE_STATE}/,0600,不覆盖既有文件。查重、写入、回读任何一步失败即停,保留原requestId和已取得编号;不自动重试/换键/删记录,错误不代表零写入。
  154. 成功回报4篇各自sourceId、evidenceId、jobId、sourceOperation、created/unchanged、import两层invocationId及逐项回读。可为unchanged,不为凑新增改键。collectionPerformedBy为Codex,实际导入执行者为你,不声称你刚刚重新抓取了平台。
  155. 本次只做原话资料入库:4来源、每来源1条主引文;不建KfCategory/KfProduct,不保存S2,不生成机会卡/项目,不改S1 G8IrkkDG7X,不重跑旧K轮或旧导入。3篇燃魂、1篇香辣不合SKU,均不挂到-KbX0Agka-。不称已验证购买、真实身份或普遍需求,也不据历史帖子认定当前商品价格/规格/库存。
  156. '''
  157. retain(DOCS / '20-发给运维001-消费者原话入库准备.txt', ops.encode('utf-8'))
  158. retain(DOCS / '21-发给王刚001-消费者原话入库.txt', wang.encode('utf-8'))
  159. blobs = {'inputs/' + name: encode(value) for name, value in inputs.items()}
  160. blobs.update({
  161. 'archives/consumer-quotes-codex-20261102.zip': ARCHIVE.read_bytes(),
  162. 'draft-review.json': encode(review),
  163. '草稿核对结果.txt': review_text.encode('utf-8'),
  164. '给运维001.txt': ops.encode('utf-8'), '给王刚001.txt': wang.encode('utf-8'),
  165. 'local-verification.json': verification.read_bytes(),
  166. })
  167. history_file = ROOT / 'work/consumer-quote-import-history-20261102.json'
  168. if history_file.exists():
  169. blobs['readonly-history.json'] = history_file.read_bytes()
  170. blobs['FILES.sha256'] = ''.join(sha(b) + ' ' + n + '\n' for n, b in blobs.items()).encode('utf-8')
  171. if PACKAGE.exists():
  172. with zipfile.ZipFile(PACKAGE) as z:
  173. assert set(z.namelist()) == set(blobs)
  174. for n, b in blobs.items():
  175. assert z.read(n) == b
  176. else:
  177. with zipfile.ZipFile(PACKAGE, 'x', compression=zipfile.ZIP_DEFLATED) as z:
  178. for name, blob in blobs.items():
  179. z.writestr(name, blob)
  180. with zipfile.ZipFile(PACKAGE) as z:
  181. assert z.testzip() is None
  182. for line in z.read('FILES.sha256').decode('utf-8').splitlines():
  183. expected, name = line.split(' ', 1)
  184. assert sha(z.read(name)) == expected
  185. receipt = {'ok': True, 'operation': 'consumer-quote-inputs-packaged',
  186. 'taskKey': TASK, 'file': str(PACKAGE), 'bytes': PACKAGE.stat().st_size,
  187. 'sha256': sha(PACKAGE.read_bytes()), 'manifestChecked': len(blobs) - 1,
  188. 'sourcesPlanned': 4, 'evidencePlanned': 4, 'productsPlanned': 0,
  189. 'reportsPlanned': 0, 'targetExecuted': False, 'businessWrites': 0,
  190. 'originalArchiveSha256': ARCHIVE_SHA, 'inputHashes': review['inputHashes']}
  191. retain(ROOT / 'work/consumer-quote-import-package-receipt-20261102.json', encode(receipt))
  192. return receipt
  193. if __name__ == '__main__':
  194. sys.stdout.reconfigure(encoding='utf-8')
  195. prepared = prepare()
  196. if '--package' in sys.argv:
  197. print(json.dumps(package(*prepared), ensure_ascii=False, indent=2))
  198. else:
  199. print(json.dumps({'ok': True, 'operation': 'consumer-quote-inputs-prepared',
  200. 'taskKey': TASK, 'inputHashes': prepared[1]['inputHashes'],
  201. 'businessWrites': 0}, ensure_ascii=False, indent=2))