// 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 共享工具层 * * 零依赖。集中处理: * - 仓库内路径解析(技能可能被拷到 ~/.claude/skills/,也可能在仓库内直接跑) * - 标签字典读写(自进化) * - 幂等键生成(tenantId + idempotencyKey) * - 素材/案例字段规范化(与 SHARED-CONTRACT.md 逐字对齐) * - 绝对不做的事:不写任何真实 PII、不落 token、不碰 .env * * ⚠️ 本仓库禁止出现任何密钥/token/私网地址。token 只从用户环境读取,仅内存持有。 */ import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; import crypto from 'node:crypto'; import { fileURLToPath } from 'node:url'; export const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); export const SKILL_ROOT = path.resolve(SCRIPT_DIR, '..'); export const TENANT_DEFAULT = process.env.CASE_TENANT_ID || 'lumi-demo'; // --------------------------------------------------------------------------- // 文件与 JSON // --------------------------------------------------------------------------- export function readJson(filePath, fallback = null) { try { if (!filePath || !fs.existsSync(filePath)) return fallback; // 手写的 json 可能带 UTF-8 BOM const raw = fs.readFileSync(filePath, 'utf8').replace(/^/, ''); return JSON.parse(raw); } catch { return fallback; } } export function writeJson(filePath, value) { fs.mkdirSync(path.dirname(filePath), { recursive: true }); fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8'); } export function ensureDir(dir) { fs.mkdirSync(dir, { recursive: true }); return dir; } export function sha1(value) { return crypto.createHash('sha1').update(String(value ?? '')).digest('hex'); } export function shortHash(value, length = 10) { return sha1(value).slice(0, length); } // --------------------------------------------------------------------------- // 兄弟技能脚本解析(skill-vision / skill-listen) // --------------------------------------------------------------------------- function candidateRoots(envVar) { const roots = []; if (envVar && process.env[envVar]) roots.push(process.env[envVar]); roots.push(path.join(os.homedir(), '.claude', 'skills')); roots.push(path.join(process.cwd(), '.claude', 'skills')); roots.push(path.join(SKILL_ROOT, '..')); return roots; } /** * 定位兄弟技能里的脚本。找不到返回 null(调用方给可执行的降级提示,不抛栈)。 * @param {string} envVar 允许用环境变量覆盖的变量名 * @param {string} relativePath 相对 skills 根目录的路径,如 skill-vision/scripts/vision-client.mjs */ export function resolveSiblingScript(envVar, relativePath) { for (const root of candidateRoots(envVar)) { const full = path.join(root, relativePath); if (fs.existsSync(full)) return full; } return null; } export function resolvePython() { return process.env.CASE_PYTHON || 'python3'; } // --------------------------------------------------------------------------- // 标签字典 // --------------------------------------------------------------------------- export function dictionaryPath(custom) { if (custom) return path.resolve(custom); return path.join(SKILL_ROOT, 'references', 'tag-dictionary.json'); } export function loadDictionary(custom) { const file = dictionaryPath(custom); const dict = readJson(file, null); if (!dict || !dict.dimensions) { throw new Error(`标签字典不可用或结构不对:${file}`); } if (!Array.isArray(dict.__learned)) dict.__learned = []; dict.__file = file; return dict; } /** 取某维度的候选取值(数组;schoolAlias 这类结构特殊,单独处理)。 */ export function dimensionValues(dict, dimension) { const dim = dict && dict.dimensions ? dict.dimensions[dimension] : null; if (!dim) return []; if (Array.isArray(dim.values)) return dim.values; return []; } /** * 字典自进化:把采集过程中遇到的新取值追加进字典。 * 只追加、不改写既有条目;带 source/learnedAt 便于追溯。 * * @returns {{added: Array<{dimension:string, value:string}>, skipped: Array<{dimension:string,value:string,reason:string}>}} */ export function learnTags(dict, incoming) { const added = []; const skipped = []; const now = new Date().toISOString(); for (const item of incoming || []) { const dimension = String(item && item.dimension || '').trim(); const value = String(item && item.value || '').trim(); if (!dimension || !value) { skipped.push({ dimension, value, reason: 'empty' }); continue; } const dim = dict.dimensions ? dict.dimensions[dimension] : null; if (!dim) { skipped.push({ dimension, value, reason: 'unknown-dimension' }); continue; } if (!Array.isArray(dim.values)) { // 结构型维度(schoolAlias)走 addAlias skipped.push({ dimension, value, reason: 'structured-dimension' }); continue; } if (dim.values.includes(value)) { skipped.push({ dimension, value, reason: 'exists' }); continue; } dim.values.push(value); added.push({ dimension, value }); } if (added.length) { dict.updatedAt = now; dict.__learned = Array.isArray(dict.__learned) ? dict.__learned : []; for (const entry of added) { dict.__learned.push({ ...entry, source: 'learned', learnedAt: now }); } } return { added, skipped }; } /** 学校别名自进化:追加到 schoolAlias.entries。 */ export function learnAliases(dict, entries) { const added = []; const dim = dict.dimensions && dict.dimensions.schoolAlias; if (!dim || !Array.isArray(dim.entries)) return { added }; const existing = new Set(dim.entries.map((e) => String(e.aliasText || '').toLowerCase())); const now = new Date().toISOString(); for (const item of entries || []) { const aliasText = String(item && item.aliasText || '').trim(); const canonicalName = String(item && item.canonicalName || '').trim(); if (!aliasText || !canonicalName) continue; if (existing.has(aliasText.toLowerCase())) continue; const entry = { aliasText, canonicalName, country: item.country || '', source: 'learned', learnedAt: now }; dim.entries.push(entry); existing.add(aliasText.toLowerCase()); added.push(entry); } if (added.length) { dict.updatedAt = now; dict.__learned = Array.isArray(dict.__learned) ? dict.__learned : []; for (const entry of added) dict.__learned.push({ dimension: 'schoolAlias', value: entry.aliasText, source: 'learned', learnedAt: now }); } return { added }; } export function saveDictionary(dict) { const { __file, __learned, ...clean } = dict; writeJson(dict.__file || dictionaryPath(), clean); return dict.__file || dictionaryPath(); } // --------------------------------------------------------------------------- // 幂等 // --------------------------------------------------------------------------- /** * 生成幂等键。同一批素材重复采集(同 sourceRef + 同标题 + 同素材序列)必须得到同一个键, * 保证 `tenantId + idempotencyKey` 去重、重复提交只产生一条。 */ export function buildIdempotencyKey({ tenantId, sourceType, sourceRef, title, materialAssets, explicit }) { if (explicit) return String(explicit); const assetFingerprint = (materialAssets || []) .map((a) => `${a.order || ''}:${a.kind || ''}:${a.url || a.localPath || ''}`) .join('|'); const seed = [tenantId || TENANT_DEFAULT, sourceType || '', sourceRef || '', title || '', assetFingerprint].join('::'); return `caseget-${shortHash(seed, 16)}`; } /** * 为本地素材推导可访问 URL。 * * 技能本机不存储/上传二进制(不新建业务服务器),因此: * - 调用了 `--asset-base-url `(已有 CDN / Parse Files 前缀)→ 用 base + 文件名派生 URL; * - 没提供 → 返回空串,并在调用处给出警告,由归档方补齐 URL 后再入库。 * 绝不伪造 URL,也不把本地绝对路径当 URL 写进案例包。 */ export function deriveAssetUrl(localPath, baseUrl) { if (!baseUrl) return ''; const name = String(localPath || '').split(/[\\/]/).pop() || ''; if (!name) return ''; // 路径片段保留、文件名做 URL 安全化,避免空格 / 中文导致的坏链接 const safe = encodeURIComponent(name).replace(/%2F/g, '_'); return `${String(baseUrl).replace(/\/+$/, '')}/${safe}`; } // --------------------------------------------------------------------------- // 素材/案例字段规范化(逐字对齐 SHARED-CONTRACT.md) // --------------------------------------------------------------------------- export const MATERIAL_KINDS = new Set(['image', 'video']); export const MATERIAL_ROLES = new Set(['material', 'description']); /** * 规范化单个素材项。order 1 起、同组内按九宫格阅读顺序。 * @returns {{asset: object|null, problems: string[]}} */ export function normalizeMaterialAsset(raw, index) { const problems = []; const item = raw || {}; const kind = String(item.kind || 'image').toLowerCase(); if (!MATERIAL_KINDS.has(kind)) problems.push(`素材 #${index + 1} 的 kind 非法:${item.kind}`); const order = Number.isFinite(Number(item.order)) && Number(item.order) > 0 ? Math.trunc(Number(item.order)) : index + 1; const url = item.url || item.imageUrl || item.videoUrl || ''; const localPath = item.localPath || item.path || ''; if (!url && !localPath) problems.push(`素材 #${index + 1} 既没有 url 也没有 localPath`); const role = String(item.role || 'material').toLowerCase(); if (!MATERIAL_ROLES.has(role)) problems.push(`素材 #${index + 1} 的 role 非法:${item.role}`); const asset = { order, groupId: item.groupId || '', kind, url: String(url || ''), localPath: String(localPath || ''), label: String(item.label || ''), ocrText: String(item.ocrText || ''), description: String(item.description || ''), usageSuggestion: String(item.usageSuggestion || ''), role, }; return { asset, problems }; } /** 按 order 升序排序(稳定;同 order 保持原相对顺序)。 */ export function sortAssets(assets) { return (assets || []) .map((asset, index) => ({ asset, index })) .sort((a, b) => (a.asset.order - b.asset.order) || (a.index - b.index)) .map((entry) => entry.asset); } /** * 手动调整顺序:把指定素材移动到目标位置,其余顺次重排(order 从 1 连续)。 * 支持「数组 + 顺序调整」的显式要求。 */ export function reorderAssets(assets, from, to) { const list = sortAssets(assets); const start = Math.trunc(from) - 1; const end = Math.trunc(to) - 1; if (start < 0 || start >= list.length || end < 0 || end >= list.length) { throw new Error(`reorder 越界:from=${from} to=${to},共 ${list.length} 项`); } const [moved] = list.splice(start, 1); list.splice(end, 0, moved); return list.map((asset, index) => ({ ...asset, order: index + 1 })); } /** 汇总兼容字段:imageUrls = kind='image' 的 url,按 order 排序。 */ export function imageUrlsFrom(assets) { return sortAssets(assets) .filter((a) => a.kind === 'image' && a.url) .map((a) => a.url); } // --------------------------------------------------------------------------- // 输出目录 // --------------------------------------------------------------------------- export function outputsDir(custom) { return path.resolve(custom || process.env.CASE_OUTPUT_DIR || path.join(SKILL_ROOT, 'outputs')); } export function newRunId() { const now = new Date(); const stamp = now.toISOString().replace(/[-:T]/g, '').slice(0, 14); return `${stamp}-${shortHash(`${now.getTime()}-${Math.random()}`, 6)}`; } // --------------------------------------------------------------------------- // 通用小工具 // --------------------------------------------------------------------------- /** * 去重并丢弃空值。同时接受多个数组(或嵌套数组): * uniq(['a']), uniq(['a'], ['b']) 都成立,避免调用处到处 spread。 */ export function uniq(...args) { return [...new Set( args .flat(Infinity) .filter((item) => item !== undefined && item !== null && item !== ''), )]; } export function firstText(...values) { for (const value of values) { if (typeof value === 'string' && value.trim()) return value.trim(); } return ''; } export function truncate(value, max = 160) { const text = String(value ?? '').replace(/\s+/g, ' ').trim(); return text.length > max ? `${text.slice(0, max)}…` : text; }