#!/usr/bin/env node // Copyright (c) 未来飞马 // // This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. // // Trademark Notice: // The MPL-2.0 license grants copyright permissions for source code only. // It does NOT grant any rights to use trademarks including "未来飞马", // "Harness Loop", "RSI", and associated slogan "让AI进化提前发生,让AI落地快人一步". // Any use of these trademarks requires separate written permission. /** * skill-case-get —— 案例采集 / 拆解 / 归档 主入口 * * 管线: * ① 输入识别:连续多图 / .docx / .pptx / 图文混合 / 裸图 / 视频 * ② 拆解提取:图片→skill-vision(OCR+语义);docx/pptx→python3 标准库解包;视频→抽帧+skill-listen 转写 * ③ 分组与顺序:尺寸/时间/内容相似度判同组;九宫格按左上→右下给显式 order * ④ 案例信息 vs 素材 分离 * ⑤ 合规判断:能否入库?客诉/负面?→ riskFlags / privacyFindings * ⑥ 打标:静态属性 + 跟进状态七段 + 亮点/场景/异议 + 学校别名归一 * ⑦ 入库:caseSubmit 写待审清单(reviewStatus=pending, readyForUse=false),带幂等键 * ⑧ 字典自进化:新标签追加进 references/tag-dictionary.json * * 子命令: * plan 只识别 + 提取 + 分组(不调任何模型,纯本机) * ingest plan + 图片理解 + 分类打标 → 产出 case-package.json(默认不提交) * submit 读取 case-package.json(可先 reorder)→ 调云函数 caseSubmit * reorder 调整 materialAssets 顺序(支持数组 + 顺序调整) * dictionary 查看 / 追加标签字典 * selftest 本机自检(office 解包 + 分组 + 打标 + 合规) * * 安全:绝不打印 token/PII;生成的产物默认落在 /case-get-outputs/。 */ import fs from 'node:fs'; import path from 'node:path'; import zlib from 'node:zlib'; import { spawnSync } from 'node:child_process'; import { parseArgs } from 'node:util'; import { TENANT_DEFAULT, SKILL_ROOT, buildIdempotencyKey, deriveAssetUrl, ensureDir, firstText, imageUrlsFrom, learnAliases, learnTags, loadDictionary, newRunId, normalizeMaterialAsset, outputsDir, readJson, reorderAssets, resolvePython, saveDictionary, sortAssets, truncate, uniq, writeJson, } from './lib.mjs'; import { readImageInfo, IMAGE_EXTENSIONS, VIDEO_EXTENSIONS, DOC_EXTENSIONS, DECK_EXTENSIONS, TEXT_EXTENSIONS } from './image-size.mjs'; import { applyGroupOverrides, groupMaterials, DEFAULT_ORDERING_RULE } from './grouping.mjs'; import { classifyCase } from './classify.mjs'; import { processVideo } from './listen-bridge.mjs'; import { analyzeImages } from './vision-bridge.mjs'; import { uploadMaterial, StorageUnavailableError } from './storage-bridge.mjs'; import { CloudError, submitCase } from './cloud-client.mjs'; import { submitViaParse, probeParse } from './parse-direct.mjs'; // --------------------------------------------------------------------------- // 输入识别 // --------------------------------------------------------------------------- export function detectSourceType({ images = 0, documents = 0, decks = 0, videos = 0, texts = 0 }) { const kinds = [images > 0, documents > 0, decks > 0, videos > 0, texts > 0].filter(Boolean).length; if (kinds > 1) return 'mixed'; if (images > 1) return 'image_group'; if (images === 1 && videos === 0) return 'single_image'; if (videos > 0) return 'image_group'; // 视频素材组,按 group 处理 if (decks > 0) return 'presentation'; if (documents > 0) return 'document'; return 'mixed'; } export function classifyInput(filePath) { const ext = path.extname(filePath).toLowerCase(); if (IMAGE_EXTENSIONS.has(ext)) return 'image'; if (VIDEO_EXTENSIONS.has(ext)) return 'video'; if (DOC_EXTENSIONS.has(ext)) return 'docx'; if (DECK_EXTENSIONS.has(ext)) return 'pptx'; if (TEXT_EXTENSIONS.has(ext)) return 'text'; return 'unknown'; } /** 展开目录(只展开一层里的文件,不递归太深,避免误扫)。 */ export function expandInputs(inputs, maxDepth = 2) { const out = []; const walk = (target, depth) => { let stat; try { stat = fs.statSync(target); } catch { out.push({ path: target, exists: false }); return; } if (stat.isDirectory()) { if (depth > maxDepth) return; for (const entry of fs.readdirSync(target).sort()) { if (entry.startsWith('.')) continue; walk(path.join(target, entry), depth + 1); } return; } out.push({ path: target, exists: true, size: stat.size, mtimeMs: stat.mtimeMs }); }; for (const input of inputs) walk(input, 0); return out; } // --------------------------------------------------------------------------- // ② 拆解提取 // --------------------------------------------------------------------------- function runOfficeExtract(files, mediaOut) { if (!files.length) return { available: true, results: [], error: null }; const script = path.join(SKILL_ROOT, 'scripts', 'office_extract.py'); if (!fs.existsSync(script)) return { available: false, results: [], error: `缺少 ${script}` }; const python = resolvePython(); const args = [script, '--media-out', mediaOut]; for (const file of files) args.push('--input', file); const run = spawnSync(python, args, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }); if (run.error) { return { available: false, results: [], error: `${python} 启动失败:${run.error.message}(.docx/.pptx 解包需要 python3 标准库)` }; } let parsed = null; try { parsed = JSON.parse(run.stdout || '{}'); } catch { return { available: true, results: [], error: `office_extract 输出不是 JSON:${truncate(run.stderr || run.stdout, 300)}` }; } return { available: true, results: parsed.results || [], error: parsed.ok ? null : `部分文档解包失败:${(parsed.results || []).filter((r) => !r.ok).map((r) => r.error).join(', ')}`, }; } function sleep(ms) { return new Promise((resolve) => { setTimeout(resolve, ms); }); } // --------------------------------------------------------------------------- // plan:识别 + 提取 + 分组 // --------------------------------------------------------------------------- export async function runPlan(options = {}) { const outDir = ensureDir(options.outDir); const mediaDir = ensureDir(path.join(outDir, 'media')); const entries = expandInputs(options.inputs || []); const missing = entries.filter((e) => !e.exists).map((e) => e.path); const buckets = { image: [], video: [], docx: [], pptx: [], text: [], unknown: [] }; for (const entry of entries) { if (!entry.exists) continue; buckets[classifyInput(entry.path)].push(entry.path); } const warnings = []; for (const file of missing) warnings.push(`输入不存在:${file}`); if (buckets.unknown.length) warnings.push(`忽略无法识别的文件:${buckets.unknown.join(', ')}`); // ---- docx / pptx 解包 const docxResults = runOfficeExtract(buckets.docx, path.join(mediaDir, 'docx')); const pptxResults = runOfficeExtract(buckets.pptx, path.join(mediaDir, 'pptx')); if (docxResults.error) warnings.push(docxResults.error); if (pptxResults.error) warnings.push(pptxResults.error); const textBundle = { paragraphs: [], notes: [], titles: [], transcript: '' }; const mediaFromDocs = []; for (const result of [...docxResults.results, ...pptxResults.results]) { if (!result.ok) { warnings.push(`${result.fileName || result.input} 解包失败:${result.error}`); continue; } for (const p of result.paragraphs || []) if (p.text) textBundle.paragraphs.push(p.text); for (const h of result.headings || []) if (h) textBundle.titles.push(h); for (const note of result.notes || []) { if (typeof note === 'string') textBundle.notes.push(note); else if (note && note.text) textBundle.notes.push(note.text); } for (const slide of result.slides || []) if (slide.title) textBundle.titles.push(slide.title); for (const media of result.media || []) { if (media.kind === 'image' && media.localPath) mediaFromDocs.push({ ...media, sourceDoc: result.fileName || result.input }); } } // ---- 纯文本输入(.md/.txt):并入说明语料 for (const file of buckets.text) { try { const text = fs.readFileSync(file, 'utf8'); textBundle.paragraphs.push(...text.split(/\r?\n/).map((l) => l.trim()).filter(Boolean)); } catch (error) { warnings.push(`文本读取失败 ${file}:${error.message}`); } } // ---- 图片:读尺寸/时间 const imageItems = []; for (const file of buckets.image) { const info = readImageInfo(file); if (!info.ok) warnings.push(`图片信息读取失败 ${path.basename(file)}:${info.error}`); imageItems.push({ kind: 'image', localPath: path.resolve(file), width: info.width || null, height: info.height || null, mtimeMs: info.mtimeMs || fs.statSync(file).mtimeMs, ocrText: '', description: '', label: path.basename(file), origin: 'direct', }); } // 文档内嵌图片也参与分组(图文混合)。sourceKey 钉住来源文档, // 避免两份不同文档的配图因为同比例、同落盘时间被误并成一组。 for (const media of mediaFromDocs) { const info = readImageInfo(media.localPath); imageItems.push({ kind: 'image', localPath: media.localPath, width: info.width || null, height: info.height || null, mtimeMs: info.mtimeMs || null, ocrText: '', description: media.nearbyText || '', label: `${media.sourceDoc || '文档'} 内嵌图`, origin: 'document-media', sourceKey: media.sourceDoc || '', slide: media.slide, }); } // ---- 视频:抽帧 + 转写 const videoReports = []; for (const file of buckets.video) { if (options.skipVideo) { warnings.push(`跳过视频(--skip-video):${path.basename(file)}`); continue; } const report = await processVideo(file, { workDir: path.join(mediaDir, 'video', path.basename(file, path.extname(file))), frameCount: options.frameCount || 6, diarize: Boolean(options.diarize), }); videoReports.push(report); for (const warning of report.warnings) warnings.push(`${path.basename(file)}:${warning}`); if (report.transcription && report.transcription.text) { textBundle.transcript = [textBundle.transcript, report.transcription.text].filter(Boolean).join('\n'); } for (const frame of report.frames || []) { imageItems.push({ kind: 'image', localPath: frame.localPath, width: null, height: null, mtimeMs: null, ocrText: '', description: `视频抽帧 @${frame.atMs != null ? `${Math.round(frame.atMs / 1000)}s` : '开头'}`, label: `${path.basename(file)} 抽帧`, origin: 'video-frame', atMs: frame.atMs, videoPath: path.resolve(file), }); } } // ---- 分组(尺寸 / 时间 / 内容) let groups = groupMaterials(imageItems, options.grouping || {}); groups = applyGroupOverrides(groups, options.groupOverrides); const sourceType = options.sourceType || detectSourceType({ images: buckets.image.length, documents: buckets.docx.length + buckets.text.length, decks: buckets.pptx.length, videos: buckets.video.length, }); const plan = { runId: options.runId || newRunId(), generatedAt: new Date().toISOString(), tenantId: options.tenantId || TENANT_DEFAULT, sourceType, sourceRef: options.sourceRef || '', authorizationStatus: String(options.authorizationStatus || '').toLowerCase(), inputs: { images: buckets.image.map((p) => path.resolve(p)), documents: buckets.docx.map((p) => path.resolve(p)), decks: buckets.pptx.map((p) => path.resolve(p)), videos: buckets.video.map((p) => path.resolve(p)), texts: buckets.text.map((p) => path.resolve(p)), }, counts: { imageCount: imageItems.length, documentCount: buckets.docx.length, deckCount: buckets.pptx.length, videoCount: buckets.video.length, groupCount: groups.length, }, textBundle, groups, videoReports, warnings, // 生成物绝对路径(后续 vision / submit 直接引用) artifacts: { extracted: path.join(outDir, 'extracted.json'), groupPlan: path.join(outDir, 'group-plan.json'), }, }; if (!groups.length) plan.warnings.push('没有识别到任何可用素材(没有任何图片/视频/文档内嵌图)'); writeJson(plan.artifacts.extracted, { textBundle, mediaFromDocs, videoReports }); writeJson(plan.artifacts.groupPlan, { sourceType, groups }); return plan; } // --------------------------------------------------------------------------- // ingest:plan + 图片理解 + 分类打标 → 案例包 // --------------------------------------------------------------------------- /** * 解析单个素材的公网 URL(materialAssets[].url 的唯一来源)。 * * 优先级(任务书硬要求): * ① item.url 已存在(文档自带的远程 URL)→ 原样用,**不触发上传**; * ② 本地文件且 upload 开 → 真上传(fmode-storage 第 0 级免密钥通道)拿公网 URL; * ③ --asset-base-url 派生(保留旧行为,显式回退); * ④ 都没有 → 空字符串(由调用方补 warning)。 * * 本函数**不抛错、不打印**:上传失败以 `error` 返回,由调用方降级并汇总告警, * 保证「单张失败不拖垮整个 ingest」。 * * @returns {Promise<{url:string, upload:object|null, fallback:boolean, error:Error|null, reason:string}>} */ export async function resolveAssetUrl(item, options = {}) { const remote = item && item.url ? String(item.url) : ''; if (remote) return { url: remote, upload: null, fallback: false, error: null, reason: 'remote-url' }; const localPath = (item && item.localPath) || ''; let uploadError = null; if (localPath && options.upload) { try { const upload = await uploadMaterial(localPath, { tenantId: options.tenantId, // slug 区分度由 storage-bridge 统一兜底(slugify 为空时用内容指纹),这里原样传 caseSlug: options.caseSlug, order: options.order, }); if (upload && upload.url) { return { url: upload.url, upload, fallback: false, error: null, reason: 'uploaded' }; } uploadError = new Error('上传回执未给出公网 URL'); } catch (error) { // 上传失败:**记下原因**并继续走回退链,绝不吞掉(调用方要据此告警) uploadError = error; } } const derived = deriveAssetUrl(localPath, options.assetBaseUrl); if (derived) return { url: derived, upload: null, fallback: true, error: uploadError, reason: 'derived' }; return { url: '', upload: null, fallback: false, error: uploadError, reason: localPath ? 'no-url' : 'no-source', }; } export async function runIngest(options = {}) { const plan = await runPlan(options); const outDir = options.outDir; const warnings = [...plan.warnings]; // 图片理解(OCR + 语义)——没有图片时跳过 const allImages = plan.groups.flatMap((g) => g.items.filter((i) => i.kind === 'image')); let vision = { available: true, results: [], error: null, needsHostRead: false }; if (options.skipVision) { warnings.push('跳过图片理解(--skip-vision):素材只有文件名,ocrText/description 为空'); } else if (allImages.length) { const batchHint = [ plan.textBundle.titles.slice(0, 3).join(' / '), plan.textBundle.paragraphs.slice(0, 3).join(' / '), ].filter(Boolean).join(' | '); vision = await analyzeImages( allImages.map((item) => ({ localPath: item.localPath, url: item.url, label: item.label })), { model: options.visionModel, batchHint }, ); if (!vision.available) { warnings.push(vision.error); } else if (vision.needsHostRead) { warnings.push('宿主多模态模式:vision 返回了读图指令(instruction),请宿主 Agent 用 Read 工具读图后把 JSON 结果写入 vision-host.json,再执行 submit。'); } else if (vision.error) { warnings.push(vision.error); } } // 把 vision 结果并回素材 const visionByFile = new Map(); for (const result of vision.results || []) { if (result.file) visionByFile.set(path.resolve(result.file), result); } const enrichedGroups = plan.groups.map((group) => ({ ...group, items: group.items.map((item) => { const enriched = { ...item }; if (item.kind === 'image' && item.localPath) { const found = visionByFile.get(path.resolve(item.localPath)); if (found) { enriched.ocrText = found.ocrText || enriched.ocrText || ''; enriched.description = found.description || enriched.description || ''; enriched.usageSuggestion = found.usageSuggestion || ''; enriched.label = found.label || enriched.label || ''; // 分析未产出结论(宿主读图待补 / 分析失败)时保持默认 role='material', // 不能凭空把素材降级成说明配图。 enriched.role = found.role || 'material'; enriched.piiHints = found.piiHints || []; enriched.visionPending = Boolean(found.needsHostRead); } else { enriched.role = enriched.role || 'material'; } } return enriched; }), })); // 案例信息 vs 素材:分离后分类打标 const dict = loadDictionary(options.dictPath); const visionResults = (vision.results || []).map((r) => ({ ...r })); const classification = classifyCase({ dict, groups: enrichedGroups, visionResults, textBundle: plan.textBundle, authorizationStatus: plan.authorizationStatus, hints: options.hints || {}, }); // ⑧ 字典自进化 const learn = learnTags(dict, classification.learned.tags); const aliasLearn = learnAliases(dict, classification.learned.aliases); const dictionaryChanges = { tags: learn.added, aliases: aliasLearn.added, skipped: learn.skipped }; const dictionaryFile = options.dictPath ? path.resolve(options.dictPath) : undefined; if ((learn.added.length || aliasLearn.added.length) && !options.noLearn) { saveDictionary(dict); } else if (options.noLearn && (learn.added.length || aliasLearn.added.length)) { warnings.push('字典自进化被 --no-learn 关闭:本次新增标签未落盘'); dictionaryChanges.tags = []; dictionaryChanges.aliases = []; } if (dictionaryFile) writeJson(dictionaryFile, readJson(dictionaryFile, {})); // ---- 组装 materialAssets[](逐字对齐 SHARED-CONTRACT.md) const materialAssets = []; const groupInfoList = []; const assetProblems = []; let missingUrlCount = 0; let uploadFailedCount = 0; let storageUnavailable = false; const fallbackItems = []; // 上传真正发生才需要 caseSlug;全走本地文件时没有标题也不影响。 const caseSlugSeed = firstText(options.sourceRef, options.title); for (const group of enrichedGroups) { groupInfoList.push({ groupId: group.groupId, layout: group.layout, count: group.count, orderingRule: group.orderingRule, }); for (const item of sortAssets(group.items)) { // URL 优先级:① 文档自带远程 url → ② --upload 真上传 → ③ --asset-base-url 派生 → ④ 留空 + warning const resolved = await resolveAssetUrl(item, { // 默认开(与 CLI 一致):只有显式 upload:false / --no-upload 才关 upload: options.upload !== false, tenantId: plan.tenantId, // 这里**不要**用 'case' 兜底:留空让 resolveAssetUrl 用文件内容指纹保证 key 区分度 caseSlug: item.slug || caseSlugSeed || '', order: materialAssets.length + 1, assetBaseUrl: options.assetBaseUrl, }); const assetUrl = resolved.url; const upload = resolved.upload; const uploadError = resolved.error; const usedFallback = resolved.fallback; if (uploadError) { // 上传失败绝不能拖垮整个 ingest:单张降级、明确告警,其余素材照常 uploadFailedCount += 1; if (uploadError instanceof StorageUnavailableError) storageUnavailable = true; const reason = uploadError && uploadError.message ? uploadError.message : String(uploadError); warnings.push(`上传失败(${path.basename(item.localPath)}):${reason};该素材将无公网 URL`); } if (!assetUrl && item.localPath) { missingUrlCount += 1; if (!uploadError) fallbackItems.push(path.basename(item.localPath)); } const { asset, problems } = normalizeMaterialAsset({ order: item.order, groupId: group.groupId, kind: item.kind, url: assetUrl, localPath: item.localPath || '', label: item.label || '', ocrText: item.ocrText || '', description: item.description || '', usageSuggestion: item.usageSuggestion || '', role: item.role || 'material', }, materialAssets.length); assetProblems.push(...problems); materialAssets.push({ ...asset, ...(upload ? { uploadKey: upload.key, uploadBytes: upload.bytes, uploadVia: 'cloud-function' } : {}), isFallbackUrl: usedFallback || undefined, }); } } if (uploadFailedCount) { warnings.push(`${uploadFailedCount} 张上传失败:这些素材的 url 已降级(本地派生前缀/留空),检索侧拿不到它们的图。` + (storageUnavailable ? '(fmode-storage 不可用:安装兄弟技能或改用 --no-upload + --asset-base-url)' : '')); } for (const problem of assetProblems) warnings.push(problem); if (missingUrlCount) { const detail = options.upload === false ? '(--no-upload 已关闭上传;可改用 --asset-base-url 派生)' : `已尝试上传但未拿到公网地址(${uniq(fallbackItems).slice(0, 5).join('、') || '见上方上传失败原因'})。请确认 fmode-storage 可用(免密钥通道)或改用 --asset-base-url 派生(检索侧只看 url)`; warnings.push(`${missingUrlCount} 个素材只有本地路径、没有 URL:${detail}。`); } // 全局重编号:materialAssets 已按「分组顺序 → 组内 order」构建, // 这里只顺次改写 order。**不能**再按 order 全局排序——各组的 order 都从 1 起, // 直接排会把四组的第一张图并到最前面。 const orderedAssets = materialAssets.map((asset, index) => ({ ...asset, order: index + 1 })); const imageUrls = imageUrlsFrom(orderedAssets); // ---- 组装案例包(幂等键:tenantId + 内容指纹) const title = firstText(options.title, classification.caseFields.title); const idempotencyKey = buildIdempotencyKey({ tenantId: plan.tenantId, sourceType: plan.sourceType, sourceRef: plan.sourceRef, title, materialAssets: orderedAssets, explicit: options.idempotencyKey, }); const primaryGroup = groupInfoList[0] || { groupId: 'g1', layout: '1x1', count: 0, orderingRule: DEFAULT_ORDERING_RULE }; const casePackage = { schemaVersion: '1.0.0', generatedAt: new Date().toISOString(), runId: plan.runId, contract: 'SHARED-CONTRACT.md', // ---- 云函数 caseSubmit 入参(逐字对齐) sourceType: plan.sourceType, sourceRef: plan.sourceRef, idempotencyKey, title, summary: firstText(options.summary, classification.caseFields.summary), materialAssets: orderedAssets, groupInfo: primaryGroup, groupInfoList, schoolCanonical: (classification.tags.schoolCanonical || [])[0] || '', tags: uniq([ ...(classification.tags.country || []), ...(classification.tags.schoolCanonical || []), ...(classification.tags.major || []), ...(classification.tags.stage || []), ...(classification.tags.subject || []), ...(classification.tags.highlightTypes || []), ...(classification.tags.scenarioTags || []), ...(classification.tags.objectionTags || []), ...(classification.fit.fitStatus || []), ...(classification.tags.productLine || []), ]), highlightTypes: classification.tags.highlightTypes || [], scenarioTags: classification.tags.scenarioTags || [], objectionTags: classification.tags.objectionTags || [], riskFlags: classification.compliance.riskFlags || [], authorizationStatus: plan.authorizationStatus || '', // ---- 案例字段(案例说明,与素材分开) targetCustomer: firstText(options.targetCustomer, classification.caseFields.targetCustomer), usageSuggestion: firstText(options.usageSuggestion, classification.caseFields.usageSuggestion), resultEvidence: classification.caseFields.resultEvidence, outcome: classification.caseFields.outcome, country: (classification.tags.country || [])[0] || '', major: (classification.tags.major || [])[0] || '', stage: (classification.tags.stage || [])[0] || '', subject: (classification.tags.subject || [])[0] || '', fitStatus: classification.fit.fitStatus || [], schoolAliases: classification.tags.schoolAliases || [], imageUrls, hasImage: imageUrls.length > 0, imageText: truncate(orderedAssets.map((a) => a.ocrText).filter(Boolean).join(' '), 400), privacyFindings: classification.compliance.privacyFindings || [], complianceBlockers: classification.compliance.complianceBlockers || [], reviewHint: classification.compliance.reviewHint, summaryLine: casePackageSummaryLine(classification, orderedAssets.length), submittedBy: options.submittedBy || '', // ---- 落库后由服务端决定的字段(此处只声明预期值,供 dry-run 展示) expected: { reviewStatus: 'pending', readyForUse: false }, }; // ---- 产出物 const artifacts = { extracted: plan.artifacts.extracted, groupPlan: plan.artifacts.groupPlan, vision: path.join(outDir, 'vision.json'), materialAssets: path.join(outDir, 'material-assets.json'), casePackage: path.join(outDir, 'case-package.json'), report: path.join(outDir, 'report.md'), }; writeJson(artifacts.vision, vision); writeJson(artifacts.materialAssets, { groupInfoList, materialAssets: orderedAssets }); writeJson(artifacts.casePackage, casePackage); fs.writeFileSync(artifacts.report, renderReport({ plan, casePackage, vision, dictionaryChanges, warnings }), 'utf8'); const blockers = casePackage.complianceBlockers || []; const canSubmit = plan.authorizationStatus === 'authorized' && blockers.length === 0 && orderedAssets.length > 0; return { plan, casePackage, vision, dictionaryChanges, warnings, artifacts, canSubmit }; } function casePackageSummaryLine(classification, assetCount) { const fits = (classification.fit.fitStatus || []).join('/') || '未命中'; const risks = (classification.compliance.riskFlags || []).join('/') || '无'; return `${assetCount} 个素材;适用跟进状态:${fits};风险:${risks}`; } // --------------------------------------------------------------------------- // 报告 // --------------------------------------------------------------------------- export function renderReport({ plan, casePackage, vision, dictionaryChanges, warnings }) { const lines = []; lines.push(`# 案例采集报告 · ${casePackage.runId}`); lines.push(''); lines.push(`- 生成时间:${casePackage.generatedAt}`); lines.push(`- 来源类型:\`${casePackage.sourceType}\``); lines.push(`- 授权状态:\`${casePackage.authorizationStatus || '缺失'}\``); lines.push(`- 幂等键:\`${casePackage.idempotencyKey}\``); lines.push(`- 素材数:${casePackage.materialAssets.length}(分组 ${casePackage.groupInfoList.length} 组)`); lines.push(`- 图片理解:${vision.available ? `已启用(${vision.source}${vision.needsHostRead ? ',待宿主读图' : ''})` : '不可用'}`); lines.push(''); lines.push('## 素材顺序(materialAssets)'); lines.push(''); lines.push('| order | groupId | kind | role | 标签 | 说明 |'); lines.push('| --- | --- | --- | --- | --- | --- |'); for (const asset of casePackage.materialAssets) { lines.push(`| ${asset.order} | ${asset.groupId} | ${asset.kind} | ${asset.role} | ${truncate(asset.label, 20)} | ${truncate(asset.description || asset.ocrText, 60)} |`); } lines.push(''); lines.push('## 分组(九宫格阅读顺序:左上 → 右下)'); lines.push(''); for (const group of casePackage.groupInfoList) { lines.push(`- \`${group.groupId}\` ${group.layout} · ${group.count} 张 · \`${group.orderingRule}\``); } lines.push(''); lines.push('## 标签'); lines.push(''); lines.push(`- 学校(归一):${casePackage.schoolCanonical || '未识别'}${casePackage.schoolAliases.length ? `(命中别名:${casePackage.schoolAliases.join('、')})` : ''}`); lines.push(`- 国家:${casePackage.country || '未识别'} · 专业:${casePackage.major || '未识别'} · 阶段:${casePackage.stage || '未识别'} · 科目:${casePackage.subject || '未识别'}`); lines.push(`- 亮点:${casePackage.highlightTypes.join('、') || '无'}`); lines.push(`- 场景:${casePackage.scenarioTags.join('、') || '无'}`); lines.push(`- 异议:${casePackage.objectionTags.join('、') || '无'}`); lines.push(`- 适用跟进状态:${(casePackage.fitStatus || []).join('、') || '未命中'}`); lines.push(''); lines.push('## 合规判断'); lines.push(''); lines.push(`- riskFlags:${casePackage.riskFlags.join('、') || '无'}`); lines.push(`- privacyFindings:${casePackage.privacyFindings.length} 条(仅记录,本期不打码)`); for (const finding of casePackage.privacyFindings) { lines.push(` - ${finding.field} / ${finding.rule}:\`${finding.snippet}\``); } lines.push(`- 结论:${casePackage.complianceBlockers.length ? '不入库' : '可入库待审'}`); for (const blocker of casePackage.complianceBlockers) lines.push(` - ⛔ ${blocker}`); lines.push(`- 审核提示:${casePackage.reviewHint}`); lines.push(''); lines.push('## 字典自进化'); lines.push(''); lines.push(`- 新增标签:${dictionaryChanges.tags.length ? dictionaryChanges.tags.map((t) => `${t.dimension}=${t.value}`).join('、') : '无'}`); lines.push(`- 新增别名:${dictionaryChanges.aliases.length ? dictionaryChanges.aliases.map((a) => `${a.aliasText}→${a.canonicalName}`).join('、') : '无'}`); lines.push(''); if (warnings.length) { lines.push('## 警告'); lines.push(''); for (const warning of uniq(warnings)) lines.push(`- ⚠️ ${warning}`); lines.push(''); } lines.push('## 下一步'); lines.push(''); lines.push('1. 打开 `material-assets.json` 核对顺序(九宫格从左到右、从上到下),需要调整用 `reorder`;'); lines.push('2. 确认无误后执行 `submit --package case-package.json`,落到待审清单(reviewStatus=pending,不进公共素材库);'); lines.push('3. 由负责人用 `caseApprove` 审核放行后才会 `readyForUse=true`、进检索结果。'); return `${lines.join('\n')}\n`; } // --------------------------------------------------------------------------- // submit // --------------------------------------------------------------------------- export async function runSubmit(options = {}) { const packagePath = path.resolve(options.package); const casePackage = readJson(packagePath, null); if (!casePackage) throw new Error(`读不到案例包:${packagePath}`); const blockers = casePackage.complianceBlockers || []; if (String(casePackage.authorizationStatus || '').toLowerCase() !== 'authorized') { return { submitted: false, reason: `authorizationStatus=${casePackage.authorizationStatus || '缺失'},仅 authorized 才可入库(硬规则:无授权不入库)`, blockers: ['UNAUTHORIZED'], }; } if (blockers.length) { return { submitted: false, reason: 'complianceBlockers 非空,禁止入库', blockers }; } if (!casePackage.materialAssets || !casePackage.materialAssets.length) { return { submitted: false, reason: 'materialAssets 为空,无法构成案例', blockers: ['NO_MATERIAL'] }; } const useDirect = options.direct !== false; // 默认允许回落到 Parse 直写 try { const response = await submitCase(casePackage, { submittedBy: options.submittedBy, token: options.token }); if (!response.ok) { // 云函数存在但业务报错(如未授权 403)→ 不回落直写,如实返回 // 端点不提供函数服务(404/400 HTML,或网络不可达)→ 回落 Parse 直写 if (useDirect && (response.endpointUnavailable || response.error.code === 'NETWORK_UNAVAILABLE' || /404|不可达|超时/.test(response.error.message || ''))) { const direct = await submitViaParse(casePackage, { submittedBy: options.submittedBy }); return { submitted: true, ...direct, fallback: 'cloud-unavailable -> parse-direct' }; } return { submitted: false, reason: `caseSubmit 返回错误:${response.error.code} ${response.error.message}`, error: response.error }; } return { submitted: true, objectId: response.result.objectId, reviewStatus: response.result.reviewStatus || 'pending', readyForUse: response.result.readyForUse === true, via: 'cloud-function', }; } catch (error) { if (error instanceof CloudError) { // 客户自有云未挂载函数运行时 → 走同契约的 Parse 直写归档(仍只进待审清单) if (useDirect) { try { const direct = await submitViaParse(casePackage, { submittedBy: options.submittedBy }); return { submitted: true, ...direct, fallback: 'cloud-unavailable -> parse-direct' }; } catch (directErr) { return { submitted: false, reason: `云函数不可达,Parse 直写也失败:${directErr.message}`, offline: true }; } } return { submitted: false, reason: error.message, offline: true, hint: '未提交成功(网络不可达/未登录)。案例包已保存在本地,可直接重跑 submit;幂等键保证重复提交只产生一条。', }; } throw error; } } // --------------------------------------------------------------------------- // reorder // --------------------------------------------------------------------------- export function runReorder(options = {}) { const assetsPath = path.resolve(options.materialAssets || options.package); const bundle = readJson(assetsPath, null); if (!bundle) throw new Error(`读不到素材文件:${assetsPath}`); const list = Array.isArray(bundle) ? bundle : bundle.materialAssets; if (!Array.isArray(list)) throw new Error('素材文件里没有 materialAssets 数组'); let next; if (options.move) { const [from, to] = String(options.move).split(/[:>,-]/).map((v) => Number(v.trim())); if (!Number.isFinite(from) || !Number.isFinite(to)) throw new Error('--move 格式应为 from:to,如 3:1'); next = reorderAssets(list, from, to); } else if (options.order) { // --order "3,1,2" 表示「新顺序 = 原来第 3 项、第 1 项、第 2 项」(按位置,不是按 order 值) const positions = String(options.order).split(/[,\s]+/).map((v) => Number(v.trim())).filter(Number.isFinite); if (positions.length !== list.length) throw new Error(`--order 需要 ${list.length} 个位置序号,收到 ${positions.length} 个`); const byPosition = sortAssets(list); next = positions.map((position, index) => { const picked = byPosition[position - 1]; if (!picked) throw new Error(`--order 位置越界:${position}(共 ${byPosition.length} 项)`); return { ...picked, order: index + 1 }; }); } else { throw new Error('需要 --move "3:1" 或 --order "3,1,2"'); } const out = Array.isArray(bundle) ? next : { ...bundle, materialAssets: next, reorderedAt: new Date().toISOString() }; writeJson(assetsPath, out); return { file: assetsPath, materialAssets: next }; } // --------------------------------------------------------------------------- // 字典 // --------------------------------------------------------------------------- export function runDictionary(options = {}) { const dict = loadDictionary(options.dictPath); if (options.add) { const items = []; for (const raw of options.add) { const [dimension, value] = String(raw).split('='); items.push({ dimension: (dimension || '').trim(), value: (value || '').trim() }); } const result = learnTags(dict, items); if (result.added.length) saveDictionary(dict); return { added: result.added, skipped: result.skipped, file: dict.__file }; } if (options.alias) { const entries = []; for (const raw of options.alias) { const [aliasText, canonicalName, country] = String(raw).split('='); entries.push({ aliasText: (aliasText || '').trim(), canonicalName: (canonicalName || '').trim(), country: (country || '').trim() }); } const result = learnAliases(dict, entries); if (result.added.length) saveDictionary(dict); return { added: result.added, file: dict.__file }; } return { file: dict.__file, dimensions: Object.entries(dict.dimensions || {}).map(([key, dim]) => ({ dimension: key, label: dim.label, count: Array.isArray(dim.values) ? dim.values.length : Array.isArray(dim.entries) ? dim.entries.length : 0, values: dim.values || undefined, })), learnedCount: (dict.__learned || []).length, }; } // --------------------------------------------------------------------------- // selftest // --------------------------------------------------------------------------- export async function runSelftest(options = {}) { const results = []; const python = resolvePython(); // ① office 解包(python 标准库) const officeScript = path.join(SKILL_ROOT, 'scripts', 'office_extract.py'); const officeRun = spawnSync(python, [officeScript, '--selftest'], { encoding: 'utf8' }); results.push({ name: 'office_extract(docx/pptx 标准库解包)', ok: officeRun.status === 0, detail: truncate(officeRun.stdout || officeRun.stderr || '', 400), }); // ② 分组 / 打标 / 合规:离线合成样例 const tmpRoot = ensureDir(path.join(outputsDir(options.outDir), 'selftest', newRunId())); const pngPath = path.join(tmpRoot, 'grid-01.png'); fs.writeFileSync(pngPath, makeTinyPng(400, 400)); const gridItems = [0, 1, 2, 3, 4, 5, 6, 7, 8].map((i) => ({ kind: 'image', localPath: path.join(tmpRoot, `grid-${String(i + 1).padStart(2, '0')}.png`), width: 400, height: 400, mtimeMs: 1_700_000_000_000 + i * 1000, ocrText: '帝国理工 计算机科学 考前冲刺 提分', description: '', label: `朋友圈素材 ${i + 1}`, })); const groups = groupMaterials(gridItems, {}); const gridOk = groups.length === 1 && groups[0].count === 9 && groups[0].layout === '3x3' && groups[0].items.every((item, index) => item.order === index + 1); results.push({ name: '九宫格分组与顺序(3x3,order 1..9)', ok: gridOk, detail: `分组数=${groups.length},布局=${groups[0] && groups[0].layout},顺序=${groups[0] && groups[0].items.map((i) => i.order).join(',')}`, }); const separated = groupMaterials([ { kind: 'image', localPath: path.join(tmpRoot, 'a1.png'), width: 400, height: 400, mtimeMs: 1, ocrText: '帝国理工 提分' }, { kind: 'image', localPath: path.join(tmpRoot, 'a2.png'), width: 400, height: 400, mtimeMs: 2000, ocrText: '帝国理工 提分' }, { kind: 'image', localPath: path.join(tmpRoot, 'b1.png'), width: 1600, height: 900, mtimeMs: 900_000_000, ocrText: '香港大学 申诉' }, ], {}); results.push({ name: '跨批次不误并组(时间 + 尺寸 + 内容三重信号)', ok: separated.length === 2, detail: `分组数=${separated.length},各组成员=${separated.map((g) => g.count).join('+')}`, }); const dict = loadDictionary(options.dictPath); const classified = classifyCase({ dict, groups: [...groups, ...separated], visionResults: [], textBundle: { paragraphs: ['帝国理工 计算机科学 考前冲刺辅导案例,学生期末提分 18 分,家长很满意', '微信:student_demo 联系 13800000000'], notes: [], titles: [], transcript: '' }, authorizationStatus: 'authorized', hints: {}, }); const classifyOk = classified.tags.schoolCanonical.includes('帝国理工') && classified.tags.highlightTypes.includes('提分') && classified.compliance.privacyFindings.length > 0 && classified.compliance.authorizationOk === true; results.push({ name: '打标 + 别名归一(IC→帝国理工)+ 隐私发现', ok: classifyOk, detail: `学校=${classified.tags.schoolCanonical.join('/')},亮点=${classified.tags.highlightTypes.join('/')},隐私=${classified.compliance.privacyFindings.map((f) => f.field).join('/')}`, }); const complaint = classifyCase({ dict, groups: [], visionResults: [], textBundle: { paragraphs: ['学生投诉退费纠纷,要求赔偿'], notes: [], titles: [], transcript: '' }, authorizationStatus: 'pending', hints: {}, }); const riskOk = complaint.compliance.riskFlags.includes('COMPLAINT') && complaint.compliance.riskFlags.includes('UNAUTHORIZED') && complaint.compliance.complianceBlockers.length >= 2 && complaint.compliance.authorizationOk === false; results.push({ name: '合规判断(客诉/负面 → riskFlags;无授权 → 不入库)', ok: riskOk, detail: `riskFlags=${complaint.compliance.riskFlags.join('/')},blockers=${complaint.compliance.complianceBlockers.length}`, }); const reorderOk = (() => { const moved = reorderAssets(sortAssets(groups[0].items).map((i) => ({ ...i, kind: 'image' })), 9, 1); return moved[0].order === 1 && moved.length === 9; })(); results.push({ name: '素材顺序调整(reorder)', ok: reorderOk, detail: '把第 9 张移到第 1 位后重新编号 1..9' }); const failed = results.filter((r) => !r.ok); return { ok: failed.length === 0, checked: results.length, failed: failed.length, results, workDir: tmpRoot }; } /** 合成一个最小的合法 PNG(用于自检,不引用任何真实素材)。 */ export function makeTinyPng(width = 1, height = 1) { const crcTable = (() => { const table = new Int32Array(256); for (let n = 0; n < 256; n++) { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; table[n] = c; } return table; })(); const crc32 = (buf) => { let c = -1; for (const byte of buf) c = crcTable[(c ^ byte) & 0xff] ^ (c >>> 8); return (c ^ -1) >>> 0; }; const chunk = (type, data) => { const length = Buffer.alloc(4); length.writeUInt32BE(data.length, 0); const typeBuf = Buffer.from(type, 'ascii'); const crc = Buffer.alloc(4); crc.writeUInt32BE(crc32(Buffer.concat([typeBuf, data])), 0); return Buffer.concat([length, typeBuf, data, crc]); }; const ihdr = Buffer.alloc(13); ihdr.writeUInt32BE(width, 0); ihdr.writeUInt32BE(height, 4); ihdr[8] = 8; // bit depth ihdr[9] = 6; // RGBA const raw = Buffer.alloc((width * 4 + 1) * height); return Buffer.concat([ Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), chunk('IHDR', ihdr), chunk('IDAT', zlib.deflateSync(raw)), chunk('IEND', Buffer.alloc(0)), ]); } // --------------------------------------------------------------------------- // CLI // --------------------------------------------------------------------------- function parseCli(argv) { const command = argv[0] && !argv[0].startsWith('-') ? argv[0] : 'ingest'; const rest = command === argv[0] ? argv.slice(1) : argv; const { values, positionals } = parseArgs({ args: rest, options: { input: { type: 'string', multiple: true, default: [] }, files: { type: 'string', multiple: true, default: [] }, image: { type: 'string', multiple: true, default: [] }, doc: { type: 'string', multiple: true, default: [] }, deck: { type: 'string', multiple: true, default: [] }, video: { type: 'string', multiple: true, default: [] }, 'out-dir': { type: 'string' }, 'out': { type: 'string' }, 'source-type': { type: 'string' }, 'source-ref': { type: 'string' }, authorization: { type: 'string', default: 'pending' }, title: { type: 'string' }, summary: { type: 'string' }, 'target-customer': { type: 'string' }, 'usage-suggestion': { type: 'string' }, 'school-canonical': { type: 'string' }, 'product-line': { type: 'string' }, major: { type: 'string' }, stage: { type: 'string' }, country: { type: 'string' }, highlight: { type: 'string', multiple: true, default: [] }, scenario: { type: 'string', multiple: true, default: [] }, objection: { type: 'string', multiple: true, default: [] }, 'idempotency-key': { type: 'string' }, 'asset-base-url': { type: 'string' }, upload: { type: 'boolean', default: true }, 'no-upload': { type: 'boolean', default: false }, 'submitted-by': { type: 'string' }, 'vision-model': { type: 'string' }, 'skip-vision': { type: 'boolean', default: false }, 'skip-video': { type: 'boolean', default: false }, 'frame-count': { type: 'string' }, diarize: { type: 'boolean', default: false }, 'no-learn': { type: 'boolean', default: false }, 'dict-path': { type: 'string' }, package: { type: 'string' }, 'material-assets': { type: 'string' }, move: { type: 'string' }, order: { type: 'string' }, 'no-direct': { type: 'boolean', default: false }, 'probe-direct': { type: 'boolean', default: false }, add: { type: 'string', multiple: true, default: [] }, alias: { type: 'string', multiple: true, default: [] }, out: { type: 'string' }, json: { type: 'boolean', default: false }, help: { type: 'boolean', default: false, short: 'h' }, }, allowPositionals: true, }); return { command, values, positionals }; } function collectInputs(values, positionals) { return [ ...values.input, ...values.files, ...values.image, ...values.doc, ...values.deck, ...values.video, ...positionals, ]; } async function cliMain(argv) { const { command, values, positionals } = parseCli(argv); if (values.help) { process.stdout.write(HELP_TEXT); return 0; } if (command === 'selftest') { const report = await runSelftest({ dictPath: values['dict-path'], outDir: values['out-dir'] }); process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); return report.ok ? 0 : 1; } if (command === 'dictionary') { const report = runDictionary({ add: values.add, alias: values.alias, dictPath: values['dict-path'] }); process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); return 0; } if (command === 'reorder') { const report = runReorder({ materialAssets: values['material-assets'] || values.package, move: values.move, order: values.order, }); process.stdout.write(`${JSON.stringify({ ok: true, file: report.file, order: report.materialAssets.map((a) => a.order) }, null, 2)}\n`); return 0; } if (command === 'submit') { if (!values.package) { process.stderr.write('需要 --package \n'); return 2; } const report = await runSubmit({ package: values.package, submittedBy: values['submitted-by'], direct: !values['no-direct'], }); process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); return report.submitted ? 0 : 4; } if (command === 'probe' || values['probe-direct']) { const report = await probeParse(); process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); return report.ok ? 0 : 4; } // plan / ingest const outDir = values['out-dir'] || values.out || path.join(outputsDir(), `${values['source-ref'] ? `${slug(values['source-ref'])}-` : ''}${newRunId()}`); const options = { inputs: collectInputs(values, positionals), outDir, sourceType: values['source-type'], sourceRef: values['source-ref'], authorizationStatus: values.authorization, title: values.title, summary: values.summary, targetCustomer: values['target-customer'], usageSuggestion: values['usage-suggestion'], idempotencyKey: values['idempotency-key'], assetBaseUrl: values['asset-base-url'] || process.env.CASE_ASSET_BASE_URL || '', // 默认开:真正上传拿公网 URL;--no-upload 关闭(离线自测用),此时回落到 --asset-base-url 派生 upload: values['no-upload'] ? false : values.upload !== false, submittedBy: values['submitted-by'], visionModel: values['vision-model'], skipVision: values['skip-vision'], skipVideo: values['skip-video'], frameCount: values['frame-count'] ? Number(values['frame-count']) : undefined, diarize: values.diarize, noLearn: values['no-learn'], dictPath: values['dict-path'], hints: { schoolCanonical: values['school-canonical'], productLine: values['product-line'], major: values.major, stage: values.stage, country: values.country, highlightTypes: values.highlight, scenarioTags: values.scenario, objectionTags: values.objection, }, }; if (!options.inputs.length) { process.stderr.write('至少需要一个输入(位置参数或 --input / --image / --doc / --deck / --video)\n'); return 2; } if (command === 'plan') { const plan = await runPlan(options); process.stdout.write(`${JSON.stringify({ ok: true, command: 'plan', runId: plan.runId, sourceType: plan.sourceType, counts: plan.counts, groups: plan.groups.map((g) => ({ groupId: g.groupId, layout: g.layout, count: g.count, orderingRule: g.orderingRule })), warnings: plan.warnings, outDir, }, null, 2)}\n`); return 0; } const report = await runIngest(options); const summary = { ok: true, command: 'ingest', runId: report.casePackage.runId, sourceType: report.casePackage.sourceType, idempotencyKey: report.casePackage.idempotencyKey, materialAssets: report.casePackage.materialAssets.length, groups: report.casePackage.groupInfoList, title: report.casePackage.title, tags: { schoolCanonical: report.casePackage.schoolCanonical, schoolAliases: report.casePackage.schoolAliases, country: report.casePackage.country, major: report.casePackage.major, stage: report.casePackage.stage, subject: report.casePackage.subject, highlightTypes: report.casePackage.highlightTypes, scenarioTags: report.casePackage.scenarioTags, objectionTags: report.casePackage.objectionTags, fitStatus: report.casePackage.fitStatus, }, compliance: { riskFlags: report.casePackage.riskFlags, privacyFindings: report.casePackage.privacyFindings.length, blockers: report.casePackage.complianceBlockers, canSubmit: report.canSubmit, expected: report.casePackage.expected, }, dictionaryChanges: report.dictionaryChanges, warnings: uniq(report.warnings), artifacts: report.artifacts, }; process.stdout.write(`${JSON.stringify(summary, null, 2)}\n`); return 0; } function slug(value) { return String(value).replace(/[^\w.-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40); } const HELP_TEXT = `skill-case-get —— 案例采集 / 拆解 / 归档 用法: case-intake.mjs ingest [输入...] [选项] 拆解 + 打标,产出案例包(默认不提交) case-intake.mjs plan [输入...] 只识别 + 提取 + 分组(不调模型) case-intake.mjs submit --package 提交到云函数 caseSubmit(待审清单) case-intake.mjs reorder --material-assets --move "3:1" case-intake.mjs dictionary [--add highlightTypes=退费保障] [--alias "IC=帝国理工=英国"] case-intake.mjs selftest 输入(可混用,支持目录): --image a.jpg --image b.jpg ... 连续多图 / 裸图 / 九宫格 --doc case.docx Word --deck deck.pptx PPT --video clip.mp4 视频(抽帧 + 转写) --input 自动按扩展名分流 --files 同 --input(别名) 采集选项: --authorization authorized|pending|denied 授权状态(硬门:仅 authorized 可入库,默认 pending) --source-ref 来源引用(不含 PII) --source-type image_group|document|presentation|mixed|single_image --title / --summary / --target-customer / --usage-suggestion 覆盖自动抽取的案例字段 --school-canonical / --product-line / --major / --stage / --country --highlight / --scenario / --objection 显式补标签(可重复) --idempotency-key 幂等键(默认按内容指纹生成) --asset-base-url 本地素材 URL 前缀(CDN / Parse Files / 对象存储目录)**回退用** (或环境变量 CASE_ASSET_BASE_URL) --upload 真正上传本地素材到对象存储,拿公网 URL(默认开) --no-upload 关闭上传;url 只按 --asset-base-url 派生(离线自测用) object key 规范:case-library//-. --out-dir 产物目录(默认 /case-get-outputs/) --out 同 --out-dir(别名) --skip-vision / --skip-video / --no-learn 关闭图片理解 / 视频处理 / 字典自进化 --submitted-by 提交人 提交选项: --package ingest 产出的案例包 --submitted-by 产物(--out-dir): extracted.json 文档文本 + 内嵌媒体 group-plan.json 分组与九宫格顺序 vision.json 每张图的 OCR / 描述 / 使用建议 material-assets.json materialAssets[](可编辑顺序) case-package.json 云函数 caseSubmit 入参(逐字对齐共享契约) report.md 人类可读报告 `; const invokedDirectly = process.argv[1] && path.resolve(process.argv[1]).endsWith(path.join('scripts', 'case-intake.mjs')); if (invokedDirectly) { cliMain(process.argv.slice(2)) .then((code) => process.exit(code)) .catch((error) => { process.stderr.write(`skill-case-get 失败:${error.message}\n`); process.exit(1); }); } export { cliMain };