| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373 |
- // 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 = [];
- // 注意:envVar 的显式覆盖在 resolveSiblingScript 里优先处理,这里不再重复入列。
- roots.push(path.join(os.homedir(), '.claude', 'skills'));
- roots.push(path.join(process.cwd(), '.claude', 'skills'));
- roots.push(path.join(SKILL_ROOT, '..'));
- // 平台级技能目录(本部署的兄弟技能 fmode-storage 落在这里,不在 ~/.claude/skills):
- // 1) CASE_SKILLS_DIR 显式指定;
- // 2) $HERMES_HOME/skills(容器默认 HERMES_HOME=/opt/data);
- // 3) /opt/data/skills 兜底。
- // 命中与否由 resolveSiblingScript 的 existsSync 判定,多列不存在的根目录无副作用。
- if (process.env.CASE_SKILLS_DIR) roots.push(process.env.CASE_SKILLS_DIR);
- if (process.env.HERMES_HOME) roots.push(path.join(process.env.HERMES_HOME, 'skills'));
- roots.push('/opt/data/skills');
- return roots;
- }
- /**
- * 定位兄弟技能里的脚本。找不到返回 null(调用方给可执行的降级提示,不抛栈)。
- *
- * 环境变量(如 CASE_STORAGE_SCRIPT / CASE_VISION_SCRIPT)一旦显式设置,就是**权威覆盖**:
- * 先按「skills 根目录」解释(env/相对路径),再按「脚本文件本身」解释;
- * 两种都不存在时直接返回 null,**不再回落到默认搜索路径**——否则调用方想模拟
- * 「兄弟技能缺失」时会意外命中真实技能(测试与非预期环境都会踩)。
- *
- * @param {string} envVar 允许用环境变量覆盖的变量名
- * @param {string} relativePath 相对 skills 根目录的路径,如 skill-vision/scripts/vision-client.mjs
- */
- export function resolveSiblingScript(envVar, relativePath) {
- const override = envVar && process.env[envVar];
- if (override) {
- const asRoot = path.join(override, relativePath);
- if (fs.existsSync(asRoot)) return asRoot;
- if (fs.existsSync(override) && fs.statSync(override).isFile()) return override;
- return null;
- }
- 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 <base>`(已有 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;
- }
|