|
|
@@ -0,0 +1,1127 @@
|
|
|
+#!/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;生成的产物默认落在 <cwd>/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 { CloudError, submitCase } from './cloud-client.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 + 图片理解 + 分类打标 → 案例包
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
+
|
|
|
+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;
|
|
|
+
|
|
|
+ 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 时,按 --asset-base-url 派生;没给基础地址则留空并警告
|
|
|
+ const derivedUrl = item.url || deriveAssetUrl(item.localPath, options.assetBaseUrl);
|
|
|
+ if (!derivedUrl && item.localPath) missingUrlCount += 1;
|
|
|
+ const { asset, problems } = normalizeMaterialAsset({
|
|
|
+ order: item.order,
|
|
|
+ groupId: group.groupId,
|
|
|
+ kind: item.kind,
|
|
|
+ url: derivedUrl,
|
|
|
+ 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);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ for (const problem of assetProblems) warnings.push(problem);
|
|
|
+ if (missingUrlCount) {
|
|
|
+ warnings.push(`${missingUrlCount} 个素材只有本地路径、没有 URL:用 --asset-base-url <CDN前缀> 派生,或由归档方补齐 url 后再 submit(检索侧只看 url)。`);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 全局重编号: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'] };
|
|
|
+ }
|
|
|
+
|
|
|
+ try {
|
|
|
+ const response = await submitCase(casePackage, { submittedBy: options.submittedBy, token: options.token });
|
|
|
+ if (!response.ok) {
|
|
|
+ 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,
|
|
|
+ };
|
|
|
+ } catch (error) {
|
|
|
+ if (error instanceof CloudError) {
|
|
|
+ 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: [] },
|
|
|
+ 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' },
|
|
|
+ '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' },
|
|
|
+ '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' },
|
|
|
+ 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.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 <case-package.json>\n');
|
|
|
+ return 2;
|
|
|
+ }
|
|
|
+ const report = await runSubmit({ package: values.package, submittedBy: values['submitted-by'] });
|
|
|
+ process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
|
+ return report.submitted ? 0 : 4;
|
|
|
+ }
|
|
|
+
|
|
|
+ // plan / ingest
|
|
|
+ const outDir = values['out-dir']
|
|
|
+ || 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 || '',
|
|
|
+ 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 <file> 提交到云函数 caseSubmit(待审清单)
|
|
|
+ case-intake.mjs reorder --material-assets <file> --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 <file|dir> 自动按扩展名分流
|
|
|
+
|
|
|
+采集选项:
|
|
|
+ --authorization authorized|pending|denied 授权状态(硬门:仅 authorized 可入库,默认 pending)
|
|
|
+ --source-ref <ref> 来源引用(不含 PII)
|
|
|
+ --source-type <t> 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 <key> 幂等键(默认按内容指纹生成)
|
|
|
+ --asset-base-url <base> 本地素材 URL 前缀(CDN / Parse Files),用于派生 materialAssets[].url
|
|
|
+ (也可用环境变量 CASE_ASSET_BASE_URL)
|
|
|
+ --out-dir <dir> 产物目录(默认 <cwd>/case-get-outputs/<runId>)
|
|
|
+ --skip-vision / --skip-video / --no-learn 关闭图片理解 / 视频处理 / 字典自进化
|
|
|
+ --submitted-by <userId> 提交人
|
|
|
+
|
|
|
+提交选项:
|
|
|
+ --package <case-package.json> ingest 产出的案例包
|
|
|
+ --submitted-by <userId>
|
|
|
+
|
|
|
+产物(--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 };
|