Просмотр исходного кода

release: v1.0.1 — 素材真上传(storage-bridge)+ key 兜底区分度 + vision PII 并入隐私发现

FmodeAgent 5 часов назад
Родитель
Сommit
6e13ee2ff1

+ 3 - 3
.gitignore

@@ -1,4 +1,4 @@
-node_modules/
 outputs/
-*.log
-.DS_Store
+case-get-outputs/
+node_modules/
+.claude/

+ 19 - 7
README.md

@@ -33,12 +33,15 @@ npx skill-case-get@latest selftest           # 本机自检(不联网)
 # 只看分组对不对(不调模型、不联网)
 npx skill-case-get@latest plan -- --image g1.jpg --image g2.jpg --image g9.jpg
 
-# 完整采集(推荐先 dry-run,确认后再 submit)
+# 完整采集(推荐先 dry-run,确认后再 submit)——本地素材默认真上传,拿公网 URL
 npx skill-case-get@latest ingest -- \
   --image g1.jpg --image g9.jpg \
   --doc 案例稿.docx \
-  --authorization authorized \
-  --asset-base-url https://cdn.example.com/cases
+  --authorization authorized
+
+# 离线自测 / 不想联网:关掉上传,只按前缀派生 URL
+npx skill-case-get@latest ingest -- --image g1.jpg --no-upload \
+  --asset-base-url https://s3.fmode.cn/user/<uid>/case-library/20261010
 
 # 确认顺序无误 → 提交待审
 npx skill-case-get@latest submit -- \
@@ -63,6 +66,7 @@ skills/skill-case-get/
     ├── case-intake.mjs           主入口:plan / ingest / submit / reorder / dictionary / selftest
     ├── office_extract.py         .docx/.pptx 解包(纯标准库 zipfile + ElementTree)
     ├── vision-bridge.mjs         图片理解(复用 skill-vision)
+    ├── storage-bridge.mjs        素材上传(复用 fmode-storage 第 0 级免密钥通道)
     ├── listen-bridge.mjs         视频抽帧 + 转写(ffmpeg + skill-listen)
     ├── cloud-client.mjs          云函数 caseSubmit / casePendingList
     ├── grouping.mjs              分组 + 九宫格顺序
@@ -84,9 +88,16 @@ skills/skill-case-get/
 只看内容相似度会把两份不同学生的相似截图并成一组;只看时间会把同一时刻收到的不同素材并成一组。
 尺寸(同源同排版)+ 时间(连发)+ 内容(共同话术)三者取二,才既不漏并也不误并。
 
-**为什么不自动上传二进制?**
-项目规则明确「不新建独立业务服务器处理普通 CRUD」。技能只做本机拆解与归档编排,
-媒体由既有 CDN / Parse Files 承载,用 `--asset-base-url` 派生 URL。没给就明确警告,而不是造一个假 URL。
+**为什么不再只靠 `--asset-base-url` 派生 URL?**
+检索侧只看 `materialAssets[].url`,本地路径不算数。过去 ingest 只把本地文件名拼到 `--asset-base-url`
+前缀上:没给前缀就留空(线上真实故障:案例有 `localPath` 但 `url` 全空),给了也只是拼一个**并不存在的假链接**。
+现在 ingest **默认真上传**(`--upload`):本地素材走兄弟技能 `fmode-storage` 的**第 0 级免密钥通道**
+(云函数预签名直传,AK/SK 始终留在云函数侧)拿到公网 URL,key 规范
+`case-library/<YYYYMMDD>/<caseSlug>-<order>.<ext>`。`--asset-base-url` 保留为**显式回退**
+(`--no-upload` 或上传失败时),拿不到 URL 就留空并告警——**任何情况下都不伪造 URL**。
+
+上传失败不会拖垮整批 ingest:单张降级留空 + 定位到具体文件的 warning,其余照常,最后汇总「N 张上传失败」。
+离线自测用 `--no-upload`。
 
 ## 依赖
 
@@ -95,6 +106,7 @@ skills/skill-case-get/
 | Node ≥ 18 | ✅ | 运行器(含 `fetch`、`node --test`) |
 | python3 ≥ 3.8 | ✅ | `.docx` / `.pptx` 解包(仅标准库) |
 | skill-vision | 推荐 | 图片 OCR + 语义理解;宿主自带多模态时由宿主读图 |
+| fmode-storage | 推荐 | 素材上传(第 0 级云函数免密钥通道),`--upload` 默认依赖它 |
 | skill-listen | 可选 | 视频音轨转写(走 Fmode 网关,凭据仅服务端) |
 | ffmpeg / ffprobe | 可选 | 视频抽帧、时长探测 |
 
@@ -105,7 +117,7 @@ skills/skill-case-get/
 - **只处理已获授权素材**:`authorizationStatus != authorized` 一律不入库。
 - **新案例不进公共素材库**:只写 `reviewStatus='pending'` + `readyForUse=false`,等 `caseApprove` 放行。
 - **PII 不落库**:`privacyFindings[].snippet` 是掩码后的(`138****5678`),真实 PII 只在内存里过一遍。
-- **凭据不落盘**:token 只从环境变量读取,仅内存持有。
+- **凭据不落盘**:token / AK / SK 只在服务端或进程内存中,绝不打日志、不进报告;素材上传走云函数免密钥通道,技能内不内联任何对象存储凭据。
 
 详见 `SKILL.md` 与仓库 `CLAUDE.md` / `.claude/rules/data-and-compliance.md`。
 

+ 80 - 3
SKILL.md

@@ -2,7 +2,7 @@
 name: skill-case-get
 description: "案例采集 / 拆解 / 归档。把各种素材(连续多图 / 九宫格、Word .docx、PPT .pptx、图文混合、裸图、视频)拆解为「案例信息」与「素材数组 materialAssets[]」,做合规判断(能否入库 / 客诉负面 / 隐私发现)与打标(学校别名归一 + 跟进状态七段 + 亮点/场景/异议),最后经云函数 caseSubmit 落到待审清单。适用场景:(1) 同事连发 6/9 张图其实是同一个素材,需要按九宫格阅读顺序编号归档;(2) 一份 Word/PPT 案例稿要抽出文字说明并拆出内嵌配图;(3) 判断这堆素材能不能进案例库、是不是客诉负面事件;(4) 按案例库标签体系打标并归一学校别名(IC → 帝国理工)。本 Skill 只处理已获授权素材。"
 description_en: "Case intake, decomposition and archiving. Turns raw material (image grids, .docx, .pptx, mixed text+image, bare images, video) into case fields plus an ordered materialAssets[] array, runs compliance triage and tagging, then submits to the review queue via the caseSubmit cloud function. Use for: (1) a run of 6/9 images that is ONE material needing 3x3 reading-order numbering, (2) extracting text and embedded media from Word/PPT, (3) deciding whether material may enter the case library or is a complaint/negative event, (4) tagging against the case-library tag system and normalising school aliases (IC -> Imperial College London). This skill only processes authorised material."
-version: 1.0.0
+version: 1.0.1
 author: Yuyang001 (FmodeAgent)
 license: MPL-2.0
 copyright: "Copyright (c) 2026 未来飞马 Fmode"
@@ -51,6 +51,10 @@ tags: [未来飞马, 智能体技能, 超级技能, 服务级, 案例采集, 案
   该函数在**宿主自带多模态**时(FmodeCode / Claude Code)会返回读图指令而非调 API,此时由宿主 Agent 用自己的 Read 工具读图后补全。
   可用 `--vision-model <name>` 覆盖,或 `--skip-vision` 跳过。
 - **音视频**:复用兄弟技能 `skill-listen` 的 `listen-runner.mjs`(走 Fmode 网关,凭据仅服务端)。
+- **素材上传**:复用兄弟技能 `fmode-storage` 的 `uploader.mjs`(第 0 级云函数免密钥通道)。
+  找不到时 `uploadMaterial` 抛 `StorageUnavailableError`,ingest 会降级(`url` 留空 + 告警)而**不崩**。
+  可用 `CASE_STORAGE_SCRIPT` 指定其路径;本部署该技能在平台技能目录(`/opt/data/skills/fmode-storage`),
+  `resolveSiblingScript` 已覆盖 `CASE_SKILLS_DIR` / `$HERMES_HOME/skills` / `/opt/data/skills`。
 - **python3**:解包 `.docx` / `.pptx`,**只用标准库**(本机没有 python-docx / python-pptx)。
 - **ffmpeg / ffprobe**:视频抽帧、探测时长(可选)。
 
@@ -148,6 +152,78 @@ if (report.canSubmit) await runSubmit({ package: report.artifacts.casePackage })
 - **字典自进化**:采集到字典里没有的新取值时追加进 `references/tag-dictionary.json`
   (`--no-learn` 可关;本技能**只改文件、不自动 push**)。
 
+## 授权依据(企业微信场景)
+
+企微会话里系统提示「**对方默认同意存档会话内容**」时,该截图可作为 `--authorization authorized` 的依据(会话内双方均已收到该告知)。**没有**这行提示的截图/记录一律按 `pending` 处理,不得擅自升为 authorized。授权状态是硬门:未授权 `submit` 必拒。
+
+## 素材上传(`--upload`,默认开)
+
+案例检索侧只看 `materialAssets[].url`,本地路径不算数。**ingest 现在会真正上传本地素材**,
+拿到公网 URL 再写进案例包——不再依赖人工先传一遍。
+
+URL 优先级(`resolveAssetUrl`):
+
+| 顺序 | 条件 | 结果 |
+| --- | --- | --- |
+| ① | `item.url` 已存在(文档/清单里带来的远程 URL) | 原样使用,**不触发上传** |
+| ② | 本地文件且 `--upload` 开(默认) | 调 `uploadMaterial()` 真上传,拿公网 URL |
+| ③ | ② 失败 / 关闭时的显式回退 | `--asset-base-url <前缀>` 派生(旧行为,标记 `isFallbackUrl`) |
+| ④ | 都没有 | `url` 留空 + `warnings` 明确提示(**绝不伪造 URL**) |
+
+**通道**:走兄弟技能 `fmode-storage` 的**第 0 级通道**(云函数预签名直传,免密钥)。
+容器只需 `FMODE_SESSION_TOKEN`,真实 AK/SK 始终留在云函数侧;**技能代码里绝不内联任何对象存储凭据**,
+也**不回落**本地 AK/SK 通道(宁可在 ingest 里降级留空 URL)。
+
+**object key 规范**(相对 key,云函数会自动补 `user/<调用者id>/` 前缀,**别自己加**):
+
+```
+case-library/<YYYYMMDD>/<caseSlug>-<order>.<ext>
+# 例:case-library/20261010/imperial-aero50006-1.png
+#     → https://s3.fmode.cn/user/<uid>/case-library/20261010/imperial-aero50006-1.png
+```
+
+`caseSlug` 取 `--source-ref`,无则取 `--title`,再无则 `case`;`order` 与素材 `order` 一致(1 起)。
+幂等:同一 `(localPath, key)` 在**本进程内**只传一次(Map 缓存),重复调用直接复用回执。
+
+**失败降级(绝不拖垮整批 ingest)**:单张上传失败 → 该素材 `url` 留空、
+push 一条 `上传失败 <file>:<原因>;该素材将无公网 URL`,其余素材照常上传,
+最后再汇总一条 `N 张上传失败`。若原因是兄弟技能缺失/不可用,汇总里会点明「fmode-storage 不可用」。
+**上传失败不会让 ingest 崩**,案例包照常产出(合规红线字段不变:`pending` / `readyForUse=false`)。
+
+**什么时候用 `--no-upload`**:
+
+- 离线自测 / 单测(不想因为环境差异联网);
+- 素材已经在别处传过、只想用 `--asset-base-url` 派生;
+- 环境确实装不了 `fmode-storage`,又接受「url 留空、稍后由归档方补齐」。
+
+```bash
+# 默认:真上传(推荐)
+node scripts/case-intake.mjs ingest --image ./img.jpg --authorization authorized
+
+# 离线:不上传,只按前缀派生
+node scripts/case-intake.mjs ingest --image ./img.jpg --no-upload \
+  --asset-base-url https://s3.fmode.cn/user/<uid>/case-library/20261010
+```
+
+单独传一张排障(也是官方 CLI):
+
+```bash
+node scripts/storage-bridge.mjs --file ./img.png --case-slug imperial-aero50006 --order 1
+# → { "url": "https://s3.fmode.cn/user/<uid>/case-library/<YYYYMMDD>/imperial-aero50006-1.png", "key": "...", "bytes": 12345 }
+```
+
+上传后务必用匿名 GET 复核公网可读(`200 image/png` 且字节数一致)——签名与 Content-Type 绑定,只看上传回执不够。
+`case-package.json` 里 `materialAssets[].uploadKey / uploadBytes / uploadVia` 记录了上传回执,便于追溯。
+
+> `fmode-storage` 未安装时,`uploadMaterial` 抛 `StorageUnavailableError`(明确错误码 `STORAGE_UNAVAILABLE`),
+> 不会静默失败。安装:`npx fmode-storage@latest install`;或用环境变量 `CASE_STORAGE_SCRIPT` 指向其 `uploader.mjs`。
+
+## 打标:学校不在白名单时显式传 --school-canonical
+
+`references/tag-dictionary.json` 的 `dimensions.schoolCanonical.values` 是**有限白名单**(约 33 所,多为 QS 百强英/澳/美/港/新名校),**不含西交利物浦大学**等中外合作办学主体;模型也不会把「西浦」直接归一到未收录的书写名。
+
+所以遇到白名单外的学校(如西浦 XJTLU、宁波诺丁汉、昆山杜克),**采集时显式传** `--school-canonical <标准中文书写名>`,并同时补 `--country`;否则 `schoolCanonical` 会留空、检索必然漏检。事后建议把该校补进字典白名单。
+
 ## 合规判断(能力 5)
 
 | 判定 | 落到哪 |
@@ -171,7 +247,8 @@ if (report.canSubmit) await runSubmit({ package: report.artifacts.casePackage })
 
 - 本部署(`server.lumistedu.com`)**没有可用的函数运行时**:`/api/functions` 只是定位器端点、`/parse/functions/*` 只有 Parse 原生注册表。项目的真实数据架构就是浏览器端 Parse 直连(见 `web/src/app/core/data.service.ts`),归档走同一路径。
 - **仍受同一套红线约束**:`reviewStatus='pending'`、`readyForUse=false`、`tenantId + idempotencyKey` 幂等 —— 新案例只进待审清单,不进公共素材库。
-- 通道自检:`node scripts/case-intake.mjs probe`(只读,验证配置与连通性)。
+- `submit` 必然以 `fallback: 'cloud-unavailable -> parse-direct'` 返回,**这是本部署的正常路径、不是故障**:不要为了"修好云函数"去反复调 `/api/functions`(任何 path 都 500,连通性探针也一样)。
+- 通道自检:`node scripts/case-intake.mjs probe`(只读,应返回 `ok:true, hasMasterKey:true`)。
 - 服务端特权密钥只从环境变量 `CASE_PARSE_MASTER_KEY` 或 0600 的 `~/.fmode/config/enterprise/current.json` 读取,只进请求头,**不落盘、不打印**。
 - 线上 `CaseAsset` 字段类型:`targetCustomer` / `resultEvidence` / `outcome` / `groupInfo` 是 **Object**(不是字符串),本通道已按此对齐。
 
@@ -198,6 +275,6 @@ npx --yes skill-case-get@latest install     # 用户级 → ~/.claude/skills/ski
 ## 注意事项
 
 - `.docx` / `.pptx` 解包走 python3 标准库,**不要**为此引入 python-docx / python-pptx / 任何 npm 包。
-- 本机不存储 / 不上传二进制:本地素材的 `url` 由 `--asset-base-url` 派生;没给就会在 `warnings` 里提示(不伪造 URL)。
+- 本地素材默认**真上传**(`--upload` 开,走 fmode-storage 第 0 级免密钥通道)拿公网 URL;`--no-upload` 才回落到 `--asset-base-url` 派生。两种情况下都**不伪造 URL**,拿不到就留空并在 `warnings` 里说清楚。
 - 视频抽帧是均匀取点(默认 6 帧,`--frame-count` 调整);转写走网关按音频时长计费。
 - 枚举统一**小写**(对齐线上:`pending` / `authorized`),展示层再做中文化。

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "skill-case-get",
-  "version": "1.0.0",
+  "version": "1.0.1",
   "description": "案例采集 / 拆解 / 归档技能包:把连续多图、Word、PPT、图文混合、裸图等素材拆解为案例字段与素材数组(materialAssets),做合规判断与打标,经云函数 caseSubmit 入库待审。",
   "type": "commonjs",
   "main": "./scripts/case-intake.mjs",

+ 126 - 10
scripts/case-intake.mjs

@@ -42,13 +42,14 @@ 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,
+  readJson, reorderAssets, resolvePython, saveDictionary, sha1, 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';
 
@@ -314,6 +315,71 @@ export async function runPlan(options = {}) {
 // 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}>}
+ */
+
+// 文件内容指纹:避免两个不同文件落成同一个 object key 互相覆盖(读不到则退化为 0)。
+function contentFingerprint(filePath) {
+  try {
+    const buf = fs.readFileSync(filePath);
+    return `${buf.length}-${sha1(buf.toString('base64')).slice(0, 12)}`;
+  } catch {
+    return '0';
+  }
+}
+
+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;
+  // key 区分度:优先 显式 slug / 来源引用 / 标题;都没有时用「文件内容指纹」兜底。
+  // 否则两个不同案例都会落成 case-1.png → 同日撞 key 互相覆盖(真实事故:两张不同图同 key)。
+  let slugSeed = options.caseSlug;
+  if (!slugSeed) {
+    slugSeed = localPath ? `c${contentFingerprint(localPath)}` : 'case';
+  }
+  if (localPath && options.upload) {
+    try {
+      const upload = await uploadMaterial(localPath, {
+        tenantId: options.tenantId,
+        caseSlug: slugSeed,
+        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;
@@ -405,6 +471,11 @@ export async function runIngest(options = {}) {
   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({
@@ -414,14 +485,36 @@ export async function runIngest(options = {}) {
       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;
+      // 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: derivedUrl,
+        url: assetUrl,
         localPath: item.localPath || '',
         label: item.label || '',
         ocrText: item.ocrText || '',
@@ -430,12 +523,23 @@ export async function runIngest(options = {}) {
         role: item.role || 'material',
       }, materialAssets.length);
       assetProblems.push(...problems);
-      materialAssets.push(asset);
+      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) {
-    warnings.push(`${missingUrlCount} 个素材只有本地路径、没有 URL:用 --asset-base-url <CDN前缀> 派生,或由归档方补齐 url 后再 submit(检索侧只看 url)。`);
+    const detail = options.upload === false
+      ? '(--no-upload 已关闭上传;可改用 --asset-base-url <CDN前缀> 派生)'
+      : `已尝试上传但未拿到公网地址(${uniq(fallbackItems).slice(0, 5).join('、') || '见上方上传失败原因'})。请确认 fmode-storage 可用(免密钥通道)或改用 --asset-base-url <CDN前缀> 派生(检索侧只看 url)`;
+    warnings.push(`${missingUrlCount} 个素材只有本地路径、没有 URL:${detail}。`);
   }
 
   // 全局重编号:materialAssets 已按「分组顺序 → 组内 order」构建,
@@ -896,11 +1000,13 @@ function parseCli(argv) {
     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' },
@@ -918,6 +1024,8 @@ function parseCli(argv) {
       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 },
@@ -946,6 +1054,7 @@ function parseCli(argv) {
 function collectInputs(values, positionals) {
   return [
     ...values.input,
+    ...values.files,
     ...values.image,
     ...values.doc,
     ...values.deck,
@@ -1005,7 +1114,7 @@ async function cliMain(argv) {
   }
 
   // plan / ingest
-  const outDir = values['out-dir']
+  const outDir = values['out-dir'] || values.out
     || path.join(outputsDir(), `${values['source-ref'] ? `${slug(values['source-ref'])}-` : ''}${newRunId()}`);
 
   const options = {
@@ -1020,6 +1129,8 @@ async function cliMain(argv) {
     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'],
@@ -1117,6 +1228,7 @@ const HELP_TEXT = `skill-case-get —— 案例采集 / 拆解 / 归档
   --deck deck.pptx                       PPT
   --video clip.mp4                       视频(抽帧 + 转写)
   --input <file|dir>                     自动按扩展名分流
+  --files <file|dir>                     同 --input(别名)
 
 采集选项:
   --authorization authorized|pending|denied   授权状态(硬门:仅 authorized 可入库,默认 pending)
@@ -1126,9 +1238,13 @@ const HELP_TEXT = `skill-case-get —— 案例采集 / 拆解 / 归档
   --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)
+  --asset-base-url <base>                     本地素材 URL 前缀(CDN / Parse Files / 对象存储目录)**回退用**
+                                               (或环境变量 CASE_ASSET_BASE_URL)
+  --upload                                    真正上传本地素材到对象存储,拿公网 URL(默认开)
+  --no-upload                                 关闭上传;url 只按 --asset-base-url 派生(离线自测用)
+                                               object key 规范:case-library/<YYYYMMDD>/<caseSlug>-<order>.<ext>
   --out-dir <dir>                             产物目录(默认 <cwd>/case-get-outputs/<runId>)
+  --out <dir>                                 同 --out-dir(别名)
   --skip-vision / --skip-video / --no-learn   关闭图片理解 / 视频处理 / 字典自进化
   --submitted-by <userId>                     提交人
 

+ 54 - 1
scripts/classify.mjs

@@ -56,6 +56,46 @@ export function findPrivacy(text) {
   return findings;
 }
 
+/**
+ * 把图片理解(vision)给出的 PII 线索转成与规则同一形状的隐私发现。
+ * 为什么需要:截图里的姓名/头像只存在于像素中,文本正则永远扫不到;
+ * 不并进来就会「图里有 4 个姓名,privacyFindings 却是 0 条」——审核员会被误导。
+ * 片段同样一律掩码(只留首字 + 圆点),绝不落完整 PII。
+ * @param {Array<{field?:string, snippet?:string}>} hints
+ * @returns {Array<{field:string, rule:string, snippet:string}>}
+ */
+export function normalizePiiHints(hints) {
+  const out = [];
+  for (const hint of Array.isArray(hints) ? hints : []) {
+    if (!hint || !hint.field) continue;
+    const field = String(hint.field);
+    const raw = String(hint.snippet || '').trim();
+    let snippet;
+    if (field === '头像') {
+      snippet = '头像:[图片内容已标记]';
+    } else if (!raw) {
+      snippet = `${field}:[已掩码]`;
+    } else {
+      snippet = `${raw.slice(0, 1)}${'*'.repeat(Math.max(1, Math.min(raw.length, 8) - 1))}`;
+    }
+    out.push({ field, rule: 'vision', snippet: truncate(snippet, 40) });
+  }
+  return out;
+}
+
+/** 按 字段+片段 去重(同一姓名被规则和 vision 各命中一次时只留一条)。 */
+function dedupeFindings(findings) {
+  const seen = new Set();
+  const out = [];
+  for (const f of findings) {
+    const key = `${f.field}|${f.snippet}`;
+    if (seen.has(key)) continue;
+    seen.add(key);
+    out.push(f);
+  }
+  return out;
+}
+
 // ---------------------------------------------------------------------------
 // 合规关键词
 // ---------------------------------------------------------------------------
@@ -390,6 +430,7 @@ export function extractOutcome(corpus) {
  *   - authorizationStatus: 'authorized' | 'pending' | 'denied' | 其它
  *   - hasMaterials: boolean 是否有可用素材
  *   - fields: 抽取出来的案例字段(用于判断信息完整度)
+ *   - piiHints: 图片理解给出的隐私线索 [{field, snippet}]
  * @returns {{authorizationOk:boolean, riskFlags:string[], privacyFindings:object[], complianceBlockers:string[], reviewHint:string}}
  */
 export function assessCompliance(input = {}) {
@@ -401,7 +442,11 @@ export function assessCompliance(input = {}) {
     if (matchesAny(corpus, needles)) riskFlags.push(flag);
   }
 
-  const privacyFindings = findPrivacy(corpus);
+  // 文本正则 + 图片理解线索合并:截图类素材的姓名/头像只在像素里,必须两条腿走路
+  const privacyFindings = dedupeFindings([
+    ...findPrivacy(corpus),
+    ...normalizePiiHints(input.piiHints),
+  ]);
   if (privacyFindings.length) riskFlags.push('PII_RISK');
 
   // 主题相关性
@@ -491,11 +536,19 @@ export function classifyCase(input = {}) {
   const fit = inferFitStatus(tags);
   const caseFields = deriveCaseFields(split.descriptionCorpus || split.materialCorpus, groups, tags, fit);
 
+  // 图片理解给出的隐私线索(姓名/头像/手机号等)汇总后与文本正则一起判断
+  const piiHints = [
+    ...(Array.isArray(input.piiHints) ? input.piiHints : []),
+    ...visionResults.flatMap((r) => (Array.isArray(r.piiHints) ? r.piiHints : [])),
+    ...groups.flatMap((g) => (g.items || []).flatMap((it) => (Array.isArray(it.piiHints) ? it.piiHints : []))),
+  ];
+
   const compliance = assessCompliance({
     corpus: fullCorpus,
     authorizationStatus: input.authorizationStatus,
     hasMaterials: groups.some((g) => g.count > 0),
     fields: caseFields,
+    piiHints,
   });
 
   // 字典自进化候选:文本里出现、字典里没有、但强线索命中的高价值短语

+ 22 - 1
scripts/lib.mjs

@@ -72,19 +72,40 @@ export function shortHash(value, length = 10) {
 
 function candidateRoots(envVar) {
   const roots = [];
-  if (envVar && process.env[envVar]) roots.push(process.env[envVar]);
+  // 注意: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;

+ 299 - 0
scripts/storage-bridge.mjs

@@ -0,0 +1,299 @@
+// 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.
+/**
+ * 素材上传桥(复用兄弟技能 fmode-storage)
+ *
+ * 职责:把本地素材文件真正传到对象存储,拿到**公网可读 URL**,供 materialAssets[].url 使用。
+ *
+ * 为什么存在:案例检索侧只看 materialAssets[].url,本地路径不算数。此前只有
+ * `--asset-base-url` 前缀派生(base + 文件名)——没给前缀就留空,给了也只是拼一个
+ * 并不存在的假链接,比空字符串更坏。本桥接把「先上传、再拿真 URL」落成代码。
+ *
+ * 走 fmode-storage 的**第 0 级通道**(云函数免密钥预签名直传):
+ *   - 容器只需 FMODE_SESSION_TOKEN,真实 AK/SK 永远留在云函数侧,**技能里绝不内联任何凭据**;
+ *   - 云函数在服务端强制把对象挂到调用者自己的前缀下:user/<调用者id>/<key>;
+ *     因此这里的 key 一律是**相对 key**(不带 user/ 前缀),公网 URL 由上传回执给出。
+ *
+ * object key 规范:case-library/<YYYYMMDD>/<caseSlug>-<order>.<ext>
+ *
+ * 幂等:同一 (localPath, key) 在**本进程内**只传一次(Map 缓存),重复调用直接复用回执。
+ *       跨进程不做要求。
+ *
+ * 安全:绝不打印 token / AK / SK / sessionToken(含错误分支);上传回执里也没有密钥。
+ */
+
+import fs from 'node:fs';
+import path from 'node:path';
+import { parseArgs } from 'node:util';
+import { resolveSiblingScript, shortHash } from './lib.mjs';
+
+const STORAGE_REL = path.join('fmode-storage', 'scripts', 'uploader.mjs');
+
+// 云函数自动补 user/<id>/ 前缀,这里只写相对 key
+const KEY_NAMESPACE = 'case-library';
+
+/** 兄弟技能 fmode-storage 不可用(未安装 / 导入失败)。 */
+export class StorageUnavailableError extends Error {
+  constructor(message, detail = {}) {
+    super(message);
+    this.name = 'StorageUnavailableError';
+    this.code = 'STORAGE_UNAVAILABLE';
+    this.detail = detail;
+  }
+}
+
+/**
+ * 定位兄弟技能 fmode-storage 的 uploader.mjs。
+ * 可用环境变量 CASE_STORAGE_SCRIPT 覆盖(与 vision-bridge 的 CASE_VISION_SCRIPT 同构)。
+ * @returns {string|null} 找不到返回 null(调用方给可执行的降级提示,不抛栈)
+ */
+export function resolveStorageScript() {
+  return resolveSiblingScript('CASE_STORAGE_SCRIPT', STORAGE_REL);
+}
+
+// 本进程内已上传缓存:`${localPath}::${key}` → { url, key, bytes }
+const uploadCache = new Map();
+
+/** 清空本进程内上传缓存(测试用)。 */
+export function resetUploadCache() {
+  uploadCache.clear();
+}
+
+/** 文件名 slug 化:中文/空格/特殊字符全部替换成 '-',避免坏链接。 */
+export function slugify(value, max = 40) {
+  const slug = String(value ?? '')
+    .trim()
+    .replace(/[^\w.-]+/g, '-')
+    .replace(/-{2,}/g, '-')
+    .replace(/^-+|-+$/g, '');
+  return slug.slice(0, max);
+}
+
+/** 归一化扩展名:去掉点、小写;空则回落到 bin。 */
+export function normalizeExt(ext) {
+  const clean = String(ext ?? '').trim().replace(/^\.+/, '').toLowerCase().replace(/[^a-z0-9]/g, '');
+  return clean || 'bin';
+}
+
+/** 紧凑日期戳 YYYYMMDD(本地时区,和案例包其它时间字段口径一致)。 */
+export function dateStamp(date = new Date()) {
+  const pad = (n) => String(n).padStart(2, '0');
+  return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}`;
+}
+
+/**
+ * 组装 object key(**相对 key**,云函数会自动补 user/<id>/ 前缀,别自己加)。
+ * 规范:case-library/<YYYYMMDD>/<caseSlug>-<order>.<ext>
+ *
+ * caseSlug 缺省一律用调用方传入值;调用方须保证其区分度(无标题时用内容指纹兜底,
+ * 见 case-intake.resolveAssetUrl),否则两个不同案例会在同一 <日期> 目录里撞 key 互相覆盖。
+ * @returns {string}
+ */
+export function buildObjectKey({ caseSlug, order, ext, date } = {}) {
+  const slug = slugify(caseSlug) || 'case';
+  const seq = Number.isFinite(Number(order)) && Number(order) > 0 ? Math.trunc(Number(order)) : 1;
+  return `${KEY_NAMESPACE}/${dateStamp(date)}/${slug}-${seq}.${normalizeExt(ext)}`;
+}
+
+/** 动态加载兄弟技能导出(路径用 file:// URL,避免 Windows 盘符/空格问题)。 */
+async function loadUploader() {
+  const script = resolveStorageScript();
+  if (!script) {
+    throw new StorageUnavailableError(
+      `未找到 fmode-storage(期望 ${STORAGE_REL})。请先安装:npx fmode-storage@latest install;`
+      + '或用环境变量 CASE_STORAGE_SCRIPT 指向 uploader.mjs。若本机确实无法上传,请用 --no-upload 关闭上传。',
+      { expected: STORAGE_REL },
+    );
+  }
+  let mod;
+  try {
+    mod = await importQuietly(new URL(`file://${script.split(path.sep).join('/')}`).href);
+  } catch (error) {
+    throw new StorageUnavailableError(`加载 fmode-storage 失败:${error.message}`, { script });
+  }
+  const missing = ['putViaCloudFunction', 'publicUrl', 'guessMime', 'normalizeKey']
+    .filter((name) => typeof mod[name] !== 'function');
+  if (missing.length) {
+    throw new StorageUnavailableError(
+      `fmode-storage 版本不兼容:uploader.mjs 缺少导出 ${missing.join(', ')}。请升级该技能。`,
+      { script, missing },
+    );
+  }
+  return { mod, script };
+}
+
+/**
+ * 静默导入:uploader.mjs 在**模块顶层**跑了一次 main(),而它的命令列表不含我们传的 argv,
+ * 于是会往 **stdout** 打一行「用法: init | test | put | setacl | config」。
+ * 那一行会污染 `case-intake.mjs ingest` 的 JSON stdout(调用方按 JSON 解析),
+ * 所以导入窗口内的 console.log 一律改投 stderr —— 不丢信息,也不污染 stdout。
+ */
+async function importQuietly(url) {
+  const originalLog = console.log;
+  const captured = [];
+  console.log = (...args) => { captured.push(args.map((arg) => String(arg)).join(' ')); };
+  try {
+    return await import(url);
+  } finally {
+    console.log = originalLog;
+    for (const line of captured) {
+      if (line.trim()) process.stderr.write(`[fmode-storage] ${line}\n`);
+    }
+  }
+}
+
+/** 从上传回执取公网 URL:回执自带 publicUrl 优先,其次按 fmode-storage 的 publicUrl() 规则(CDN 前缀)拼。 */
+function urlFromResult(mod, result) {
+  if (result.url) return String(result.url);
+  try {
+    // 第 0 级通道的公网入口固定是 CDN(https://s3.fmode.cn/<key>),不需要 bucket/endpoint
+    return mod.publicUrl({ cdnDomain: 's3.fmode.cn' }, result.key);
+  } catch {
+    return '';
+  }
+}
+
+/**
+ * 上传单个素材。
+ *
+ * @param {string} localPath 本地文件绝对路径
+ * @param {{tenantId?:string, caseSlug?:string, order?:number, ext?:string}} options
+ *        ext 省略时取文件自身扩展名。
+ * @returns {Promise<{url:string, key:string, bytes:number}>}
+ * @throws {StorageUnavailableError} 兄弟技能缺失 / 不可加载
+ * @throws {Error} 上传失败(网络 / 云函数未配置 / 文件读取失败)
+ */
+export async function uploadMaterial(localPath, options = {}) {
+  const file = path.resolve(String(localPath || ''));
+  if (!localPath || !fs.existsSync(file)) {
+    throw new Error(`素材文件不存在:${localPath || '(空)'}`);
+  }
+
+  const ext = options.ext || path.extname(file).replace(/^\./, '');
+  const key = options.key
+    ? String(options.key)
+    : buildObjectKey({ caseSlug: options.caseSlug, order: options.order, ext });
+
+  // 幂等:同 (localPath, key) 本进程内只传一次
+  const cacheKey = `${file}::${key}`;
+  const cached = uploadCache.get(cacheKey);
+  if (cached) return { ...cached };
+
+  const { mod } = await loadUploader();
+
+  // 归一化 key(fmode-storage 会剥离误写的 user/<id>/ 前缀并打 warn 到 stderr——
+  // warn 文案里只有 key,不含任何密钥,可以安全透传)
+  const normalized = mod.normalizeKey(key);
+  const mime = mod.guessMime(file);
+  const namespace = KEY_NAMESPACE;
+
+  let result;
+  try {
+    result = await mod.putViaCloudFunction(
+      file,
+      normalized,
+      namespace,
+      path.basename(normalized),
+      mime,
+    );
+  } catch (error) {
+    throw new Error(`上传通道报错:${error.message}`);
+  }
+
+  if (!result || result.ok !== true || !result.key) {
+    // putViaCloudFunction 返回 null 表示第 0 级通道不可用(无 sessionToken / 云函数未配置 / 网络失败)。
+    // **不回落本地 AK/SK 通道**:技能红线是不在内联任何对象存储凭据,宁可在 ingest 里降级留空 URL。
+    throw new Error(
+      '云函数免密钥上传通道不可用(无 FMODE_SESSION_TOKEN、云函数未配置或网络失败);'
+      + '本技能不内联对象存储凭据,故不回落本地 AK/SK 通道',
+    );
+  }
+
+  const payload = {
+    url: urlFromResult(mod, result),
+    key: String(result.key),
+    bytes: Number(result.bytes) || fs.statSync(file).size,
+  };
+  if (!payload.url) throw new Error(`上传回执未给出公网 URL(key=${payload.key})`);
+
+  uploadCache.set(cacheKey, payload);
+  return { ...payload };
+}
+
+// ---------------------------------------------------------------------------
+// CLI(可选,供人工单传 / 排障;ingest 走的是上面的函数)
+// ---------------------------------------------------------------------------
+
+const HELP_TEXT = `storage-bridge —— 素材上传(复用兄弟技能 fmode-storage,第 0 级免密钥通道)
+
+用法:
+  node storage-bridge.mjs --file ./img.png [--case-slug imperial-aero50006] [--order 1] [--out url.json]
+
+选项:
+  --file <path>       要上传的本地素材(必填)
+  --case-slug <slug>  object key 里的案例短名(默认取文件名主干)
+  --order <n>         object key 里的序号(默认 1)
+  --ext <ext>         覆盖扩展名(默认取文件自身扩展名)
+  --key <objectKey>   直接指定相对 key(给了就忽略 case-slug/order/ext)
+  --out <file>        把结果 JSON 写文件(不给则打印到 stdout)
+
+object key 规范:case-library/<YYYYMMDD>/<caseSlug>-<order>.<ext>(云函数会自动补 user/<id>/ 前缀)
+`;
+
+async function main() {
+  const { values } = parseArgs({
+    options: {
+      file: { type: 'string' },
+      'case-slug': { type: 'string' },
+      order: { type: 'string' },
+      ext: { type: 'string' },
+      key: { type: 'string' },
+      out: { type: 'string' },
+      help: { type: 'boolean', default: false, short: 'h' },
+    },
+    allowPositionals: true,
+  });
+
+  if (values.help) {
+    process.stdout.write(HELP_TEXT);
+    return 0;
+  }
+  if (!values.file) {
+    process.stderr.write('需要 --file <本地素材>\n');
+    return 2;
+  }
+
+  const report = await uploadMaterial(values.file, {
+    caseSlug: values['case-slug'] || path.basename(values.file, path.extname(values.file)),
+    order: values.order ? Number(values.order) : 1,
+    ext: values.ext,
+    key: values.key,
+  });
+  const text = `${JSON.stringify({ ok: true, ...report }, null, 2)}\n`;
+  if (values.out) {
+    fs.mkdirSync(path.dirname(path.resolve(values.out)), { recursive: true });
+    fs.writeFileSync(path.resolve(values.out), text, 'utf8');
+  } else {
+    process.stdout.write(text);
+  }
+  return 0;
+}
+
+const invokedDirectly = process.argv[1] && path.resolve(process.argv[1]).endsWith(path.join('scripts', 'storage-bridge.mjs'));
+if (invokedDirectly) {
+  main().then((code) => process.exit(code)).catch((error) => {
+    process.stderr.write(`storage-bridge 失败:${error.message}\n`);
+    process.exit(error instanceof StorageUnavailableError ? 3 : 1);
+  });
+}
+
+export { STORAGE_REL, KEY_NAMESPACE };

+ 3 - 0
scripts/tests/classify.test.mjs

@@ -262,6 +262,8 @@ test('端到端 ingest:多图(9)+docx+pptx → 案例包,字段对齐共享
     sourceRef: 'e2e-fixture',
     authorizationStatus: 'authorized',
     assetBaseUrl: 'https://cdn.example.invalid/cases/e2e',
+    // 单测不联网:关掉真上传,专门验证 --asset-base-url 派生的旧行为
+    upload: false,
     skipVision: true, // 不调模型:OCR 留空,其余链路全跑
     noLearn: true,
   });
@@ -326,6 +328,7 @@ test('端到端 ingest:未授权时不产出可提交案例包(canSubmit=fal
     inputs: [fixtures.images[0]],
     outDir: path.join(workRoot, 'out'),
     authorizationStatus: 'pending',
+    upload: false, // 单测不联网
     skipVision: true,
     noLearn: true,
   });

+ 339 - 0
scripts/tests/upload.test.mjs

@@ -0,0 +1,339 @@
+// 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/.
+/**
+ * 素材上传(storage-bridge + case-intake 优先级)单测。
+ *
+ * 覆盖任务书要求的 4 类:
+ *   ① 有 item.url(文档自带远程 URL)→ 不触发上传;
+ *   ② --no-upload → 不上传、url 为空且产生 warning;
+ *   ③ 兄弟技能缺失 → StorageUnavailableError,且 ingest 不崩(降级留空 + warning);
+ *   ④ 上传失败 → 单张降级、其余照常,最后汇总「N 张上传失败」;
+ *   ⑤ key 规范:case-library/<YYYYMMDD>/<caseSlug>-<order>.<ext>。
+ *
+ * 全部离线:不真连对象存储。网络相关的用例起本地 HTTP 服务替代云函道。
+ * 注意:storage-bridge 的进程内 upload 缓存在用例间共享,用 resetUploadCache() 隔离。
+ */
+
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import { pathToFileURL } from 'node:url';
+
+import {
+  StorageUnavailableError, buildObjectKey, dateStamp, normalizeExt, slugify,
+  uploadMaterial, resetUploadCache,
+} from '../storage-bridge.mjs';
+import { runIngest, resolveAssetUrl, makeTinyPng } from '../case-intake.mjs';
+
+const SAMPLE_PNG = makeTinyPng(8, 8);
+
+/** 建一个临时素材文件(内容用合成 PNG,不含任何真实素材)。 */
+function makeTempImage(name = 'sample.png', { mtime } = {}) {
+  const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'case-upload-src-'));
+  const file = path.join(dir, name);
+  fs.writeFileSync(file, SAMPLE_PNG);
+  if (mtime) fs.utimesSync(file, mtime, mtime);
+  return file;
+}
+
+/** 每个用例一个独立产物目录,互不污染。 */
+function tempOutDir(label) {
+  return path.join(fs.mkdtempSync(path.join(os.tmpdir(), `case-upload-${label}-`)), 'out');
+}
+
+/** 跑一次 ingest,默认跳过图片理解(离线),只关心 materialAssets[].url。 */
+async function ingestOnce(file, options = {}) {
+  return runIngest({
+    inputs: [file],
+    outDir: options.outDir || tempOutDir('ingest'),
+    authorizationStatus: 'authorized',
+    skipVision: true,
+    noLearn: true,
+    ...options,
+  });
+}
+
+// ---------------------------------------------------------------------------
+// ① key 规范
+// ---------------------------------------------------------------------------
+
+test('buildObjectKey:case-library/<YYYYMMDD>/<caseSlug>-<order>.<ext>(相对 key)', () => {
+  const key = buildObjectKey({
+    caseSlug: 'imperial-aero50006',
+    order: 2,
+    ext: 'png',
+    date: new Date(2026, 9, 10),
+  });
+  assert.equal(key, 'case-library/20261010/imperial-aero50006-2.png');
+  // 绝不能带 user/ 前缀(云函数会自动补,带了会变双层)
+  assert.ok(!key.startsWith('user/'), `key 不应带 user/ 前缀:${key}`);
+});
+
+test('buildObjectKey:slug 化中文/空格、order 兜底 1、ext 归一', () => {
+  assert.equal(
+    buildObjectKey({ caseSlug: '帝国理工 AERO/50006', order: 0, ext: '.PNG', date: new Date(2026, 0, 5) }),
+    'case-library/20260105/AERO-50006-1.png',
+  );
+  assert.equal(
+    buildObjectKey({ caseSlug: '', order: 'x', ext: '', date: new Date(2026, 11, 31) }),
+    'case-library/20261231/case-1.bin',
+  );
+});
+
+test('slugify / normalizeExt / dateStamp 边界', () => {
+  assert.equal(slugify('帝国理工 AERO50006'), 'AERO50006');
+  assert.equal(slugify(''), '');
+  assert.equal(normalizeExt('.JPEG'), 'jpeg');
+  assert.equal(normalizeExt(''), 'bin');
+  assert.equal(dateStamp(new Date(2026, 10, 3)), '20261103');
+});
+
+// ---------------------------------------------------------------------------
+// ② 有 item.url → 不触发上传
+// ---------------------------------------------------------------------------
+
+test('resolveAssetUrl:item.url 已存在 → 原样返回,不调用上传', async () => {
+  process.env.CASE_STORAGE_SCRIPT = '/definitely/not/here/uploader.mjs';
+  try {
+    const resolved = await resolveAssetUrl(
+      { url: 'https://cdn.example.com/a.png', localPath: '/tmp/nope-a.png' },
+      { upload: true, assetBaseUrl: 'https://fallback.example.com' },
+    );
+    assert.equal(resolved.url, 'https://cdn.example.com/a.png');
+    assert.equal(resolved.reason, 'remote-url');
+    assert.equal(resolved.upload, null);
+    assert.equal(resolved.error, null);
+  } finally {
+    delete process.env.CASE_STORAGE_SCRIPT;
+  }
+});
+
+test('ingest:兄弟技能缺失时,本地素材降级留空 + 告警(不崩、不伪造 URL)', async () => {
+  const outDir = tempOutDir('remote');
+  const file = makeTempImage('remote.png');
+  // 用一个「不存在」的兄弟技能路径:若 ingest 试图上传,就会报 StorageUnavailableError。
+  process.env.CASE_STORAGE_SCRIPT = '/definitely/not/here/uploader.mjs';
+  try {
+    const report = await ingestOnce(file, { outDir, assetBaseUrl: '' });
+    const asset = report.casePackage.materialAssets[0];
+    // item.url 不存在(本地文件),所以走的是上传失败降级——确认上传确实被尝试过且没有伪造 URL
+    assert.equal(asset.url, '');
+    assert.ok(report.warnings.some((w) => /上传失败/.test(w)), '应报上传失败');
+  } finally {
+    delete process.env.CASE_STORAGE_SCRIPT;
+  }
+});
+
+// ---------------------------------------------------------------------------
+// ③ --no-upload → 不上传、告警
+// ---------------------------------------------------------------------------
+
+test('resolveAssetUrl:--no-upload 且无 --asset-base-url → 空 url', async () => {
+  const resolved = await resolveAssetUrl(
+    { localPath: '/tmp/whatever.png' },
+    { upload: false, assetBaseUrl: '' },
+  );
+  assert.equal(resolved.url, '');
+  assert.equal(resolved.reason, 'no-url');
+  assert.equal(resolved.upload, null);
+});
+
+test('ingest:--no-upload → url 为空 + 「只有本地路径、没有 URL」warning(不触发上传)', async () => {
+  const file = makeTempImage('no-upload.png');
+  const report = await ingestOnce(file, { upload: false, outDir: tempOutDir('noupload') });
+  const asset = report.casePackage.materialAssets[0];
+  assert.equal(asset.url, '');
+  assert.equal(asset.uploadKey, undefined);
+  assert.equal(report.casePackage.hasImage, false);
+  assert.deepEqual(report.casePackage.imageUrls, []);
+  assert.ok(
+    report.warnings.some((w) => /只有本地路径、没有 URL/.test(w)),
+    `应有缺 URL 告警,实际:${JSON.stringify(report.warnings)}`,
+  );
+  assert.ok(!report.warnings.some((w) => /上传失败/.test(w)), '--no-upload 不应出现上传失败告警');
+});
+
+test('ingest:--no-upload + --asset-base-url → 保留旧派生行为并标记 isFallbackUrl', async () => {
+  const file = makeTempImage('derived.png');
+  const report = await ingestOnce(file, {
+    upload: false,
+    assetBaseUrl: 'https://cdn.example.com/cases',
+    outDir: tempOutDir('derived'),
+  });
+  const asset = report.casePackage.materialAssets[0];
+  assert.equal(asset.url, 'https://cdn.example.com/cases/derived.png');
+  assert.equal(asset.isFallbackUrl, true);
+  assert.equal(report.casePackage.hasImage, true);
+});
+
+// ---------------------------------------------------------------------------
+// ④ 兄弟技能缺失 → StorageUnavailableError,且 ingest 不崩
+// ---------------------------------------------------------------------------
+
+test('uploadMaterial:兄弟技能缺失 → StorageUnavailableError(明确错误,不静默)', async () => {
+  resetUploadCache();
+  const file = makeTempImage('real-file.png');
+  process.env.CASE_STORAGE_SCRIPT = '/definitely/not/here/uploader.mjs';
+  try {
+    await assert.rejects(
+      () => uploadMaterial(file, { caseSlug: 'x', order: 1 }),
+      (error) => {
+        assert.ok(error instanceof StorageUnavailableError, `应为 StorageUnavailableError,实际 ${error.name}`);
+        assert.equal(error.code, 'STORAGE_UNAVAILABLE');
+        assert.match(error.message, /fmode-storage/);
+        return true;
+      },
+    );
+  } finally {
+    delete process.env.CASE_STORAGE_SCRIPT;
+  }
+});
+
+test('ingest:兄弟技能缺失 → 不崩、url 留空、汇总「N 张上传失败」', async () => {
+  resetUploadCache();
+  const file = makeTempImage('missing-sibling.png');
+  process.env.CASE_STORAGE_SCRIPT = '/definitely/not/here/uploader.mjs';
+  try {
+    const report = await ingestOnce(file, { outDir: tempOutDir('missing') });
+    assert.equal(report.casePackage.materialAssets.length, 1);
+    assert.equal(report.casePackage.materialAssets[0].url, '');
+    assert.equal(report.casePackage.materialAssets[0].order, 1);
+    assert.ok(
+      report.warnings.some((w) => /上传失败(missing-sibling\.png)/.test(w) && /fmode-storage/.test(w)),
+      `应有单张上传失败告警,实际:${JSON.stringify(report.warnings, null, 2)}`,
+    );
+    assert.ok(
+      report.warnings.some((w) => /1 张上传失败/.test(w) && /fmode-storage 不可用/.test(w)),
+      `应有汇总告警,实际:${JSON.stringify(report.warnings, null, 2)}`,
+    );
+    // 案例包仍然产出,红线字段不变
+    assert.equal(report.casePackage.expected.reviewStatus, 'pending');
+    assert.equal(report.casePackage.expected.readyForUse, false);
+  } finally {
+    delete process.env.CASE_STORAGE_SCRIPT;
+  }
+});
+
+// ---------------------------------------------------------------------------
+// ⑤ 真上传链路(桩 uploader,零网络:不起 socket、不连对象存储)
+// ---------------------------------------------------------------------------
+
+/**
+ * 生成一个「契约与 fmode-storage 一致」的桩 uploader,可精确模拟上传成功 / 失败。
+ * 只验证 storage-bridge 与 case-intake 的编排,不去重测 fmode-storage 自己的 HTTP 客户端
+ * (那是兄弟技能的职责)。
+ */
+function makeStubUploader({ failKeys = [] } = {}) {
+  const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'fake-storage-'));
+  const stub = path.join(dir, 'uploader.mjs');
+  fs.writeFileSync(stub, `
+import fs from 'node:fs';
+import path from 'node:path';
+const FAIL_KEYS = ${JSON.stringify(failKeys)};
+export const putCalls = [];
+export function guessMime(file) {
+  return path.extname(file).toLowerCase() === '.png' ? 'image/png' : 'application/octet-stream';
+}
+export function normalizeKey(key) { const m = String(key || '').match(/^user\\/[^/]+\\/(.+)$/); return m ? m[1] : key; }
+export function publicUrl(cfg, key) { return 'https://s3.fmode.cn/' + key; }
+export async function putViaCloudFunction(file, objectKey, namespace, name, mimeType) {
+  const buf = fs.readFileSync(file);
+  const rel = objectKey || 'anon';
+  putCalls.push(rel);
+  if (FAIL_KEYS.some((f) => rel.includes(f))) return null;   // 模拟该通道不可用
+  const key = 'user/u-test/' + rel;
+  return { ok: true, key, url: 'https://s3.fmode.cn/' + key, bytes: buf.length, via: 'level0:stub' };
+}
+`, 'utf8');
+  return stub;
+}
+
+test('uploadMaterial:走第 0 级通道 → 返回公网 URL / key / bytes', async () => {
+  resetUploadCache();
+  const stub = makeStubUploader();
+  process.env.CASE_STORAGE_SCRIPT = stub;
+  try {
+    const file = makeTempImage('upload-me.png');
+    const result = await uploadMaterial(file, { tenantId: 'lumi', caseSlug: 'upload-me', order: 3 });
+    assert.match(result.url, /^https:\/\/s3\.fmode\.cn\/user\/u-test\/case-library\/\d{8}\/upload-me-3\.png$/);
+    assert.equal(result.key, `user/u-test/case-library/${dateStamp()}/upload-me-3.png`);
+    assert.equal(result.bytes, SAMPLE_PNG.length);
+  } finally {
+    delete process.env.CASE_STORAGE_SCRIPT;
+  }
+});
+
+test('uploadMaterial:同一 (localPath, key) 只传一次(进程内幂等)', async () => {
+  resetUploadCache();
+  const stub = makeStubUploader();
+  process.env.CASE_STORAGE_SCRIPT = stub;
+  try {
+    const file = makeTempImage('same.png');
+    const first = await uploadMaterial(file, { caseSlug: 'same', order: 1 });
+    const second = await uploadMaterial(file, { caseSlug: 'same', order: 1 });
+    assert.deepEqual(second, first);
+    const calls = (await import(pathToFileURL(stub).href)).putCalls;
+    assert.equal(calls.length, 1, '重复上传应命中缓存,只调一次');
+  } finally {
+    delete process.env.CASE_STORAGE_SCRIPT;
+  }
+});
+
+test('ingest:上传成功 → materialAssets[].url 填真 URL,imageUrls / hasImage 随之派生', async () => {
+  resetUploadCache();
+  const stub = makeStubUploader();
+  process.env.CASE_STORAGE_SCRIPT = stub;
+  try {
+    const file = makeTempImage('real-upload.png');
+    const report = await ingestOnce(file, {
+      outDir: tempOutDir('real'),
+      sourceRef: 'wx-e2e',
+      title: '帝国理工 AERO50006 案例',
+    });
+    const asset = report.casePackage.materialAssets[0];
+    assert.match(asset.url, /^https:\/\/s3\.fmode\.cn\/user\/u-test\/case-library\/\d{8}\//);
+    assert.equal(asset.uploadVia, 'cloud-function');
+    assert.ok(asset.uploadBytes > 0);
+    assert.equal(asset.isFallbackUrl, undefined);
+    assert.deepEqual(report.casePackage.imageUrls, [asset.url]);
+    assert.equal(report.casePackage.hasImage, true);
+    assert.ok(!report.warnings.some((w) => /上传失败/.test(w)));
+  } finally {
+    delete process.env.CASE_STORAGE_SCRIPT;
+  }
+});
+
+test('ingest:单张上传失败 → 该张留空并告警,其余素材照常上传(不拖垮整批)', async () => {
+  resetUploadCache();
+  // 同一案例内所有素材共享 caseSlug,靠 order 区分:只让第 2 张(-2.png)失败
+  const stub = makeStubUploader({ failKeys: ['-2.png'] });
+  process.env.CASE_STORAGE_SCRIPT = stub;
+  try {
+    // 明确时间差,确保 a、b 不被分组逻辑并成一组(否则 order/key 会变)
+    const fileA = makeTempImage('a.png', { mtime: new Date('2026-10-10T00:00:00Z') });
+    const fileB = makeTempImage('b.png', { mtime: new Date('2026-10-10T00:10:00Z') });
+    const report = await runIngest({
+      inputs: [fileA, fileB],
+      outDir: tempOutDir('failbatch'),
+      authorizationStatus: 'authorized',
+      skipVision: true,
+      noLearn: true,
+    });
+    const [assetA, assetB] = report.casePackage.materialAssets;
+    assert.equal(report.casePackage.materialAssets.length, 2, '两张素材都要产出');
+    assert.match(assetA.url, /^https:\/\/s3\.fmode\.cn\/user\/u-test\/case-library\//, 'a 仍应上传成功');
+    assert.equal(assetB.url, '', 'b 上传失败 → url 留空');
+    assert.ok(report.warnings.some((w) => /上传失败(b\.png)/.test(w)), '应定位到具体失败文件');
+    assert.ok(report.warnings.some((w) => /1 张上传失败/.test(w)), '应有汇总告警');
+    // 红线字段不变:仍只进待审清单
+    assert.equal(report.casePackage.expected.reviewStatus, 'pending');
+    assert.equal(report.casePackage.expected.readyForUse, false);
+  } finally {
+    delete process.env.CASE_STORAGE_SCRIPT;
+  }
+});

+ 1 - 1
skill-package-manifest.json

@@ -1,6 +1,6 @@
 {
   "name": "skill-case-get",
-  "version": "1.0.0",
+  "version": "1.0.1",
   "description": "FmodeCode / Claude Code 独立技能包:案例采集 / 拆解 / 归档。把连续多图(九宫格)、Word(.docx)、PPT(.pptx)、图文混合、裸图、视频等异构素材拆解为「案例信息」与「素材数组 materialAssets[]」,做合规判断(riskFlags / privacyFindings)、打标(标签字典 + 学校别名归一),最后经云函数 caseSubmit 入库到待审清单(reviewStatus=pending, readyForUse=false)。.docx/.pptx 用 python3 标准库 zipfile + ElementTree 解包,无第三方依赖;图片复用 skill-vision,音视频复用 skill-listen。",
   "plugin": "skill-case-get",
   "skills": [