lib.mjs 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352
  1. // Copyright (c) 未来飞马
  2. //
  3. // This Source Code Form is subject to the terms of the Mozilla Public
  4. // License, v. 2.0. If a copy of the MPL was not distributed with this
  5. // file, You can obtain one at https://mozilla.org/MPL/2.0/.
  6. //
  7. // Trademark Notice:
  8. // The MPL-2.0 license grants copyright permissions for source code only.
  9. // It does NOT grant any rights to use trademarks including "未来飞马",
  10. // "Harness Loop", "RSI", and associated slogan "让AI进化提前发生,让AI落地快人一步".
  11. // Any use of these trademarks requires separate written permission.
  12. /**
  13. * skill-case-get 共享工具层
  14. *
  15. * 零依赖。集中处理:
  16. * - 仓库内路径解析(技能可能被拷到 ~/.claude/skills/,也可能在仓库内直接跑)
  17. * - 标签字典读写(自进化)
  18. * - 幂等键生成(tenantId + idempotencyKey)
  19. * - 素材/案例字段规范化(与 SHARED-CONTRACT.md 逐字对齐)
  20. * - 绝对不做的事:不写任何真实 PII、不落 token、不碰 .env
  21. *
  22. * ⚠️ 本仓库禁止出现任何密钥/token/私网地址。token 只从用户环境读取,仅内存持有。
  23. */
  24. import fs from 'node:fs';
  25. import path from 'node:path';
  26. import os from 'node:os';
  27. import crypto from 'node:crypto';
  28. import { fileURLToPath } from 'node:url';
  29. export const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
  30. export const SKILL_ROOT = path.resolve(SCRIPT_DIR, '..');
  31. export const TENANT_DEFAULT = process.env.CASE_TENANT_ID || 'lumi-demo';
  32. // ---------------------------------------------------------------------------
  33. // 文件与 JSON
  34. // ---------------------------------------------------------------------------
  35. export function readJson(filePath, fallback = null) {
  36. try {
  37. if (!filePath || !fs.existsSync(filePath)) return fallback;
  38. // 手写的 json 可能带 UTF-8 BOM
  39. const raw = fs.readFileSync(filePath, 'utf8').replace(/^/, '');
  40. return JSON.parse(raw);
  41. } catch {
  42. return fallback;
  43. }
  44. }
  45. export function writeJson(filePath, value) {
  46. fs.mkdirSync(path.dirname(filePath), { recursive: true });
  47. fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
  48. }
  49. export function ensureDir(dir) {
  50. fs.mkdirSync(dir, { recursive: true });
  51. return dir;
  52. }
  53. export function sha1(value) {
  54. return crypto.createHash('sha1').update(String(value ?? '')).digest('hex');
  55. }
  56. export function shortHash(value, length = 10) {
  57. return sha1(value).slice(0, length);
  58. }
  59. // ---------------------------------------------------------------------------
  60. // 兄弟技能脚本解析(skill-vision / skill-listen)
  61. // ---------------------------------------------------------------------------
  62. function candidateRoots(envVar) {
  63. const roots = [];
  64. if (envVar && process.env[envVar]) roots.push(process.env[envVar]);
  65. roots.push(path.join(os.homedir(), '.claude', 'skills'));
  66. roots.push(path.join(process.cwd(), '.claude', 'skills'));
  67. roots.push(path.join(SKILL_ROOT, '..'));
  68. return roots;
  69. }
  70. /**
  71. * 定位兄弟技能里的脚本。找不到返回 null(调用方给可执行的降级提示,不抛栈)。
  72. * @param {string} envVar 允许用环境变量覆盖的变量名
  73. * @param {string} relativePath 相对 skills 根目录的路径,如 skill-vision/scripts/vision-client.mjs
  74. */
  75. export function resolveSiblingScript(envVar, relativePath) {
  76. for (const root of candidateRoots(envVar)) {
  77. const full = path.join(root, relativePath);
  78. if (fs.existsSync(full)) return full;
  79. }
  80. return null;
  81. }
  82. export function resolvePython() {
  83. return process.env.CASE_PYTHON || 'python3';
  84. }
  85. // ---------------------------------------------------------------------------
  86. // 标签字典
  87. // ---------------------------------------------------------------------------
  88. export function dictionaryPath(custom) {
  89. if (custom) return path.resolve(custom);
  90. return path.join(SKILL_ROOT, 'references', 'tag-dictionary.json');
  91. }
  92. export function loadDictionary(custom) {
  93. const file = dictionaryPath(custom);
  94. const dict = readJson(file, null);
  95. if (!dict || !dict.dimensions) {
  96. throw new Error(`标签字典不可用或结构不对:${file}`);
  97. }
  98. if (!Array.isArray(dict.__learned)) dict.__learned = [];
  99. dict.__file = file;
  100. return dict;
  101. }
  102. /** 取某维度的候选取值(数组;schoolAlias 这类结构特殊,单独处理)。 */
  103. export function dimensionValues(dict, dimension) {
  104. const dim = dict && dict.dimensions ? dict.dimensions[dimension] : null;
  105. if (!dim) return [];
  106. if (Array.isArray(dim.values)) return dim.values;
  107. return [];
  108. }
  109. /**
  110. * 字典自进化:把采集过程中遇到的新取值追加进字典。
  111. * 只追加、不改写既有条目;带 source/learnedAt 便于追溯。
  112. *
  113. * @returns {{added: Array<{dimension:string, value:string}>, skipped: Array<{dimension:string,value:string,reason:string}>}}
  114. */
  115. export function learnTags(dict, incoming) {
  116. const added = [];
  117. const skipped = [];
  118. const now = new Date().toISOString();
  119. for (const item of incoming || []) {
  120. const dimension = String(item && item.dimension || '').trim();
  121. const value = String(item && item.value || '').trim();
  122. if (!dimension || !value) {
  123. skipped.push({ dimension, value, reason: 'empty' });
  124. continue;
  125. }
  126. const dim = dict.dimensions ? dict.dimensions[dimension] : null;
  127. if (!dim) {
  128. skipped.push({ dimension, value, reason: 'unknown-dimension' });
  129. continue;
  130. }
  131. if (!Array.isArray(dim.values)) {
  132. // 结构型维度(schoolAlias)走 addAlias
  133. skipped.push({ dimension, value, reason: 'structured-dimension' });
  134. continue;
  135. }
  136. if (dim.values.includes(value)) {
  137. skipped.push({ dimension, value, reason: 'exists' });
  138. continue;
  139. }
  140. dim.values.push(value);
  141. added.push({ dimension, value });
  142. }
  143. if (added.length) {
  144. dict.updatedAt = now;
  145. dict.__learned = Array.isArray(dict.__learned) ? dict.__learned : [];
  146. for (const entry of added) {
  147. dict.__learned.push({ ...entry, source: 'learned', learnedAt: now });
  148. }
  149. }
  150. return { added, skipped };
  151. }
  152. /** 学校别名自进化:追加到 schoolAlias.entries。 */
  153. export function learnAliases(dict, entries) {
  154. const added = [];
  155. const dim = dict.dimensions && dict.dimensions.schoolAlias;
  156. if (!dim || !Array.isArray(dim.entries)) return { added };
  157. const existing = new Set(dim.entries.map((e) => String(e.aliasText || '').toLowerCase()));
  158. const now = new Date().toISOString();
  159. for (const item of entries || []) {
  160. const aliasText = String(item && item.aliasText || '').trim();
  161. const canonicalName = String(item && item.canonicalName || '').trim();
  162. if (!aliasText || !canonicalName) continue;
  163. if (existing.has(aliasText.toLowerCase())) continue;
  164. const entry = { aliasText, canonicalName, country: item.country || '', source: 'learned', learnedAt: now };
  165. dim.entries.push(entry);
  166. existing.add(aliasText.toLowerCase());
  167. added.push(entry);
  168. }
  169. if (added.length) {
  170. dict.updatedAt = now;
  171. dict.__learned = Array.isArray(dict.__learned) ? dict.__learned : [];
  172. for (const entry of added) dict.__learned.push({ dimension: 'schoolAlias', value: entry.aliasText, source: 'learned', learnedAt: now });
  173. }
  174. return { added };
  175. }
  176. export function saveDictionary(dict) {
  177. const { __file, __learned, ...clean } = dict;
  178. writeJson(dict.__file || dictionaryPath(), clean);
  179. return dict.__file || dictionaryPath();
  180. }
  181. // ---------------------------------------------------------------------------
  182. // 幂等
  183. // ---------------------------------------------------------------------------
  184. /**
  185. * 生成幂等键。同一批素材重复采集(同 sourceRef + 同标题 + 同素材序列)必须得到同一个键,
  186. * 保证 `tenantId + idempotencyKey` 去重、重复提交只产生一条。
  187. */
  188. export function buildIdempotencyKey({ tenantId, sourceType, sourceRef, title, materialAssets, explicit }) {
  189. if (explicit) return String(explicit);
  190. const assetFingerprint = (materialAssets || [])
  191. .map((a) => `${a.order || ''}:${a.kind || ''}:${a.url || a.localPath || ''}`)
  192. .join('|');
  193. const seed = [tenantId || TENANT_DEFAULT, sourceType || '', sourceRef || '', title || '', assetFingerprint].join('::');
  194. return `caseget-${shortHash(seed, 16)}`;
  195. }
  196. /**
  197. * 为本地素材推导可访问 URL。
  198. *
  199. * 技能本机不存储/上传二进制(不新建业务服务器),因此:
  200. * - 调用了 `--asset-base-url <base>`(已有 CDN / Parse Files 前缀)→ 用 base + 文件名派生 URL;
  201. * - 没提供 → 返回空串,并在调用处给出警告,由归档方补齐 URL 后再入库。
  202. * 绝不伪造 URL,也不把本地绝对路径当 URL 写进案例包。
  203. */
  204. export function deriveAssetUrl(localPath, baseUrl) {
  205. if (!baseUrl) return '';
  206. const name = String(localPath || '').split(/[\\/]/).pop() || '';
  207. if (!name) return '';
  208. // 路径片段保留、文件名做 URL 安全化,避免空格 / 中文导致的坏链接
  209. const safe = encodeURIComponent(name).replace(/%2F/g, '_');
  210. return `${String(baseUrl).replace(/\/+$/, '')}/${safe}`;
  211. }
  212. // ---------------------------------------------------------------------------
  213. // 素材/案例字段规范化(逐字对齐 SHARED-CONTRACT.md)
  214. // ---------------------------------------------------------------------------
  215. export const MATERIAL_KINDS = new Set(['image', 'video']);
  216. export const MATERIAL_ROLES = new Set(['material', 'description']);
  217. /**
  218. * 规范化单个素材项。order 1 起、同组内按九宫格阅读顺序。
  219. * @returns {{asset: object|null, problems: string[]}}
  220. */
  221. export function normalizeMaterialAsset(raw, index) {
  222. const problems = [];
  223. const item = raw || {};
  224. const kind = String(item.kind || 'image').toLowerCase();
  225. if (!MATERIAL_KINDS.has(kind)) problems.push(`素材 #${index + 1} 的 kind 非法:${item.kind}`);
  226. const order = Number.isFinite(Number(item.order)) && Number(item.order) > 0
  227. ? Math.trunc(Number(item.order))
  228. : index + 1;
  229. const url = item.url || item.imageUrl || item.videoUrl || '';
  230. const localPath = item.localPath || item.path || '';
  231. if (!url && !localPath) problems.push(`素材 #${index + 1} 既没有 url 也没有 localPath`);
  232. const role = String(item.role || 'material').toLowerCase();
  233. if (!MATERIAL_ROLES.has(role)) problems.push(`素材 #${index + 1} 的 role 非法:${item.role}`);
  234. const asset = {
  235. order,
  236. groupId: item.groupId || '',
  237. kind,
  238. url: String(url || ''),
  239. localPath: String(localPath || ''),
  240. label: String(item.label || ''),
  241. ocrText: String(item.ocrText || ''),
  242. description: String(item.description || ''),
  243. usageSuggestion: String(item.usageSuggestion || ''),
  244. role,
  245. };
  246. return { asset, problems };
  247. }
  248. /** 按 order 升序排序(稳定;同 order 保持原相对顺序)。 */
  249. export function sortAssets(assets) {
  250. return (assets || [])
  251. .map((asset, index) => ({ asset, index }))
  252. .sort((a, b) => (a.asset.order - b.asset.order) || (a.index - b.index))
  253. .map((entry) => entry.asset);
  254. }
  255. /**
  256. * 手动调整顺序:把指定素材移动到目标位置,其余顺次重排(order 从 1 连续)。
  257. * 支持「数组 + 顺序调整」的显式要求。
  258. */
  259. export function reorderAssets(assets, from, to) {
  260. const list = sortAssets(assets);
  261. const start = Math.trunc(from) - 1;
  262. const end = Math.trunc(to) - 1;
  263. if (start < 0 || start >= list.length || end < 0 || end >= list.length) {
  264. throw new Error(`reorder 越界:from=${from} to=${to},共 ${list.length} 项`);
  265. }
  266. const [moved] = list.splice(start, 1);
  267. list.splice(end, 0, moved);
  268. return list.map((asset, index) => ({ ...asset, order: index + 1 }));
  269. }
  270. /** 汇总兼容字段:imageUrls = kind='image' 的 url,按 order 排序。 */
  271. export function imageUrlsFrom(assets) {
  272. return sortAssets(assets)
  273. .filter((a) => a.kind === 'image' && a.url)
  274. .map((a) => a.url);
  275. }
  276. // ---------------------------------------------------------------------------
  277. // 输出目录
  278. // ---------------------------------------------------------------------------
  279. export function outputsDir(custom) {
  280. return path.resolve(custom || process.env.CASE_OUTPUT_DIR || path.join(SKILL_ROOT, 'outputs'));
  281. }
  282. export function newRunId() {
  283. const now = new Date();
  284. const stamp = now.toISOString().replace(/[-:T]/g, '').slice(0, 14);
  285. return `${stamp}-${shortHash(`${now.getTime()}-${Math.random()}`, 6)}`;
  286. }
  287. // ---------------------------------------------------------------------------
  288. // 通用小工具
  289. // ---------------------------------------------------------------------------
  290. /**
  291. * 去重并丢弃空值。同时接受多个数组(或嵌套数组):
  292. * uniq(['a']), uniq(['a'], ['b']) 都成立,避免调用处到处 spread。
  293. */
  294. export function uniq(...args) {
  295. return [...new Set(
  296. args
  297. .flat(Infinity)
  298. .filter((item) => item !== undefined && item !== null && item !== ''),
  299. )];
  300. }
  301. export function firstText(...values) {
  302. for (const value of values) {
  303. if (typeof value === 'string' && value.trim()) return value.trim();
  304. }
  305. return '';
  306. }
  307. export function truncate(value, max = 160) {
  308. const text = String(value ?? '').replace(/\s+/g, ' ').trim();
  309. return text.length > max ? `${text.slice(0, max)}…` : text;
  310. }