case-intake.mjs 55 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274
  1. #!/usr/bin/env node
  2. // Copyright (c) 未来飞马
  3. //
  4. // This Source Code Form is subject to the terms of the Mozilla Public
  5. // License, v. 2.0. If a copy of the MPL was not distributed with this
  6. // file, You can obtain one at https://mozilla.org/MPL/2.0/.
  7. //
  8. // Trademark Notice:
  9. // The MPL-2.0 license grants copyright permissions for source code only.
  10. // It does NOT grant any rights to use trademarks including "未来飞马",
  11. // "Harness Loop", "RSI", and associated slogan "让AI进化提前发生,让AI落地快人一步".
  12. // Any use of these trademarks requires separate written permission.
  13. /**
  14. * skill-case-get —— 案例采集 / 拆解 / 归档 主入口
  15. *
  16. * 管线:
  17. * ① 输入识别:连续多图 / .docx / .pptx / 图文混合 / 裸图 / 视频
  18. * ② 拆解提取:图片→skill-vision(OCR+语义);docx/pptx→python3 标准库解包;视频→抽帧+skill-listen 转写
  19. * ③ 分组与顺序:尺寸/时间/内容相似度判同组;九宫格按左上→右下给显式 order
  20. * ④ 案例信息 vs 素材 分离
  21. * ⑤ 合规判断:能否入库?客诉/负面?→ riskFlags / privacyFindings
  22. * ⑥ 打标:静态属性 + 跟进状态七段 + 亮点/场景/异议 + 学校别名归一
  23. * ⑦ 入库:caseSubmit 写待审清单(reviewStatus=pending, readyForUse=false),带幂等键
  24. * ⑧ 字典自进化:新标签追加进 references/tag-dictionary.json
  25. *
  26. * 子命令:
  27. * plan 只识别 + 提取 + 分组(不调任何模型,纯本机)
  28. * ingest plan + 图片理解 + 分类打标 → 产出 case-package.json(默认不提交)
  29. * submit 读取 case-package.json(可先 reorder)→ 调云函数 caseSubmit
  30. * reorder 调整 materialAssets 顺序(支持数组 + 顺序调整)
  31. * dictionary 查看 / 追加标签字典
  32. * selftest 本机自检(office 解包 + 分组 + 打标 + 合规)
  33. *
  34. * 安全:绝不打印 token/PII;生成的产物默认落在 <cwd>/case-get-outputs/。
  35. */
  36. import fs from 'node:fs';
  37. import path from 'node:path';
  38. import zlib from 'node:zlib';
  39. import { spawnSync } from 'node:child_process';
  40. import { parseArgs } from 'node:util';
  41. import {
  42. TENANT_DEFAULT, SKILL_ROOT, buildIdempotencyKey, deriveAssetUrl, ensureDir, firstText, imageUrlsFrom,
  43. learnAliases, learnTags, loadDictionary, newRunId, normalizeMaterialAsset, outputsDir,
  44. readJson, reorderAssets, resolvePython, saveDictionary, sha1, sortAssets, truncate, uniq, writeJson,
  45. } from './lib.mjs';
  46. import { readImageInfo, IMAGE_EXTENSIONS, VIDEO_EXTENSIONS, DOC_EXTENSIONS, DECK_EXTENSIONS, TEXT_EXTENSIONS } from './image-size.mjs';
  47. import { applyGroupOverrides, groupMaterials, DEFAULT_ORDERING_RULE } from './grouping.mjs';
  48. import { classifyCase } from './classify.mjs';
  49. import { processVideo } from './listen-bridge.mjs';
  50. import { analyzeImages } from './vision-bridge.mjs';
  51. import { uploadMaterial, StorageUnavailableError } from './storage-bridge.mjs';
  52. import { CloudError, submitCase } from './cloud-client.mjs';
  53. import { submitViaParse, probeParse } from './parse-direct.mjs';
  54. // ---------------------------------------------------------------------------
  55. // 输入识别
  56. // ---------------------------------------------------------------------------
  57. export function detectSourceType({ images = 0, documents = 0, decks = 0, videos = 0, texts = 0 }) {
  58. const kinds = [images > 0, documents > 0, decks > 0, videos > 0, texts > 0].filter(Boolean).length;
  59. if (kinds > 1) return 'mixed';
  60. if (images > 1) return 'image_group';
  61. if (images === 1 && videos === 0) return 'single_image';
  62. if (videos > 0) return 'image_group'; // 视频素材组,按 group 处理
  63. if (decks > 0) return 'presentation';
  64. if (documents > 0) return 'document';
  65. return 'mixed';
  66. }
  67. export function classifyInput(filePath) {
  68. const ext = path.extname(filePath).toLowerCase();
  69. if (IMAGE_EXTENSIONS.has(ext)) return 'image';
  70. if (VIDEO_EXTENSIONS.has(ext)) return 'video';
  71. if (DOC_EXTENSIONS.has(ext)) return 'docx';
  72. if (DECK_EXTENSIONS.has(ext)) return 'pptx';
  73. if (TEXT_EXTENSIONS.has(ext)) return 'text';
  74. return 'unknown';
  75. }
  76. /** 展开目录(只展开一层里的文件,不递归太深,避免误扫)。 */
  77. export function expandInputs(inputs, maxDepth = 2) {
  78. const out = [];
  79. const walk = (target, depth) => {
  80. let stat;
  81. try {
  82. stat = fs.statSync(target);
  83. } catch {
  84. out.push({ path: target, exists: false });
  85. return;
  86. }
  87. if (stat.isDirectory()) {
  88. if (depth > maxDepth) return;
  89. for (const entry of fs.readdirSync(target).sort()) {
  90. if (entry.startsWith('.')) continue;
  91. walk(path.join(target, entry), depth + 1);
  92. }
  93. return;
  94. }
  95. out.push({ path: target, exists: true, size: stat.size, mtimeMs: stat.mtimeMs });
  96. };
  97. for (const input of inputs) walk(input, 0);
  98. return out;
  99. }
  100. // ---------------------------------------------------------------------------
  101. // ② 拆解提取
  102. // ---------------------------------------------------------------------------
  103. function runOfficeExtract(files, mediaOut) {
  104. if (!files.length) return { available: true, results: [], error: null };
  105. const script = path.join(SKILL_ROOT, 'scripts', 'office_extract.py');
  106. if (!fs.existsSync(script)) return { available: false, results: [], error: `缺少 ${script}` };
  107. const python = resolvePython();
  108. const args = [script, '--media-out', mediaOut];
  109. for (const file of files) args.push('--input', file);
  110. const run = spawnSync(python, args, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
  111. if (run.error) {
  112. return { available: false, results: [], error: `${python} 启动失败:${run.error.message}(.docx/.pptx 解包需要 python3 标准库)` };
  113. }
  114. let parsed = null;
  115. try {
  116. parsed = JSON.parse(run.stdout || '{}');
  117. } catch {
  118. return { available: true, results: [], error: `office_extract 输出不是 JSON:${truncate(run.stderr || run.stdout, 300)}` };
  119. }
  120. return {
  121. available: true,
  122. results: parsed.results || [],
  123. error: parsed.ok ? null : `部分文档解包失败:${(parsed.results || []).filter((r) => !r.ok).map((r) => r.error).join(', ')}`,
  124. };
  125. }
  126. function sleep(ms) {
  127. return new Promise((resolve) => { setTimeout(resolve, ms); });
  128. }
  129. // ---------------------------------------------------------------------------
  130. // plan:识别 + 提取 + 分组
  131. // ---------------------------------------------------------------------------
  132. export async function runPlan(options = {}) {
  133. const outDir = ensureDir(options.outDir);
  134. const mediaDir = ensureDir(path.join(outDir, 'media'));
  135. const entries = expandInputs(options.inputs || []);
  136. const missing = entries.filter((e) => !e.exists).map((e) => e.path);
  137. const buckets = { image: [], video: [], docx: [], pptx: [], text: [], unknown: [] };
  138. for (const entry of entries) {
  139. if (!entry.exists) continue;
  140. buckets[classifyInput(entry.path)].push(entry.path);
  141. }
  142. const warnings = [];
  143. for (const file of missing) warnings.push(`输入不存在:${file}`);
  144. if (buckets.unknown.length) warnings.push(`忽略无法识别的文件:${buckets.unknown.join(', ')}`);
  145. // ---- docx / pptx 解包
  146. const docxResults = runOfficeExtract(buckets.docx, path.join(mediaDir, 'docx'));
  147. const pptxResults = runOfficeExtract(buckets.pptx, path.join(mediaDir, 'pptx'));
  148. if (docxResults.error) warnings.push(docxResults.error);
  149. if (pptxResults.error) warnings.push(pptxResults.error);
  150. const textBundle = { paragraphs: [], notes: [], titles: [], transcript: '' };
  151. const mediaFromDocs = [];
  152. for (const result of [...docxResults.results, ...pptxResults.results]) {
  153. if (!result.ok) {
  154. warnings.push(`${result.fileName || result.input} 解包失败:${result.error}`);
  155. continue;
  156. }
  157. for (const p of result.paragraphs || []) if (p.text) textBundle.paragraphs.push(p.text);
  158. for (const h of result.headings || []) if (h) textBundle.titles.push(h);
  159. for (const note of result.notes || []) {
  160. if (typeof note === 'string') textBundle.notes.push(note);
  161. else if (note && note.text) textBundle.notes.push(note.text);
  162. }
  163. for (const slide of result.slides || []) if (slide.title) textBundle.titles.push(slide.title);
  164. for (const media of result.media || []) {
  165. if (media.kind === 'image' && media.localPath) mediaFromDocs.push({ ...media, sourceDoc: result.fileName || result.input });
  166. }
  167. }
  168. // ---- 纯文本输入(.md/.txt):并入说明语料
  169. for (const file of buckets.text) {
  170. try {
  171. const text = fs.readFileSync(file, 'utf8');
  172. textBundle.paragraphs.push(...text.split(/\r?\n/).map((l) => l.trim()).filter(Boolean));
  173. } catch (error) {
  174. warnings.push(`文本读取失败 ${file}:${error.message}`);
  175. }
  176. }
  177. // ---- 图片:读尺寸/时间
  178. const imageItems = [];
  179. for (const file of buckets.image) {
  180. const info = readImageInfo(file);
  181. if (!info.ok) warnings.push(`图片信息读取失败 ${path.basename(file)}:${info.error}`);
  182. imageItems.push({
  183. kind: 'image',
  184. localPath: path.resolve(file),
  185. width: info.width || null,
  186. height: info.height || null,
  187. mtimeMs: info.mtimeMs || fs.statSync(file).mtimeMs,
  188. ocrText: '',
  189. description: '',
  190. label: path.basename(file),
  191. origin: 'direct',
  192. });
  193. }
  194. // 文档内嵌图片也参与分组(图文混合)。sourceKey 钉住来源文档,
  195. // 避免两份不同文档的配图因为同比例、同落盘时间被误并成一组。
  196. for (const media of mediaFromDocs) {
  197. const info = readImageInfo(media.localPath);
  198. imageItems.push({
  199. kind: 'image',
  200. localPath: media.localPath,
  201. width: info.width || null,
  202. height: info.height || null,
  203. mtimeMs: info.mtimeMs || null,
  204. ocrText: '',
  205. description: media.nearbyText || '',
  206. label: `${media.sourceDoc || '文档'} 内嵌图`,
  207. origin: 'document-media',
  208. sourceKey: media.sourceDoc || '',
  209. slide: media.slide,
  210. });
  211. }
  212. // ---- 视频:抽帧 + 转写
  213. const videoReports = [];
  214. for (const file of buckets.video) {
  215. if (options.skipVideo) {
  216. warnings.push(`跳过视频(--skip-video):${path.basename(file)}`);
  217. continue;
  218. }
  219. const report = await processVideo(file, {
  220. workDir: path.join(mediaDir, 'video', path.basename(file, path.extname(file))),
  221. frameCount: options.frameCount || 6,
  222. diarize: Boolean(options.diarize),
  223. });
  224. videoReports.push(report);
  225. for (const warning of report.warnings) warnings.push(`${path.basename(file)}:${warning}`);
  226. if (report.transcription && report.transcription.text) {
  227. textBundle.transcript = [textBundle.transcript, report.transcription.text].filter(Boolean).join('\n');
  228. }
  229. for (const frame of report.frames || []) {
  230. imageItems.push({
  231. kind: 'image',
  232. localPath: frame.localPath,
  233. width: null,
  234. height: null,
  235. mtimeMs: null,
  236. ocrText: '',
  237. description: `视频抽帧 @${frame.atMs != null ? `${Math.round(frame.atMs / 1000)}s` : '开头'}`,
  238. label: `${path.basename(file)} 抽帧`,
  239. origin: 'video-frame',
  240. atMs: frame.atMs,
  241. videoPath: path.resolve(file),
  242. });
  243. }
  244. }
  245. // ---- 分组(尺寸 / 时间 / 内容)
  246. let groups = groupMaterials(imageItems, options.grouping || {});
  247. groups = applyGroupOverrides(groups, options.groupOverrides);
  248. const sourceType = options.sourceType || detectSourceType({
  249. images: buckets.image.length,
  250. documents: buckets.docx.length + buckets.text.length,
  251. decks: buckets.pptx.length,
  252. videos: buckets.video.length,
  253. });
  254. const plan = {
  255. runId: options.runId || newRunId(),
  256. generatedAt: new Date().toISOString(),
  257. tenantId: options.tenantId || TENANT_DEFAULT,
  258. sourceType,
  259. sourceRef: options.sourceRef || '',
  260. authorizationStatus: String(options.authorizationStatus || '').toLowerCase(),
  261. inputs: {
  262. images: buckets.image.map((p) => path.resolve(p)),
  263. documents: buckets.docx.map((p) => path.resolve(p)),
  264. decks: buckets.pptx.map((p) => path.resolve(p)),
  265. videos: buckets.video.map((p) => path.resolve(p)),
  266. texts: buckets.text.map((p) => path.resolve(p)),
  267. },
  268. counts: {
  269. imageCount: imageItems.length,
  270. documentCount: buckets.docx.length,
  271. deckCount: buckets.pptx.length,
  272. videoCount: buckets.video.length,
  273. groupCount: groups.length,
  274. },
  275. textBundle,
  276. groups,
  277. videoReports,
  278. warnings,
  279. // 生成物绝对路径(后续 vision / submit 直接引用)
  280. artifacts: {
  281. extracted: path.join(outDir, 'extracted.json'),
  282. groupPlan: path.join(outDir, 'group-plan.json'),
  283. },
  284. };
  285. if (!groups.length) plan.warnings.push('没有识别到任何可用素材(没有任何图片/视频/文档内嵌图)');
  286. writeJson(plan.artifacts.extracted, { textBundle, mediaFromDocs, videoReports });
  287. writeJson(plan.artifacts.groupPlan, { sourceType, groups });
  288. return plan;
  289. }
  290. // ---------------------------------------------------------------------------
  291. // ingest:plan + 图片理解 + 分类打标 → 案例包
  292. // ---------------------------------------------------------------------------
  293. /**
  294. * 解析单个素材的公网 URL(materialAssets[].url 的唯一来源)。
  295. *
  296. * 优先级(任务书硬要求):
  297. * ① item.url 已存在(文档自带的远程 URL)→ 原样用,**不触发上传**;
  298. * ② 本地文件且 upload 开 → 真上传(fmode-storage 第 0 级免密钥通道)拿公网 URL;
  299. * ③ --asset-base-url 派生(保留旧行为,显式回退);
  300. * ④ 都没有 → 空字符串(由调用方补 warning)。
  301. *
  302. * 本函数**不抛错、不打印**:上传失败以 `error` 返回,由调用方降级并汇总告警,
  303. * 保证「单张失败不拖垮整个 ingest」。
  304. *
  305. * @returns {Promise<{url:string, upload:object|null, fallback:boolean, error:Error|null, reason:string}>}
  306. */
  307. // 文件内容指纹:避免两个不同文件落成同一个 object key 互相覆盖(读不到则退化为 0)。
  308. function contentFingerprint(filePath) {
  309. try {
  310. const buf = fs.readFileSync(filePath);
  311. return `${buf.length}-${sha1(buf.toString('base64')).slice(0, 12)}`;
  312. } catch {
  313. return '0';
  314. }
  315. }
  316. export async function resolveAssetUrl(item, options = {}) {
  317. const remote = item && item.url ? String(item.url) : '';
  318. if (remote) return { url: remote, upload: null, fallback: false, error: null, reason: 'remote-url' };
  319. const localPath = (item && item.localPath) || '';
  320. let uploadError = null;
  321. // key 区分度:优先 显式 slug / 来源引用 / 标题;都没有时用「文件内容指纹」兜底。
  322. // 否则两个不同案例都会落成 case-1.png → 同日撞 key 互相覆盖(真实事故:两张不同图同 key)。
  323. let slugSeed = options.caseSlug;
  324. if (!slugSeed) {
  325. slugSeed = localPath ? `c${contentFingerprint(localPath)}` : 'case';
  326. }
  327. if (localPath && options.upload) {
  328. try {
  329. const upload = await uploadMaterial(localPath, {
  330. tenantId: options.tenantId,
  331. caseSlug: slugSeed,
  332. order: options.order,
  333. });
  334. if (upload && upload.url) {
  335. return { url: upload.url, upload, fallback: false, error: null, reason: 'uploaded' };
  336. }
  337. uploadError = new Error('上传回执未给出公网 URL');
  338. } catch (error) {
  339. // 上传失败:**记下原因**并继续走回退链,绝不吞掉(调用方要据此告警)
  340. uploadError = error;
  341. }
  342. }
  343. const derived = deriveAssetUrl(localPath, options.assetBaseUrl);
  344. if (derived) return { url: derived, upload: null, fallback: true, error: uploadError, reason: 'derived' };
  345. return {
  346. url: '',
  347. upload: null,
  348. fallback: false,
  349. error: uploadError,
  350. reason: localPath ? 'no-url' : 'no-source',
  351. };
  352. }
  353. export async function runIngest(options = {}) {
  354. const plan = await runPlan(options);
  355. const outDir = options.outDir;
  356. const warnings = [...plan.warnings];
  357. // 图片理解(OCR + 语义)——没有图片时跳过
  358. const allImages = plan.groups.flatMap((g) => g.items.filter((i) => i.kind === 'image'));
  359. let vision = { available: true, results: [], error: null, needsHostRead: false };
  360. if (options.skipVision) {
  361. warnings.push('跳过图片理解(--skip-vision):素材只有文件名,ocrText/description 为空');
  362. } else if (allImages.length) {
  363. const batchHint = [
  364. plan.textBundle.titles.slice(0, 3).join(' / '),
  365. plan.textBundle.paragraphs.slice(0, 3).join(' / '),
  366. ].filter(Boolean).join(' | ');
  367. vision = await analyzeImages(
  368. allImages.map((item) => ({ localPath: item.localPath, url: item.url, label: item.label })),
  369. { model: options.visionModel, batchHint },
  370. );
  371. if (!vision.available) {
  372. warnings.push(vision.error);
  373. } else if (vision.needsHostRead) {
  374. warnings.push('宿主多模态模式:vision 返回了读图指令(instruction),请宿主 Agent 用 Read 工具读图后把 JSON 结果写入 vision-host.json,再执行 submit。');
  375. } else if (vision.error) {
  376. warnings.push(vision.error);
  377. }
  378. }
  379. // 把 vision 结果并回素材
  380. const visionByFile = new Map();
  381. for (const result of vision.results || []) {
  382. if (result.file) visionByFile.set(path.resolve(result.file), result);
  383. }
  384. const enrichedGroups = plan.groups.map((group) => ({
  385. ...group,
  386. items: group.items.map((item) => {
  387. const enriched = { ...item };
  388. if (item.kind === 'image' && item.localPath) {
  389. const found = visionByFile.get(path.resolve(item.localPath));
  390. if (found) {
  391. enriched.ocrText = found.ocrText || enriched.ocrText || '';
  392. enriched.description = found.description || enriched.description || '';
  393. enriched.usageSuggestion = found.usageSuggestion || '';
  394. enriched.label = found.label || enriched.label || '';
  395. // 分析未产出结论(宿主读图待补 / 分析失败)时保持默认 role='material',
  396. // 不能凭空把素材降级成说明配图。
  397. enriched.role = found.role || 'material';
  398. enriched.piiHints = found.piiHints || [];
  399. enriched.visionPending = Boolean(found.needsHostRead);
  400. } else {
  401. enriched.role = enriched.role || 'material';
  402. }
  403. }
  404. return enriched;
  405. }),
  406. }));
  407. // 案例信息 vs 素材:分离后分类打标
  408. const dict = loadDictionary(options.dictPath);
  409. const visionResults = (vision.results || []).map((r) => ({ ...r }));
  410. const classification = classifyCase({
  411. dict,
  412. groups: enrichedGroups,
  413. visionResults,
  414. textBundle: plan.textBundle,
  415. authorizationStatus: plan.authorizationStatus,
  416. hints: options.hints || {},
  417. });
  418. // ⑧ 字典自进化
  419. const learn = learnTags(dict, classification.learned.tags);
  420. const aliasLearn = learnAliases(dict, classification.learned.aliases);
  421. const dictionaryChanges = { tags: learn.added, aliases: aliasLearn.added, skipped: learn.skipped };
  422. const dictionaryFile = options.dictPath ? path.resolve(options.dictPath) : undefined;
  423. if ((learn.added.length || aliasLearn.added.length) && !options.noLearn) {
  424. saveDictionary(dict);
  425. } else if (options.noLearn && (learn.added.length || aliasLearn.added.length)) {
  426. warnings.push('字典自进化被 --no-learn 关闭:本次新增标签未落盘');
  427. dictionaryChanges.tags = [];
  428. dictionaryChanges.aliases = [];
  429. }
  430. if (dictionaryFile) writeJson(dictionaryFile, readJson(dictionaryFile, {}));
  431. // ---- 组装 materialAssets[](逐字对齐 SHARED-CONTRACT.md)
  432. const materialAssets = [];
  433. const groupInfoList = [];
  434. const assetProblems = [];
  435. let missingUrlCount = 0;
  436. let uploadFailedCount = 0;
  437. let storageUnavailable = false;
  438. const fallbackItems = [];
  439. // 上传真正发生才需要 caseSlug;全走本地文件时没有标题也不影响。
  440. const caseSlugSeed = firstText(options.sourceRef, options.title);
  441. for (const group of enrichedGroups) {
  442. groupInfoList.push({
  443. groupId: group.groupId,
  444. layout: group.layout,
  445. count: group.count,
  446. orderingRule: group.orderingRule,
  447. });
  448. for (const item of sortAssets(group.items)) {
  449. // URL 优先级:① 文档自带远程 url → ② --upload 真上传 → ③ --asset-base-url 派生 → ④ 留空 + warning
  450. const resolved = await resolveAssetUrl(item, {
  451. // 默认开(与 CLI 一致):只有显式 upload:false / --no-upload 才关
  452. upload: options.upload !== false,
  453. tenantId: plan.tenantId,
  454. // 这里**不要**用 'case' 兜底:留空让 resolveAssetUrl 用文件内容指纹保证 key 区分度
  455. caseSlug: item.slug || caseSlugSeed || '',
  456. order: materialAssets.length + 1,
  457. assetBaseUrl: options.assetBaseUrl,
  458. });
  459. const assetUrl = resolved.url;
  460. const upload = resolved.upload;
  461. const uploadError = resolved.error;
  462. const usedFallback = resolved.fallback;
  463. if (uploadError) {
  464. // 上传失败绝不能拖垮整个 ingest:单张降级、明确告警,其余素材照常
  465. uploadFailedCount += 1;
  466. if (uploadError instanceof StorageUnavailableError) storageUnavailable = true;
  467. const reason = uploadError && uploadError.message ? uploadError.message : String(uploadError);
  468. warnings.push(`上传失败(${path.basename(item.localPath)}):${reason};该素材将无公网 URL`);
  469. }
  470. if (!assetUrl && item.localPath) {
  471. missingUrlCount += 1;
  472. if (!uploadError) fallbackItems.push(path.basename(item.localPath));
  473. }
  474. const { asset, problems } = normalizeMaterialAsset({
  475. order: item.order,
  476. groupId: group.groupId,
  477. kind: item.kind,
  478. url: assetUrl,
  479. localPath: item.localPath || '',
  480. label: item.label || '',
  481. ocrText: item.ocrText || '',
  482. description: item.description || '',
  483. usageSuggestion: item.usageSuggestion || '',
  484. role: item.role || 'material',
  485. }, materialAssets.length);
  486. assetProblems.push(...problems);
  487. materialAssets.push({
  488. ...asset,
  489. ...(upload ? { uploadKey: upload.key, uploadBytes: upload.bytes, uploadVia: 'cloud-function' } : {}),
  490. isFallbackUrl: usedFallback || undefined,
  491. });
  492. }
  493. }
  494. if (uploadFailedCount) {
  495. warnings.push(`${uploadFailedCount} 张上传失败:这些素材的 url 已降级(本地派生前缀/留空),检索侧拿不到它们的图。`
  496. + (storageUnavailable ? '(fmode-storage 不可用:安装兄弟技能或改用 --no-upload + --asset-base-url)' : ''));
  497. }
  498. for (const problem of assetProblems) warnings.push(problem);
  499. if (missingUrlCount) {
  500. const detail = options.upload === false
  501. ? '(--no-upload 已关闭上传;可改用 --asset-base-url <CDN前缀> 派生)'
  502. : `已尝试上传但未拿到公网地址(${uniq(fallbackItems).slice(0, 5).join('、') || '见上方上传失败原因'})。请确认 fmode-storage 可用(免密钥通道)或改用 --asset-base-url <CDN前缀> 派生(检索侧只看 url)`;
  503. warnings.push(`${missingUrlCount} 个素材只有本地路径、没有 URL:${detail}。`);
  504. }
  505. // 全局重编号:materialAssets 已按「分组顺序 → 组内 order」构建,
  506. // 这里只顺次改写 order。**不能**再按 order 全局排序——各组的 order 都从 1 起,
  507. // 直接排会把四组的第一张图并到最前面。
  508. const orderedAssets = materialAssets.map((asset, index) => ({ ...asset, order: index + 1 }));
  509. const imageUrls = imageUrlsFrom(orderedAssets);
  510. // ---- 组装案例包(幂等键:tenantId + 内容指纹)
  511. const title = firstText(options.title, classification.caseFields.title);
  512. const idempotencyKey = buildIdempotencyKey({
  513. tenantId: plan.tenantId,
  514. sourceType: plan.sourceType,
  515. sourceRef: plan.sourceRef,
  516. title,
  517. materialAssets: orderedAssets,
  518. explicit: options.idempotencyKey,
  519. });
  520. const primaryGroup = groupInfoList[0] || { groupId: 'g1', layout: '1x1', count: 0, orderingRule: DEFAULT_ORDERING_RULE };
  521. const casePackage = {
  522. schemaVersion: '1.0.0',
  523. generatedAt: new Date().toISOString(),
  524. runId: plan.runId,
  525. contract: 'SHARED-CONTRACT.md',
  526. // ---- 云函数 caseSubmit 入参(逐字对齐)
  527. sourceType: plan.sourceType,
  528. sourceRef: plan.sourceRef,
  529. idempotencyKey,
  530. title,
  531. summary: firstText(options.summary, classification.caseFields.summary),
  532. materialAssets: orderedAssets,
  533. groupInfo: primaryGroup,
  534. groupInfoList,
  535. schoolCanonical: (classification.tags.schoolCanonical || [])[0] || '',
  536. tags: uniq([
  537. ...(classification.tags.country || []),
  538. ...(classification.tags.schoolCanonical || []),
  539. ...(classification.tags.major || []),
  540. ...(classification.tags.stage || []),
  541. ...(classification.tags.subject || []),
  542. ...(classification.tags.highlightTypes || []),
  543. ...(classification.tags.scenarioTags || []),
  544. ...(classification.tags.objectionTags || []),
  545. ...(classification.fit.fitStatus || []),
  546. ...(classification.tags.productLine || []),
  547. ]),
  548. highlightTypes: classification.tags.highlightTypes || [],
  549. scenarioTags: classification.tags.scenarioTags || [],
  550. objectionTags: classification.tags.objectionTags || [],
  551. riskFlags: classification.compliance.riskFlags || [],
  552. authorizationStatus: plan.authorizationStatus || '',
  553. // ---- 案例字段(案例说明,与素材分开)
  554. targetCustomer: firstText(options.targetCustomer, classification.caseFields.targetCustomer),
  555. usageSuggestion: firstText(options.usageSuggestion, classification.caseFields.usageSuggestion),
  556. resultEvidence: classification.caseFields.resultEvidence,
  557. outcome: classification.caseFields.outcome,
  558. country: (classification.tags.country || [])[0] || '',
  559. major: (classification.tags.major || [])[0] || '',
  560. stage: (classification.tags.stage || [])[0] || '',
  561. subject: (classification.tags.subject || [])[0] || '',
  562. fitStatus: classification.fit.fitStatus || [],
  563. schoolAliases: classification.tags.schoolAliases || [],
  564. imageUrls,
  565. hasImage: imageUrls.length > 0,
  566. imageText: truncate(orderedAssets.map((a) => a.ocrText).filter(Boolean).join(' '), 400),
  567. privacyFindings: classification.compliance.privacyFindings || [],
  568. complianceBlockers: classification.compliance.complianceBlockers || [],
  569. reviewHint: classification.compliance.reviewHint,
  570. summaryLine: casePackageSummaryLine(classification, orderedAssets.length),
  571. submittedBy: options.submittedBy || '',
  572. // ---- 落库后由服务端决定的字段(此处只声明预期值,供 dry-run 展示)
  573. expected: { reviewStatus: 'pending', readyForUse: false },
  574. };
  575. // ---- 产出物
  576. const artifacts = {
  577. extracted: plan.artifacts.extracted,
  578. groupPlan: plan.artifacts.groupPlan,
  579. vision: path.join(outDir, 'vision.json'),
  580. materialAssets: path.join(outDir, 'material-assets.json'),
  581. casePackage: path.join(outDir, 'case-package.json'),
  582. report: path.join(outDir, 'report.md'),
  583. };
  584. writeJson(artifacts.vision, vision);
  585. writeJson(artifacts.materialAssets, { groupInfoList, materialAssets: orderedAssets });
  586. writeJson(artifacts.casePackage, casePackage);
  587. fs.writeFileSync(artifacts.report, renderReport({ plan, casePackage, vision, dictionaryChanges, warnings }), 'utf8');
  588. const blockers = casePackage.complianceBlockers || [];
  589. const canSubmit = plan.authorizationStatus === 'authorized' && blockers.length === 0 && orderedAssets.length > 0;
  590. return { plan, casePackage, vision, dictionaryChanges, warnings, artifacts, canSubmit };
  591. }
  592. function casePackageSummaryLine(classification, assetCount) {
  593. const fits = (classification.fit.fitStatus || []).join('/') || '未命中';
  594. const risks = (classification.compliance.riskFlags || []).join('/') || '无';
  595. return `${assetCount} 个素材;适用跟进状态:${fits};风险:${risks}`;
  596. }
  597. // ---------------------------------------------------------------------------
  598. // 报告
  599. // ---------------------------------------------------------------------------
  600. export function renderReport({ plan, casePackage, vision, dictionaryChanges, warnings }) {
  601. const lines = [];
  602. lines.push(`# 案例采集报告 · ${casePackage.runId}`);
  603. lines.push('');
  604. lines.push(`- 生成时间:${casePackage.generatedAt}`);
  605. lines.push(`- 来源类型:\`${casePackage.sourceType}\``);
  606. lines.push(`- 授权状态:\`${casePackage.authorizationStatus || '缺失'}\``);
  607. lines.push(`- 幂等键:\`${casePackage.idempotencyKey}\``);
  608. lines.push(`- 素材数:${casePackage.materialAssets.length}(分组 ${casePackage.groupInfoList.length} 组)`);
  609. lines.push(`- 图片理解:${vision.available ? `已启用(${vision.source}${vision.needsHostRead ? ',待宿主读图' : ''})` : '不可用'}`);
  610. lines.push('');
  611. lines.push('## 素材顺序(materialAssets)');
  612. lines.push('');
  613. lines.push('| order | groupId | kind | role | 标签 | 说明 |');
  614. lines.push('| --- | --- | --- | --- | --- | --- |');
  615. for (const asset of casePackage.materialAssets) {
  616. lines.push(`| ${asset.order} | ${asset.groupId} | ${asset.kind} | ${asset.role} | ${truncate(asset.label, 20)} | ${truncate(asset.description || asset.ocrText, 60)} |`);
  617. }
  618. lines.push('');
  619. lines.push('## 分组(九宫格阅读顺序:左上 → 右下)');
  620. lines.push('');
  621. for (const group of casePackage.groupInfoList) {
  622. lines.push(`- \`${group.groupId}\` ${group.layout} · ${group.count} 张 · \`${group.orderingRule}\``);
  623. }
  624. lines.push('');
  625. lines.push('## 标签');
  626. lines.push('');
  627. lines.push(`- 学校(归一):${casePackage.schoolCanonical || '未识别'}${casePackage.schoolAliases.length ? `(命中别名:${casePackage.schoolAliases.join('、')})` : ''}`);
  628. lines.push(`- 国家:${casePackage.country || '未识别'} · 专业:${casePackage.major || '未识别'} · 阶段:${casePackage.stage || '未识别'} · 科目:${casePackage.subject || '未识别'}`);
  629. lines.push(`- 亮点:${casePackage.highlightTypes.join('、') || '无'}`);
  630. lines.push(`- 场景:${casePackage.scenarioTags.join('、') || '无'}`);
  631. lines.push(`- 异议:${casePackage.objectionTags.join('、') || '无'}`);
  632. lines.push(`- 适用跟进状态:${(casePackage.fitStatus || []).join('、') || '未命中'}`);
  633. lines.push('');
  634. lines.push('## 合规判断');
  635. lines.push('');
  636. lines.push(`- riskFlags:${casePackage.riskFlags.join('、') || '无'}`);
  637. lines.push(`- privacyFindings:${casePackage.privacyFindings.length} 条(仅记录,本期不打码)`);
  638. for (const finding of casePackage.privacyFindings) {
  639. lines.push(` - ${finding.field} / ${finding.rule}:\`${finding.snippet}\``);
  640. }
  641. lines.push(`- 结论:${casePackage.complianceBlockers.length ? '不入库' : '可入库待审'}`);
  642. for (const blocker of casePackage.complianceBlockers) lines.push(` - ⛔ ${blocker}`);
  643. lines.push(`- 审核提示:${casePackage.reviewHint}`);
  644. lines.push('');
  645. lines.push('## 字典自进化');
  646. lines.push('');
  647. lines.push(`- 新增标签:${dictionaryChanges.tags.length ? dictionaryChanges.tags.map((t) => `${t.dimension}=${t.value}`).join('、') : '无'}`);
  648. lines.push(`- 新增别名:${dictionaryChanges.aliases.length ? dictionaryChanges.aliases.map((a) => `${a.aliasText}→${a.canonicalName}`).join('、') : '无'}`);
  649. lines.push('');
  650. if (warnings.length) {
  651. lines.push('## 警告');
  652. lines.push('');
  653. for (const warning of uniq(warnings)) lines.push(`- ⚠️ ${warning}`);
  654. lines.push('');
  655. }
  656. lines.push('## 下一步');
  657. lines.push('');
  658. lines.push('1. 打开 `material-assets.json` 核对顺序(九宫格从左到右、从上到下),需要调整用 `reorder`;');
  659. lines.push('2. 确认无误后执行 `submit --package case-package.json`,落到待审清单(reviewStatus=pending,不进公共素材库);');
  660. lines.push('3. 由负责人用 `caseApprove` 审核放行后才会 `readyForUse=true`、进检索结果。');
  661. return `${lines.join('\n')}\n`;
  662. }
  663. // ---------------------------------------------------------------------------
  664. // submit
  665. // ---------------------------------------------------------------------------
  666. export async function runSubmit(options = {}) {
  667. const packagePath = path.resolve(options.package);
  668. const casePackage = readJson(packagePath, null);
  669. if (!casePackage) throw new Error(`读不到案例包:${packagePath}`);
  670. const blockers = casePackage.complianceBlockers || [];
  671. if (String(casePackage.authorizationStatus || '').toLowerCase() !== 'authorized') {
  672. return {
  673. submitted: false,
  674. reason: `authorizationStatus=${casePackage.authorizationStatus || '缺失'},仅 authorized 才可入库(硬规则:无授权不入库)`,
  675. blockers: ['UNAUTHORIZED'],
  676. };
  677. }
  678. if (blockers.length) {
  679. return { submitted: false, reason: 'complianceBlockers 非空,禁止入库', blockers };
  680. }
  681. if (!casePackage.materialAssets || !casePackage.materialAssets.length) {
  682. return { submitted: false, reason: 'materialAssets 为空,无法构成案例', blockers: ['NO_MATERIAL'] };
  683. }
  684. const useDirect = options.direct !== false; // 默认允许回落到 Parse 直写
  685. try {
  686. const response = await submitCase(casePackage, { submittedBy: options.submittedBy, token: options.token });
  687. if (!response.ok) {
  688. // 云函数存在但业务报错(如未授权 403)→ 不回落直写,如实返回
  689. // 端点不提供函数服务(404/400 HTML,或网络不可达)→ 回落 Parse 直写
  690. if (useDirect && (response.endpointUnavailable || response.error.code === 'NETWORK_UNAVAILABLE' || /404|不可达|超时/.test(response.error.message || ''))) {
  691. const direct = await submitViaParse(casePackage, { submittedBy: options.submittedBy });
  692. return { submitted: true, ...direct, fallback: 'cloud-unavailable -> parse-direct' };
  693. }
  694. return { submitted: false, reason: `caseSubmit 返回错误:${response.error.code} ${response.error.message}`, error: response.error };
  695. }
  696. return {
  697. submitted: true,
  698. objectId: response.result.objectId,
  699. reviewStatus: response.result.reviewStatus || 'pending',
  700. readyForUse: response.result.readyForUse === true,
  701. via: 'cloud-function',
  702. };
  703. } catch (error) {
  704. if (error instanceof CloudError) {
  705. // 客户自有云未挂载函数运行时 → 走同契约的 Parse 直写归档(仍只进待审清单)
  706. if (useDirect) {
  707. try {
  708. const direct = await submitViaParse(casePackage, { submittedBy: options.submittedBy });
  709. return { submitted: true, ...direct, fallback: 'cloud-unavailable -> parse-direct' };
  710. } catch (directErr) {
  711. return { submitted: false, reason: `云函数不可达,Parse 直写也失败:${directErr.message}`, offline: true };
  712. }
  713. }
  714. return {
  715. submitted: false,
  716. reason: error.message,
  717. offline: true,
  718. hint: '未提交成功(网络不可达/未登录)。案例包已保存在本地,可直接重跑 submit;幂等键保证重复提交只产生一条。',
  719. };
  720. }
  721. throw error;
  722. }
  723. }
  724. // ---------------------------------------------------------------------------
  725. // reorder
  726. // ---------------------------------------------------------------------------
  727. export function runReorder(options = {}) {
  728. const assetsPath = path.resolve(options.materialAssets || options.package);
  729. const bundle = readJson(assetsPath, null);
  730. if (!bundle) throw new Error(`读不到素材文件:${assetsPath}`);
  731. const list = Array.isArray(bundle) ? bundle : bundle.materialAssets;
  732. if (!Array.isArray(list)) throw new Error('素材文件里没有 materialAssets 数组');
  733. let next;
  734. if (options.move) {
  735. const [from, to] = String(options.move).split(/[:>,-]/).map((v) => Number(v.trim()));
  736. if (!Number.isFinite(from) || !Number.isFinite(to)) throw new Error('--move 格式应为 from:to,如 3:1');
  737. next = reorderAssets(list, from, to);
  738. } else if (options.order) {
  739. // --order "3,1,2" 表示「新顺序 = 原来第 3 项、第 1 项、第 2 项」(按位置,不是按 order 值)
  740. const positions = String(options.order).split(/[,\s]+/).map((v) => Number(v.trim())).filter(Number.isFinite);
  741. if (positions.length !== list.length) throw new Error(`--order 需要 ${list.length} 个位置序号,收到 ${positions.length} 个`);
  742. const byPosition = sortAssets(list);
  743. next = positions.map((position, index) => {
  744. const picked = byPosition[position - 1];
  745. if (!picked) throw new Error(`--order 位置越界:${position}(共 ${byPosition.length} 项)`);
  746. return { ...picked, order: index + 1 };
  747. });
  748. } else {
  749. throw new Error('需要 --move "3:1" 或 --order "3,1,2"');
  750. }
  751. const out = Array.isArray(bundle)
  752. ? next
  753. : { ...bundle, materialAssets: next, reorderedAt: new Date().toISOString() };
  754. writeJson(assetsPath, out);
  755. return { file: assetsPath, materialAssets: next };
  756. }
  757. // ---------------------------------------------------------------------------
  758. // 字典
  759. // ---------------------------------------------------------------------------
  760. export function runDictionary(options = {}) {
  761. const dict = loadDictionary(options.dictPath);
  762. if (options.add) {
  763. const items = [];
  764. for (const raw of options.add) {
  765. const [dimension, value] = String(raw).split('=');
  766. items.push({ dimension: (dimension || '').trim(), value: (value || '').trim() });
  767. }
  768. const result = learnTags(dict, items);
  769. if (result.added.length) saveDictionary(dict);
  770. return { added: result.added, skipped: result.skipped, file: dict.__file };
  771. }
  772. if (options.alias) {
  773. const entries = [];
  774. for (const raw of options.alias) {
  775. const [aliasText, canonicalName, country] = String(raw).split('=');
  776. entries.push({ aliasText: (aliasText || '').trim(), canonicalName: (canonicalName || '').trim(), country: (country || '').trim() });
  777. }
  778. const result = learnAliases(dict, entries);
  779. if (result.added.length) saveDictionary(dict);
  780. return { added: result.added, file: dict.__file };
  781. }
  782. return {
  783. file: dict.__file,
  784. dimensions: Object.entries(dict.dimensions || {}).map(([key, dim]) => ({
  785. dimension: key,
  786. label: dim.label,
  787. count: Array.isArray(dim.values) ? dim.values.length : Array.isArray(dim.entries) ? dim.entries.length : 0,
  788. values: dim.values || undefined,
  789. })),
  790. learnedCount: (dict.__learned || []).length,
  791. };
  792. }
  793. // ---------------------------------------------------------------------------
  794. // selftest
  795. // ---------------------------------------------------------------------------
  796. export async function runSelftest(options = {}) {
  797. const results = [];
  798. const python = resolvePython();
  799. // ① office 解包(python 标准库)
  800. const officeScript = path.join(SKILL_ROOT, 'scripts', 'office_extract.py');
  801. const officeRun = spawnSync(python, [officeScript, '--selftest'], { encoding: 'utf8' });
  802. results.push({
  803. name: 'office_extract(docx/pptx 标准库解包)',
  804. ok: officeRun.status === 0,
  805. detail: truncate(officeRun.stdout || officeRun.stderr || '', 400),
  806. });
  807. // ② 分组 / 打标 / 合规:离线合成样例
  808. const tmpRoot = ensureDir(path.join(outputsDir(options.outDir), 'selftest', newRunId()));
  809. const pngPath = path.join(tmpRoot, 'grid-01.png');
  810. fs.writeFileSync(pngPath, makeTinyPng(400, 400));
  811. const gridItems = [0, 1, 2, 3, 4, 5, 6, 7, 8].map((i) => ({
  812. kind: 'image',
  813. localPath: path.join(tmpRoot, `grid-${String(i + 1).padStart(2, '0')}.png`),
  814. width: 400,
  815. height: 400,
  816. mtimeMs: 1_700_000_000_000 + i * 1000,
  817. ocrText: '帝国理工 计算机科学 考前冲刺 提分',
  818. description: '',
  819. label: `朋友圈素材 ${i + 1}`,
  820. }));
  821. const groups = groupMaterials(gridItems, {});
  822. const gridOk = groups.length === 1 && groups[0].count === 9 && groups[0].layout === '3x3'
  823. && groups[0].items.every((item, index) => item.order === index + 1);
  824. results.push({
  825. name: '九宫格分组与顺序(3x3,order 1..9)',
  826. ok: gridOk,
  827. detail: `分组数=${groups.length},布局=${groups[0] && groups[0].layout},顺序=${groups[0] && groups[0].items.map((i) => i.order).join(',')}`,
  828. });
  829. const separated = groupMaterials([
  830. { kind: 'image', localPath: path.join(tmpRoot, 'a1.png'), width: 400, height: 400, mtimeMs: 1, ocrText: '帝国理工 提分' },
  831. { kind: 'image', localPath: path.join(tmpRoot, 'a2.png'), width: 400, height: 400, mtimeMs: 2000, ocrText: '帝国理工 提分' },
  832. { kind: 'image', localPath: path.join(tmpRoot, 'b1.png'), width: 1600, height: 900, mtimeMs: 900_000_000, ocrText: '香港大学 申诉' },
  833. ], {});
  834. results.push({
  835. name: '跨批次不误并组(时间 + 尺寸 + 内容三重信号)',
  836. ok: separated.length === 2,
  837. detail: `分组数=${separated.length},各组成员=${separated.map((g) => g.count).join('+')}`,
  838. });
  839. const dict = loadDictionary(options.dictPath);
  840. const classified = classifyCase({
  841. dict,
  842. groups: [...groups, ...separated],
  843. visionResults: [],
  844. textBundle: { paragraphs: ['帝国理工 计算机科学 考前冲刺辅导案例,学生期末提分 18 分,家长很满意', '微信:student_demo 联系 13800000000'], notes: [], titles: [], transcript: '' },
  845. authorizationStatus: 'authorized',
  846. hints: {},
  847. });
  848. const classifyOk = classified.tags.schoolCanonical.includes('帝国理工')
  849. && classified.tags.highlightTypes.includes('提分')
  850. && classified.compliance.privacyFindings.length > 0
  851. && classified.compliance.authorizationOk === true;
  852. results.push({
  853. name: '打标 + 别名归一(IC→帝国理工)+ 隐私发现',
  854. ok: classifyOk,
  855. detail: `学校=${classified.tags.schoolCanonical.join('/')},亮点=${classified.tags.highlightTypes.join('/')},隐私=${classified.compliance.privacyFindings.map((f) => f.field).join('/')}`,
  856. });
  857. const complaint = classifyCase({
  858. dict,
  859. groups: [],
  860. visionResults: [],
  861. textBundle: { paragraphs: ['学生投诉退费纠纷,要求赔偿'], notes: [], titles: [], transcript: '' },
  862. authorizationStatus: 'pending',
  863. hints: {},
  864. });
  865. const riskOk = complaint.compliance.riskFlags.includes('COMPLAINT')
  866. && complaint.compliance.riskFlags.includes('UNAUTHORIZED')
  867. && complaint.compliance.complianceBlockers.length >= 2
  868. && complaint.compliance.authorizationOk === false;
  869. results.push({
  870. name: '合规判断(客诉/负面 → riskFlags;无授权 → 不入库)',
  871. ok: riskOk,
  872. detail: `riskFlags=${complaint.compliance.riskFlags.join('/')},blockers=${complaint.compliance.complianceBlockers.length}`,
  873. });
  874. const reorderOk = (() => {
  875. const moved = reorderAssets(sortAssets(groups[0].items).map((i) => ({ ...i, kind: 'image' })), 9, 1);
  876. return moved[0].order === 1 && moved.length === 9;
  877. })();
  878. results.push({ name: '素材顺序调整(reorder)', ok: reorderOk, detail: '把第 9 张移到第 1 位后重新编号 1..9' });
  879. const failed = results.filter((r) => !r.ok);
  880. return { ok: failed.length === 0, checked: results.length, failed: failed.length, results, workDir: tmpRoot };
  881. }
  882. /** 合成一个最小的合法 PNG(用于自检,不引用任何真实素材)。 */
  883. export function makeTinyPng(width = 1, height = 1) {
  884. const crcTable = (() => {
  885. const table = new Int32Array(256);
  886. for (let n = 0; n < 256; n++) {
  887. let c = n;
  888. for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
  889. table[n] = c;
  890. }
  891. return table;
  892. })();
  893. const crc32 = (buf) => {
  894. let c = -1;
  895. for (const byte of buf) c = crcTable[(c ^ byte) & 0xff] ^ (c >>> 8);
  896. return (c ^ -1) >>> 0;
  897. };
  898. const chunk = (type, data) => {
  899. const length = Buffer.alloc(4);
  900. length.writeUInt32BE(data.length, 0);
  901. const typeBuf = Buffer.from(type, 'ascii');
  902. const crc = Buffer.alloc(4);
  903. crc.writeUInt32BE(crc32(Buffer.concat([typeBuf, data])), 0);
  904. return Buffer.concat([length, typeBuf, data, crc]);
  905. };
  906. const ihdr = Buffer.alloc(13);
  907. ihdr.writeUInt32BE(width, 0);
  908. ihdr.writeUInt32BE(height, 4);
  909. ihdr[8] = 8; // bit depth
  910. ihdr[9] = 6; // RGBA
  911. const raw = Buffer.alloc((width * 4 + 1) * height);
  912. return Buffer.concat([
  913. Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
  914. chunk('IHDR', ihdr),
  915. chunk('IDAT', zlib.deflateSync(raw)),
  916. chunk('IEND', Buffer.alloc(0)),
  917. ]);
  918. }
  919. // ---------------------------------------------------------------------------
  920. // CLI
  921. // ---------------------------------------------------------------------------
  922. function parseCli(argv) {
  923. const command = argv[0] && !argv[0].startsWith('-') ? argv[0] : 'ingest';
  924. const rest = command === argv[0] ? argv.slice(1) : argv;
  925. const { values, positionals } = parseArgs({
  926. args: rest,
  927. options: {
  928. input: { type: 'string', multiple: true, default: [] },
  929. files: { type: 'string', multiple: true, default: [] },
  930. image: { type: 'string', multiple: true, default: [] },
  931. doc: { type: 'string', multiple: true, default: [] },
  932. deck: { type: 'string', multiple: true, default: [] },
  933. video: { type: 'string', multiple: true, default: [] },
  934. 'out-dir': { type: 'string' },
  935. 'out': { type: 'string' },
  936. 'source-type': { type: 'string' },
  937. 'source-ref': { type: 'string' },
  938. authorization: { type: 'string', default: 'pending' },
  939. title: { type: 'string' },
  940. summary: { type: 'string' },
  941. 'target-customer': { type: 'string' },
  942. 'usage-suggestion': { type: 'string' },
  943. 'school-canonical': { type: 'string' },
  944. 'product-line': { type: 'string' },
  945. major: { type: 'string' },
  946. stage: { type: 'string' },
  947. country: { type: 'string' },
  948. highlight: { type: 'string', multiple: true, default: [] },
  949. scenario: { type: 'string', multiple: true, default: [] },
  950. objection: { type: 'string', multiple: true, default: [] },
  951. 'idempotency-key': { type: 'string' },
  952. 'asset-base-url': { type: 'string' },
  953. upload: { type: 'boolean', default: true },
  954. 'no-upload': { type: 'boolean', default: false },
  955. 'submitted-by': { type: 'string' },
  956. 'vision-model': { type: 'string' },
  957. 'skip-vision': { type: 'boolean', default: false },
  958. 'skip-video': { type: 'boolean', default: false },
  959. 'frame-count': { type: 'string' },
  960. diarize: { type: 'boolean', default: false },
  961. 'no-learn': { type: 'boolean', default: false },
  962. 'dict-path': { type: 'string' },
  963. package: { type: 'string' },
  964. 'material-assets': { type: 'string' },
  965. move: { type: 'string' },
  966. order: { type: 'string' },
  967. 'no-direct': { type: 'boolean', default: false },
  968. 'probe-direct': { type: 'boolean', default: false },
  969. add: { type: 'string', multiple: true, default: [] },
  970. alias: { type: 'string', multiple: true, default: [] },
  971. out: { type: 'string' },
  972. json: { type: 'boolean', default: false },
  973. help: { type: 'boolean', default: false, short: 'h' },
  974. },
  975. allowPositionals: true,
  976. });
  977. return { command, values, positionals };
  978. }
  979. function collectInputs(values, positionals) {
  980. return [
  981. ...values.input,
  982. ...values.files,
  983. ...values.image,
  984. ...values.doc,
  985. ...values.deck,
  986. ...values.video,
  987. ...positionals,
  988. ];
  989. }
  990. async function cliMain(argv) {
  991. const { command, values, positionals } = parseCli(argv);
  992. if (values.help) {
  993. process.stdout.write(HELP_TEXT);
  994. return 0;
  995. }
  996. if (command === 'selftest') {
  997. const report = await runSelftest({ dictPath: values['dict-path'], outDir: values['out-dir'] });
  998. process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
  999. return report.ok ? 0 : 1;
  1000. }
  1001. if (command === 'dictionary') {
  1002. const report = runDictionary({ add: values.add, alias: values.alias, dictPath: values['dict-path'] });
  1003. process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
  1004. return 0;
  1005. }
  1006. if (command === 'reorder') {
  1007. const report = runReorder({
  1008. materialAssets: values['material-assets'] || values.package,
  1009. move: values.move,
  1010. order: values.order,
  1011. });
  1012. process.stdout.write(`${JSON.stringify({ ok: true, file: report.file, order: report.materialAssets.map((a) => a.order) }, null, 2)}\n`);
  1013. return 0;
  1014. }
  1015. if (command === 'submit') {
  1016. if (!values.package) {
  1017. process.stderr.write('需要 --package <case-package.json>\n');
  1018. return 2;
  1019. }
  1020. const report = await runSubmit({
  1021. package: values.package,
  1022. submittedBy: values['submitted-by'],
  1023. direct: !values['no-direct'],
  1024. });
  1025. process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
  1026. return report.submitted ? 0 : 4;
  1027. }
  1028. if (command === 'probe' || values['probe-direct']) {
  1029. const report = await probeParse();
  1030. process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
  1031. return report.ok ? 0 : 4;
  1032. }
  1033. // plan / ingest
  1034. const outDir = values['out-dir'] || values.out
  1035. || path.join(outputsDir(), `${values['source-ref'] ? `${slug(values['source-ref'])}-` : ''}${newRunId()}`);
  1036. const options = {
  1037. inputs: collectInputs(values, positionals),
  1038. outDir,
  1039. sourceType: values['source-type'],
  1040. sourceRef: values['source-ref'],
  1041. authorizationStatus: values.authorization,
  1042. title: values.title,
  1043. summary: values.summary,
  1044. targetCustomer: values['target-customer'],
  1045. usageSuggestion: values['usage-suggestion'],
  1046. idempotencyKey: values['idempotency-key'],
  1047. assetBaseUrl: values['asset-base-url'] || process.env.CASE_ASSET_BASE_URL || '',
  1048. // 默认开:真正上传拿公网 URL;--no-upload 关闭(离线自测用),此时回落到 --asset-base-url 派生
  1049. upload: values['no-upload'] ? false : values.upload !== false,
  1050. submittedBy: values['submitted-by'],
  1051. visionModel: values['vision-model'],
  1052. skipVision: values['skip-vision'],
  1053. skipVideo: values['skip-video'],
  1054. frameCount: values['frame-count'] ? Number(values['frame-count']) : undefined,
  1055. diarize: values.diarize,
  1056. noLearn: values['no-learn'],
  1057. dictPath: values['dict-path'],
  1058. hints: {
  1059. schoolCanonical: values['school-canonical'],
  1060. productLine: values['product-line'],
  1061. major: values.major,
  1062. stage: values.stage,
  1063. country: values.country,
  1064. highlightTypes: values.highlight,
  1065. scenarioTags: values.scenario,
  1066. objectionTags: values.objection,
  1067. },
  1068. };
  1069. if (!options.inputs.length) {
  1070. process.stderr.write('至少需要一个输入(位置参数或 --input / --image / --doc / --deck / --video)\n');
  1071. return 2;
  1072. }
  1073. if (command === 'plan') {
  1074. const plan = await runPlan(options);
  1075. process.stdout.write(`${JSON.stringify({
  1076. ok: true,
  1077. command: 'plan',
  1078. runId: plan.runId,
  1079. sourceType: plan.sourceType,
  1080. counts: plan.counts,
  1081. groups: plan.groups.map((g) => ({ groupId: g.groupId, layout: g.layout, count: g.count, orderingRule: g.orderingRule })),
  1082. warnings: plan.warnings,
  1083. outDir,
  1084. }, null, 2)}\n`);
  1085. return 0;
  1086. }
  1087. const report = await runIngest(options);
  1088. const summary = {
  1089. ok: true,
  1090. command: 'ingest',
  1091. runId: report.casePackage.runId,
  1092. sourceType: report.casePackage.sourceType,
  1093. idempotencyKey: report.casePackage.idempotencyKey,
  1094. materialAssets: report.casePackage.materialAssets.length,
  1095. groups: report.casePackage.groupInfoList,
  1096. title: report.casePackage.title,
  1097. tags: {
  1098. schoolCanonical: report.casePackage.schoolCanonical,
  1099. schoolAliases: report.casePackage.schoolAliases,
  1100. country: report.casePackage.country,
  1101. major: report.casePackage.major,
  1102. stage: report.casePackage.stage,
  1103. subject: report.casePackage.subject,
  1104. highlightTypes: report.casePackage.highlightTypes,
  1105. scenarioTags: report.casePackage.scenarioTags,
  1106. objectionTags: report.casePackage.objectionTags,
  1107. fitStatus: report.casePackage.fitStatus,
  1108. },
  1109. compliance: {
  1110. riskFlags: report.casePackage.riskFlags,
  1111. privacyFindings: report.casePackage.privacyFindings.length,
  1112. blockers: report.casePackage.complianceBlockers,
  1113. canSubmit: report.canSubmit,
  1114. expected: report.casePackage.expected,
  1115. },
  1116. dictionaryChanges: report.dictionaryChanges,
  1117. warnings: uniq(report.warnings),
  1118. artifacts: report.artifacts,
  1119. };
  1120. process.stdout.write(`${JSON.stringify(summary, null, 2)}\n`);
  1121. return 0;
  1122. }
  1123. function slug(value) {
  1124. return String(value).replace(/[^\w.-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40);
  1125. }
  1126. const HELP_TEXT = `skill-case-get —— 案例采集 / 拆解 / 归档
  1127. 用法:
  1128. case-intake.mjs ingest [输入...] [选项] 拆解 + 打标,产出案例包(默认不提交)
  1129. case-intake.mjs plan [输入...] 只识别 + 提取 + 分组(不调模型)
  1130. case-intake.mjs submit --package <file> 提交到云函数 caseSubmit(待审清单)
  1131. case-intake.mjs reorder --material-assets <file> --move "3:1"
  1132. case-intake.mjs dictionary [--add highlightTypes=退费保障] [--alias "IC=帝国理工=英国"]
  1133. case-intake.mjs selftest
  1134. 输入(可混用,支持目录):
  1135. --image a.jpg --image b.jpg ... 连续多图 / 裸图 / 九宫格
  1136. --doc case.docx Word
  1137. --deck deck.pptx PPT
  1138. --video clip.mp4 视频(抽帧 + 转写)
  1139. --input <file|dir> 自动按扩展名分流
  1140. --files <file|dir> 同 --input(别名)
  1141. 采集选项:
  1142. --authorization authorized|pending|denied 授权状态(硬门:仅 authorized 可入库,默认 pending)
  1143. --source-ref <ref> 来源引用(不含 PII)
  1144. --source-type <t> image_group|document|presentation|mixed|single_image
  1145. --title / --summary / --target-customer / --usage-suggestion 覆盖自动抽取的案例字段
  1146. --school-canonical / --product-line / --major / --stage / --country
  1147. --highlight / --scenario / --objection 显式补标签(可重复)
  1148. --idempotency-key <key> 幂等键(默认按内容指纹生成)
  1149. --asset-base-url <base> 本地素材 URL 前缀(CDN / Parse Files / 对象存储目录)**回退用**
  1150. (或环境变量 CASE_ASSET_BASE_URL)
  1151. --upload 真正上传本地素材到对象存储,拿公网 URL(默认开)
  1152. --no-upload 关闭上传;url 只按 --asset-base-url 派生(离线自测用)
  1153. object key 规范:case-library/<YYYYMMDD>/<caseSlug>-<order>.<ext>
  1154. --out-dir <dir> 产物目录(默认 <cwd>/case-get-outputs/<runId>)
  1155. --out <dir> 同 --out-dir(别名)
  1156. --skip-vision / --skip-video / --no-learn 关闭图片理解 / 视频处理 / 字典自进化
  1157. --submitted-by <userId> 提交人
  1158. 提交选项:
  1159. --package <case-package.json> ingest 产出的案例包
  1160. --submitted-by <userId>
  1161. 产物(--out-dir):
  1162. extracted.json 文档文本 + 内嵌媒体
  1163. group-plan.json 分组与九宫格顺序
  1164. vision.json 每张图的 OCR / 描述 / 使用建议
  1165. material-assets.json materialAssets[](可编辑顺序)
  1166. case-package.json 云函数 caseSubmit 入参(逐字对齐共享契约)
  1167. report.md 人类可读报告
  1168. `;
  1169. const invokedDirectly = process.argv[1] && path.resolve(process.argv[1]).endsWith(path.join('scripts', 'case-intake.mjs'));
  1170. if (invokedDirectly) {
  1171. cliMain(process.argv.slice(2))
  1172. .then((code) => process.exit(code))
  1173. .catch((error) => {
  1174. process.stderr.write(`skill-case-get 失败:${error.message}\n`);
  1175. process.exit(1);
  1176. });
  1177. }
  1178. export { cliMain };