feat(llm): 用量落库 + 只读汇总端点 + 面板用量小卡(契约 v0.64)
- model 层新增可选 UsageSink(nil ⇒ 逐字不变,既有用例零改动) - 组合根注入 UsageWriter:有界队列 1024 + 单写者 + 批量事务(128/批、2s 兜底), 队列满丢弃 + 限流 WARN,绝不阻塞模型调用;sink 挂 RuntimeLLM(热重载不断) - 迁移 0017:表 llm_usage(13 列,session_id 可空)+ idx_created_at + idx_session_id - 新端点 GET /api/v1/admin/llm/usage(owner|admin 读;缺省近 24h、上限 90 天、 超限 400 不静默裁剪;hitRate = cacheHit/(cacheHit+cacheMiss),分母 0 ⇒ 0; 绝不含凭据与正文;未接线/查库失败 ⇒ 500 usage_unavailable) - LLM_USAGE_RETENTION(默认 720h,≤0 = 关闭清理):启动清一次 + 每小时 - 面板 LLM 页「用量(近 24h)」小卡 + Docs 两份同步 v0.64 - 已知缺口(如实登记):session_id 恒 NULL,需 loop 侧在请求上打标,留列后接
This commit is contained in:
1 parent
18381f65fd
commit
be38651ba4
32 files changed
+2233
-44
No files matched your search
+42
-5
@@ -212,6 +212,8 @@
|
||||
|
||||
> **发现的不一致(如实登记,2026-10-04 复核)**:design/24 §9.2 与 §9.13 写「白名单 **22** 项——原文的 23 项是笔误,以代码为准」,但 **B1(commit `f82c76d`)纳入 `.pdf` 后代码已是 23 项**(`internal/runtime/documents/store.go` 的 `ExtAllowed` = 22 个文本类 + `.pdf`;`internal/services/gateway/documents.go` 的名字形态校验经它判定)——**以代码的 23 项为准**;design/24 §9.2/§9.13/§9.4 第 1 条(「不引解析库」)**均已滞后于 B1 实现**(PDF 文本层已用纯 Go 解析库落地;扫描件与 Office 仍诚实拒绝),本次只改 Docs、**未改 DevDocs**。另:design/24 §6.2 的 `document_unreadable`(400) **在代码里没有对应 REST 错误码**,它只以 `document-read` 的工具错误形态存在(见本节 ⑤),客户端可在 DTO 里看到该文档的 `documents[].status = "unreadable"`。
|
||||
|
||||
> **契约版本 `v0.64`**(**LLM 用量落库 + 只读汇总端点 + 面板用量小卡**,依据主人 2026-10-04 夜裁决「**2.4 落**」(在「usage 落库」「只看日志」之间选前者)与 T1(commit `72b95df`,已在每次模型调用后记结构化用量日志但**只落日志**)。**一句话**:把那条日志里的**非敏感**样本落进新表 `llm_usage`(迁移 `0017`),并新增一个只读汇总端点与一张面板小卡 —— 从此看得见趋势与按用途/模型的成本,而**写入绝不阻塞模型调用**。**三档标注**:**纯增量** = 新端点 `GET /api/v1/admin/llm/usage`、新迁移 `0017_llm_usage`、新 env 键 `LLM_USAGE_RETENTION`(默认 `720h`,**≤0 = 关闭清理**)、面板 LLM 页新小卡、前端新 API `adminLlmUsage()`;**行为变更** = 无(既有端点 / 帧 / 字段形状一字未动;`model` 层新增的是**可选** sink,**不注入时行为逐字不变**——既有 model 用例零改动即全绿);**破坏性** = 无。**A. 写入(关键取舍)**:`model` 层**不直接开库**,只在既有的 `logUsage` 处多一个**可选**接口 `model.UsageSink`(`nil` = 只记日志,逐字等于引入本版之前);组合根注入 `model.UsageWriter` —— **有界队列(1024)+ 单写者 + 批量事务(128/批,2s 兜底)**,队列满**丢弃并限流 WARN(30s 一条)**,**绝不阻塞、绝不 panic、绝不无界增长**;整批落库失败只记一条带来源的 WARN。sink 挂在 `RuntimeLLM` 上(而不是每个适配器上),因此面板每次热重载 models.json 重建适配器时,落库**不会静默断一次**;**每一条链**(主/备/本地第三级/按角色跨协议)都带上它。**B. 表与索引**:`llm_usage`(`id/session_id/purpose/adapter/model/prompt_tokens/completion_tokens/cache_hit_tokens/cache_miss_tokens/reasoning_tokens/total_tokens/took_ms/created_at`)+ `idx_llm_usage_created_at`(汇总与清理的依赖)+ `idx_llm_usage_session_id`。**`session_id` 当前恒为 `NULL`**(模型层无会话概念;要填需 loop 在请求上打标,属本轮并行切片在途文件,故留列后接)。**C. 读取**:`GET /api/v1/admin/llm/usage?since=&until=&purpose=`(**owner|admin 读**,与同页 `/admin/llm` 同门;**不审计**——它是查询不是动作)→ `{data:{window:{since,until,purpose},totals:{calls,prompt,completion,cacheHit,cacheMiss,hitRate},byPurpose:[…],byModel:[…]}}`;缺省窗口 = **近 24h**,跨度上限 **90 天**(超过 → `400`,**不静默裁剪**);`since`/`until` 接受 **RFC3339 或 Unix 毫秒**;**`hitRate` = cacheHit/(cacheHit+cacheMiss),分母 0 ⇒ `0`**(口径已写进文档与前端展示规则:分母 0 显示 `—`,不显示 `0%`);分解按**调用次数降序、同次数按键升序**;**绝不含凭据与消息正文**。**D. 保留期**:`LLM_USAGE_RETENTION`(默认 `720h` = 30 天;**≤0 = 关闭清理**,日志写明)→ 启动期清一次 + 每小时清一次(复用文档 GC 的既有模式;清扫间隔不做成 env 键)。**E. 面板**:LLM 页新增「用量(近 24h)」小卡(`frontend/web/src/admin/modules/llm/LlmUsageCard.tsx`)——调用数 / 输入 token / 缓存命中率;取数与其它卡片同一条 `useAsyncData` 加载/错误态(**读不到显示错误,绝不显示 0**)。**F. 失败不伪装**:汇总出口未接线或查库失败 → `500 usage_unavailable`,**不回全 0 假汇总**。详见 §2「LLM 用量汇总契约(v0.64)」。
|
||||
|
||||
> **契约版本 `v0.63`**(**客户端登记两条落地:`documents[]` 增可选 `reason` + `GET /sessions/{id}/messages` 对不存在的会话回 `404 session_not_found`**,依据 `Cyrene-For-Android/devdocs/02-前后端协同事项.md` 的「登记(客户端会话,2026-10-05 凌晨 · 跟上 v0.61 的 `.pdf` 之后的两条)」与「登记(… · 一条 API 语义建议:**已删除的会话「看起来像空会话」**)」两条,以及后端「回执九」(2026-10-05 凌晨)的选择与承诺,2026-10-05 落实)。**三档标注**:**纯增量** = ① `documents[]` 元素增**可选** `reason`(`omitempty`,只在 `status:"unreadable"` 时下发;不读该键的老客户端行为**逐字不变**);**行为变更** = ② `GET /api/v1/sessions/{id}/messages` 对**不存在 / 已删除**的会话由 `200 {"data":[]}` 改为 **`404 {"error":{"code":"session_not_found"}}`**(**客户端需处理 404** —— 回执九已告知,客户端此前先用自建兜底);**破坏性** = 无(既有字段形状、既有成功响应一字未动)。详见 §1 行 6、§1.2 ④ 与 §5 变更记录。
|
||||
|
||||
> **契约版本 `v0.62`**(**面板「重置为 env 默认」动作落地:materialize 的反方向显式动作 + 一个新的 owner-only 管理写端点**,依据任务书 `DevDocs/_dispatch/R1-面板重置为env默认.md`、design/04 §3.1(`.env` 为默认/播种层、面板文件为一等来源)与 `DevDocs/开发时间线.md` 的欠账项(「Materialize 已导出、未接端点」),2026-10-04 落实)。**一句话**:materialize 是「面板缺该键才用 `.env` 补」,本版新增的 `POST /api/v1/admin/llm/reset-from-env` 是**反方向的显式动作**——部署者要求「把面板里 `.env` 声明过的键退回 env 值」。**A. 语义(与补齐表同源)**:只覆盖 `.env` 声明了非空值的键(`provider`/`baseUrl`/`apiKey`/`model`/`contextWindow`)、**env 未声明的键一律保留面板值**(`fallback`/`multimodal`/`maxTokens`/`reasoning`/`personaModels` 永不被改)、**组集合不增减**(不复活被删的组、不删新增的组,顺序也不动)、面板值已与 env 一致的键不算「改回」、**无键可改时不写盘**(幂等);真的改了盘就**热重载**运行中的 LLM 栈(`contextWindow` 的预算推导仍只在启动时算一次)。**B. 护栏 / 响应 / 审计**:**owner-only**(与同页两条 LLM 写端点同门;破坏性写不比同族更宽),body 必须 `{"confirm":true}`(缺失 / `false` / 非法 body ⇒ `400 bad_request` 且**一个字节都不动**)→ `200 {data:{reset:[{group,keys:[…]}], skipped:[{group,reason}], path}}`;**只回键名与中文原因,凭据值(尤其 `apiKey`)绝不回显、绝不进日志**;审计动作 `llm.resetFromEnv`(`ok`/`error`)。**C. 基准与失败**:对照基准 = 请求时刻按**既有解析入口**读到的 `.env` 默认层(新增 `config.LoadLLMDefaults`,路径经 `Deps.EnvPath` 注入,**网关绝不 fopen `.env`**);`.env` 源不可读 → `500 env_defaults_unreadable`、面板配置不存在/不可解析 → `500 models_unreadable`、写盘失败 → `500 llm_persist_failed`、已写盘但热重载失败 → `500 llm_reload_failed`、LLM 栈未注入 → `500 llm_unavailable`——**都不假装成功**。**D. 面板**:LLM 页新增「重置为 env 默认」卡片(`admin/modules/llm/LlmResetFromEnv.tsx`,行上标 `owner 专属`,**中文二次确认**后才提交)。**E. 兼容性**:**纯增量**(新端点 + 新前端卡片 + 新审计动作 + 2 个新错误码 `env_defaults_unreadable` / `models_unreadable`,另复用既有 `llm_persist_failed` / `llm_reload_failed` / `llm_unavailable`),既有端点 / WS 帧 / 字段形状**一字未动**;管理侧权限矩阵因此为 12(viewer 只读)+ 10(owner/admin 读)+ **38**(owner-only)= **60** 条路由。详见 §2「重置为 env 默认契约(v0.62)」。**上一版 `v0.61`**(**PDF 文本层抽取落地:白名单第 23 项 `.pdf` + 类型裁决由「嗅探只放行 `text/plain`」改为 `(扩展名, 嗅探)` 成对裁决**,依据 design/24 §7 的 **S6 第一段**(PDF/Office 的裁决:PDF 文本层引库,扫描件与 Office 仍诚实拒绝)与 commit `f82c76d`,2026-10-04 落实)。**三档标注**:**行为变更** = ① **`.pdf` 由「一律 `415`」变为「可上传 + 可读取 + 文本层可抽取进上下文」**(把「`.pdf` 必被拒」写死的客户端必须改);② **类型裁决收紧为成对判定**(`documents.ContentAllowed`)——**方向是收紧/细化,不是放宽**:HTML/纯文本改名成 `.pdf` ⇒ `415`(嗅探不是 PDF),**PDF 字节改名成 `.txt` ⇒ `415`**(PDF 字节也只收在 `.pdf` 名下);**纯增量** = 扩展名白名单 **22 → 23 项**(唯一新增 `.pdf`),其余 22 个文本类的判定与既有响应**一字未变**。**A. 上传(`POST /api/v1/documents`,owner|admin)**——白名单加 `.pdf`;裁决 = **字节嗅探是唯一裁决权 ∧ 扩展名白名单**,自本版起**按对判定**(纯文本类必须 `text/plain`、`.pdf` 必须 `application/pdf`;落点 `internal/runtime/documents/store.go` 的 `ExtAllowed`(23 项)/ `ContentAllowed`);`mime` 仍是**嗅探基类型**(真 PDF → `application/pdf`);`415` 文案同时点明两条口径。**B. 读取(`GET /api/v1/documents/{name}`,必带 Bearer owner|admin)**——**真 PDF 如实下发 `Content-Type: application/pdf`**(读时按同一对裁决重嗅探,判不定者仍退 `application/octet-stream`),`Content-Disposition: attachment` + `X-Content-Type-Options: nosniff` **一字未变** ⇒ **PDF 永不被内联渲染**(与图片读侧刻意相反)。**C. 抽取语义(DTO `status`)**——**文本层 PDF 抽正文**(`Tj`/`TJ`/`'`/`"` 文本算子,页间以换行相接)⇒ `status:"extracted"`,与纯文本类走同一条 sidecar/注入路径;**扫描件/图片型 PDF ⇒ 诚实拒绝 `status:"unreadable"`**,原因含「这是图片型/无文本层 PDF,需Line truncated
|
||||
@@ -222,20 +224,20 @@
|
||||
|
||||
### 2.0 管理侧权限矩阵(v0.40 起,与 `internal/services/gateway/routes.go` 逐条一致)
|
||||
|
||||
- **匿名(无 token / token 无效)**:任意 `/api/v1/admin/*` → `401 {error:{code:"unauthorized"}}`(fail-closed,全部 **60** 条管理路由无一例外 —— 12 条 viewer 档 + 10 条 owner/admin 档 + 38 条 owner-only 档)。
|
||||
- **匿名(无 token / token 无效)**:任意 `/api/v1/admin/*` → `401 {error:{code:"unauthorized"}}`(fail-closed,全部 **61** 条管理路由无一例外 —— 12 条 viewer 档 + 11 条 owner/admin 档 + 38 条 owner-only 档)。
|
||||
- **角色分档**(`✅` 放行,`❌` 回 `403 forbidden`;owner>admin>viewer 递增):
|
||||
|
||||
| 端点组 | owner | admin | viewer |
|
||||
|------|:---:|:---:|:---:|
|
||||
| **只读监控档**(12 条 GET,见下①) | ✅ | ✅ | ✅ |
|
||||
| **owner/admin 只读档**(10 条,见下②) | ✅ | ✅ | ❌ |
|
||||
| **owner/admin 只读档**(11 条,见下②) | ✅ | ✅ | ❌ |
|
||||
| **owner-only 写/凭据档**(38 条,见下③) | ✅ | ❌ | ❌ |
|
||||
|
||||
① **`viewer` 可读的 12 条只读监控档**(`viewerAuth` = owner/admin/viewer)——模块清单 + 运行时指标/日志/通知,全部是**非敏感投影**(无凭据、无他人账号数据、无消息正文):
|
||||
`GET /admin/personas`、`GET /admin/sessions`、`GET /admin/plugins`、`GET /admin/plugins/installed`、`GET /admin/channels`、`GET /admin/iot`、`GET /admin/automations`、`GET /admin/rag`、`GET /admin/observability/metrics`、`GET /admin/observability/trace`、`GET /admin/observability/logs`、`GET /admin/notifications`。
|
||||
|
||||
② **仅 `owner`/`admin` 的 10 条**(`viewer` → `403`;理由:含**他人数据 / 隐私正文 / 完整导出 / 审计流水 / 全局凭据面**):
|
||||
`GET /admin/memories`(私人记忆正文)、`GET /admin/channels/messages`、`GET /admin/channels/{platform}/messages`(完整 IM 消息流/转录)、`GET /admin/llm`(provider/模型配置面)、`GET /admin/llm/catalog`(模型目录候选面,**v0.53 新增**)、`GET /admin/users`(账号与 API key 列表)、`GET /admin/backthink`(后台思考记录)、`GET /admin/observability/audit`(审计流水)、`GET /admin/sessions/{id}/export`(会话导出)、`POST /admin/rag/query`(知识库检索,语义上是读,但按「全局知识面」归此档)。
|
||||
② **仅 `owner`/`admin` 的 11 条**(`viewer` → `403`;理由:含**他人数据 / 隐私正文 / 完整导出 / 审计流水 / 全局凭据面 / 成本面**):
|
||||
`GET /admin/memories`(私人记忆正文)、`GET /admin/channels/messages`、`GET /admin/channels/{platform}/messages`(完整 IM 消息流/转录)、`GET /admin/llm`(provider/模型配置面)、`GET /admin/llm/catalog`(模型目录候选面,**v0.53 新增**)、**`GET /admin/llm/usage`(LLM 用量/成本汇总面,**v0.64 新增**——含 token 成本与模型/用途分解,故与 `/admin/llm` 同门,**不放给 viewer**)**、`GET /admin/users`(账号与 API key 列表)、`GET /admin/backthink`(后台思考记录)、`GET /admin/observability/audit`(审计流水)、`GET /admin/sessions/{id}/export`(会话导出)、`POST /admin/rag/query`(知识库检索,语义上是读,但按「全局知识面」归此档)。
|
||||
|
||||
③ **仅 `owner` 的 38 条**(`admin` 与 `viewer` 均 `403`)——全部写操作,外加**凭据获取路径**(`login-qr`/`login-status`,`view`/`friends`,渠道连接/测试/配置)与**主人身份档案**(决定谁能进 owner 车道,连读都不给 admin):
|
||||
`GET /admin/channels/{platform}/login-qr`、`login-status`、`view`、`friends`;`POST /admin/channels/{platform}/friend/handle`、`message/test`;`PUT|DELETE /admin/channels/{platform}/config`;`POST /admin/channels/open|close|test`;`POST /admin/plugins/policy|install|uninstall`;`POST /admin/personas`、`PUT /admin/personas/{id}`;`GET|PUT /admin/owner-profile`(v0.51);`POST /admin/users`、`POST /admin/users/apikey`、`DELETE /admin/users/apikey/{id}`;`POST /admin/iot/{id}/control`、`POST /admin/iot/scenarios`、`POST /admin/iot/rules`;`POST /admin/automations`、`POST /admin/automations/{id}/run`、`DELETE /admin/automations/{id}`;`POST /admin/llm`、`POST /admin/llm/test`、**`POST /admin/llm/reset-from-env`(v0.62 新增,破坏性写——覆盖面板对若干键的修改,因此门与同页两条 LLM 写端点一致,**不比同族更宽**)**;`POST /admin/memories`、`POST /admin/memories/search`;`POST /admin/rag`、`POST /admin/rag/delete`;`POST /admin/system/revoke-keys|rotate-jwt|backup|restore`。
|
||||
@@ -274,6 +276,7 @@
|
||||
| 22 | `/api/v1/admin/system/backup` | POST | ✅(写) | 备份数据(owner-only,审计);用 SQLite `VACUUM INTO` 快照(含已提交 WAL 内容)到 `<dataDir>/backup-<ts>.db` → `{path,size}` |
|
||||
| 23 | `/api/v1/admin/system/restore` | POST | ✅(写) | 恢复数据(owner-only,审计);body `{path}` → 校验路径在 dataDir 内→**先备份当前**→替换 DB 文件→`{restored:true,backup,note:"需重启后生效"}`(运行进程持有 DB 句柄,热替换需重启可见) |
|
||||
| 24 | `/api/v1/admin/system/storage` | GET | ✅ | **存储状态(只读,v0.60)**:owner-only、**无审计动作**;`{data:{main:{kind:"sqlite",path,fileBytes,walBytes,shmBytes,pageSize,pageCount,freelistPages,freeBytes},documents:{count,bytes},vector:{kind:"pgvector"|"none",enabled,ready}}}`——**绝不含凭据**(DSN/密码/`postgres://` 全文一个字都不回);未接活连接 / 路径未配置 / `documents` 表读不到 → `500 storage_unavailable`(**不回全 0 假快照**)。详见下方「系统设置契约」 |
|
||||
| 24a | `/api/v1/admin/llm/usage` | GET | ✅ | **LLM 用量汇总(只读,v0.64,切片 U1)**:**owner\|admin 读**、**无审计动作**;`?since=&until=&purpose=`(时间接受 RFC3339 或 Unix 毫秒;缺省窗口 = 近 24h;跨度上限 **90 天**,超过 → `400`,**不静默裁剪**)→ `{data:{window:{since,until,purpose},totals:{calls,prompt,completion,cacheHit,cacheMiss,hitRate},byPurpose:[…],byModel:[…]}}`;**`hitRate` = cacheHit/(cacheHit+cacheMiss),分母 0 ⇒ `0`**;**绝不含凭据与消息正文**;未接线 / 查库失败 → `500 usage_unavailable`(**不回全 0 假汇总**)。详见下方「LLM 用量汇总契约(v0.64)」 |
|
||||
|
||||
> **M10 前端契约补充(管理面板)**:`GET /api/v1/admin/users` · `GET /api/v1/admin/llm` · `GET /api/v1/admin/automations` · `GET /api/v1/admin/backthink` · `GET /api/v1/admin/rag` · `POST /api/v1/admin/rag/query` · `GET /api/v1/admin/observability/metrics` · `GET /api/v1/admin/observability/trace` · `GET /api/v1/admin/observability/audit` · `GET /api/v1/admin/observability/logs` · `GET /api/v1/admin/notifications` 已实现,返回真实数据(**日志 tail 见下方「可观测性契约」**;未注册 `GET /api/v1/admin/logs`)。**写操作已补齐**(见下方「管理面板写操作契约」):人格/API key/设备控制/自动化/LLM 测试/渠道连接与识别测试/记忆检索/会话导出/插件卸载**真实持久化或真实生效**;**系统设置安全与存储类**(吊销 API key / 轮换 JWT 密钥 / 备份 / 恢复)也已**真实实现**(见下方「系统设置契约」);**主人档案保存(v0.51)同样已实现**——`GET`/`PUT /api/v1/admin/owner-profile` 真实落盘 + 热替换活档案,面板的「保存」不再是假成功;仍为后置 M10:**新增用户**、**IoT 场景与规则**、**编辑环境变量/重启服务/检查更新**——后端如实返回 `501 not_implemented`,不伪造成功。(**知识库删除已实现**:`POST /admin/rag/delete` 真实删除,见下方写操作契约;**插件安装已实现**:v1 为 MCP server 热安装。)
|
||||
|
||||
@@ -460,6 +463,39 @@
|
||||
- **只读保证**:调用前后 `documents` 行数与数据目录内的文件集合均不变(有用例钉住:既没有写、也没有顺手备份)。
|
||||
- **前端用法**:`adminSystemStorage()`(`frontend/web/src/lib/api/adminSystem.ts`)→ 卡片 `value` = `SQLite 12.3 MiB`、`hint` = WAL/页数/空闲页、详情行 = `documents N 件` 与 `PG 增强 …`;取数走与其它已接线卡片同一条加载/错误态。
|
||||
|
||||
**LLM 用量汇总契约(`GET /api/v1/admin/llm/usage`,v0.64,切片 U1)** —— 只读查询:**owner|admin 读**(与同页 `GET /admin/llm` 同门;含成本与模型/用途分解,**不是**给 `viewer` 的监控投影)、**不审计**、**只读**(只有 `SELECT`,复用组合根注入的活连接——不新开池、不写、不迁移)。它回答「这些模型调用到底花了多少」,是面板 LLM 页「用量」小卡的数据源。字段名逐字对齐 `internal/services/gateway/admin_llm_usage.go`。
|
||||
|
||||
- **请求**:`GET /api/v1/admin/llm/usage?since=&until=&purpose=`,`Authorization: Bearer <JWT>`(role=owner|admin)或 `X-API-Key`。
|
||||
- `since`/`until`:**接受两种形态** —— **RFC3339**(如 `2026-10-05T00:00:00Z`,JS 的 `new Date().toISOString()` 就是这个)或 **Unix 毫秒**(纯数字,如 `1759622400000`;与 `/sessions/{id}/messages` 的 `before`/`after` 游标同一种口径,便于客户端复用)。其它形态 → `400 bad_request`(**绝不静默当成「现在」**)。
|
||||
- **缺省窗口 = 近 24 小时**(`until` 缺省 = 现在,`since` 缺省 = `until − 24h`);`until` 必须**严格晚于** `since`,否则 `400`。
|
||||
- **窗口上限 = 90 天**:跨度超过上限回 `400 {error:{code:"bad_request"}}`(文案点名上限)——**不静默裁剪**,静默裁剪会让客户端把「我没查那么久」误读成「就那么少」。
|
||||
- `purpose`:空格截断后**精确匹配**用途标签(如 `chat` / `memory_extract` / `noise_review`);缺省/空 = **不过滤**。
|
||||
- **响应**:`200`
|
||||
|
||||
```json
|
||||
{"data":{
|
||||
"window":{"since":"2026-10-04T00:00:00Z","until":"2026-10-05T00:00:00Z","purpose":""},
|
||||
"totals":{"calls":128,"prompt":412000,"completion":38000,
|
||||
"cacheHit":356000,"cacheMiss":56000,"hitRate":0.8641},
|
||||
"byPurpose":[{"purpose":"chat","calls":120,"prompt":410000,"completion":37000,
|
||||
"cacheHit":355000,"cacheMiss":55000,"hitRate":0.8659}],
|
||||
"byModel":[{"model":"deepseek-chat","calls":128,"prompt":412000,"completion":38000,
|
||||
"cacheHit":356000,"cacheMiss":56000,"hitRate":0.8641}]
|
||||
}}
|
||||
```
|
||||
|
||||
- `window` 回显**补齐后**的时间窗(缺省值也回显,客户端不必自己猜)与用途过滤(`""` = 未过滤);时间一律 **UTC RFC3339**。
|
||||
- `totals`:整窗合计,键**恰好六个**——`calls`(成功调用次数)、`prompt`(输入侧 token)、`completion`(输出侧 token)、`cacheHit`/`cacheMiss`(输入侧两档)、`hitRate`。
|
||||
- **`hitRate` 口径(重要)**:`cacheHit / (cacheHit + cacheMiss)`,**分母为 0 时下发 `0`**(口径与 `model.Response.CacheHitRate` 同源,绝不下发 `NaN`/`Inf`/编造的 100%)。⚠️ 客户端展示时建议把「分母为 0」与「真 0% 命中」分开:前者显示 `—` + 说明「本窗口没有输入侧缓存明细」,后者才是 `0.0%`(Web 面板的用量小卡就是这么做的)。
|
||||
- `byPurpose` / `byModel`:按用途 / 按模型分解,**元素键 = 分组键 + 上面六个合计键**(分别是 `purpose` / `model`);排序 = **调用次数降序、同次数按键升序**(输出确定,客户端可安全 diff);**无数据时是 `[]` 而非 `null`**。
|
||||
- **不含凭据(红线)**:响应里既没有 `apiKey` / token,也**没有消息正文** —— 那些东西根本没进 `llm_usage` 表(写入方的样本结构里就没有这些字段)。
|
||||
- **错误**:`401`(无凭据 / 凭据无效);`403`(`viewer`;与 `/admin/llm` 同门);`400 bad_request`(`since`/`until` 形态非法、`since` 不早于 `until`、跨度超 90 天);`500 {error:{code:"usage_unavailable"}}`(汇总出口未接线,或查库失败)——**诚实边界**:此时**不返回**全 0 的假汇总,客户端应显示错误而不是 `0`。
|
||||
- **数据来源与写入口径(谁在写这张表)**:每次**成功**的模型调用都会把一条**非敏感**样本(`adapter` / `model` / `purpose` / 六个 token 计数 / `tookMs`)异步落进本机 `llm_usage` 表(迁移 `0017`)。写入是**有界队列 + 单写者 + 批量事务**:队列满即**丢弃并限流 WARN**,**绝不阻塞模型调用**;整批失败只记 WARN,不影响任何一次对话。**凭据与消息正文绝不入库、绝不进日志**。
|
||||
- ⚠️ **`session_id` 当前恒为 `NULL`**:模型适配器跑在 loop 之下、没有会话概念(`internal/runtime/model/stream.go` 的 `StreamEvent.SessionID` 注释有同样说明),把会话标识带到适配器边界需要 loop 侧在请求上打标 —— 列已建好、索引已建好,接线后即可用;**当前聚合也按会话分组**(本端点只按 `purpose` / `model` 分解)。
|
||||
- **保留期**:`LLM_USAGE_RETENTION`(默认 `720h` = 30 天;**≤0 = 关闭清理**,样本全留,启动日志会写明「llm usage retention disabled」);启动期清一次 + 此后每小时清一次。
|
||||
- **鉴权/审计档位说明**:它是**查询不是动作**,因此**无审计记录**;矩阵归类见 §2.0 的 ②(owner/admin 只读档)。
|
||||
- **前端用法**:`adminLlmUsage()`(`frontend/web/src/lib/api/admin.ts`)→ 卡片「用量(近 24h)」显示**调用数 / 输入 token / 缓存命中率**(`cacheHit + cacheMiss = 0` 时命中率显示 `—`);取数走与其它已接线卡片同一条加载/错误态(**读不到就显示错误,不显示 0**)。
|
||||
|
||||
**知识库 RAG 契约**:
|
||||
- `GET /admin/rag` → `{"data":{"documents":[{id,name,desc?,chunks,embedded}],"hits"?:[{docId,docName,chapter?,text,score,semantic}]}}`。`hits` 仅在携带 `?query=` 时出现;`embedded` 反映部署是否配置向量增强(未配置为待嵌入)。
|
||||
- `POST /admin/rag` body `{"name","desc?","content"}` → `201 {"data":{"name"}}`。**导入文档**:后端分块(空行分段,超长段按固定长度切)并落项目索引(项目+章节),立即可被关键词/行上下文检索;未注入 `RagStore` 回 `501`。语义/向量嵌入为优雅降级(知识库 Store 不直属向量索引,导入文档走 keyword/line 路径;语义检索仍对既有 project-linked 记忆生效)。
|
||||
@@ -467,7 +503,7 @@
|
||||
- `POST /admin/rag/delete` body `{"id":"..."}` → `200 {"data":{"id":"...","deleted":true}}`。**真实删除**:从项目索引删除该文档条目并**级联删除其全部章节**(单事务),删除后 `GET /admin/rag` 不再出现、检索不再命中。`id` 缺失/空白回 `400`;文档不存在回 `404 not_found`;未注入 `RagStore` 回 `501`(**owner-only + 审计 `rag.delete`**)。
|
||||
|
||||
**可观测性契约**(`GET /api/v1/admin/observability/*`,均需 owner/admin;`metrics`/`trace`/`audit` 的 `?limit=N` 缺省 50、上限 500):
|
||||
- `GET /metrics` → `{"data":{"totalRequests","qps","avgLatencyMs","maxLatencyMs","perMethod{}","perPath{}","statusCounts{}","llmCalls","subagentEvents"}}`。`qps` 为近 1 秒窗口计数;`llmCalls`/`subagentEvents` 由实际观测到的 trace span 按 `kind` 累计(Tracer 未注入时为 0)。**Token/成本后端不统计、不返回**(不伪造指标)。
|
||||
- `GET /metrics` → `{"data":{"totalRequests","qps","avgLatencyMs","maxLatencyMs","perMethod{}","perPath{}","statusCounts{}","llmCalls","subagentEvents"}}`。`qps` 为近 1 秒窗口计数;`llmCalls`/`subagentEvents` 由实际观测到的 trace span 按 `kind` 累计(Tracer 未注入时为 0)。**Token/成本本端点不统计、不返回**(不伪造指标)——**token 与成本由 v0.64 新增的 `GET /admin/llm/usage` 单独回答**(见 §2「LLM 用量汇总契约(v0.64)」)。
|
||||
- `GET /trace?limit=100` → `{"data":[{id,parentId?,name,kind,status,startedAt,endedAt?,durationNs,props?,events?]}`。`kind` ∈ `llm_call|tool_call|orchestration|state_transition|subagent|memory|guardrail`;`durationNs` 为纳秒;`events` 为 `[{time,type,props?}]`。
|
||||
- `GET /audit?limit=100` → `{"data":[{id,who,what,when,result}]}`。`id` 为整数自增;`when` 为 RFC3339;来源为 SQLite `audit_logs` 表(未注入 AuditStore 时退化为进程内存)。
|
||||
- `GET /logs?level=&limit=` → `{"data":[{time,level,source,msg}]}`(**后端日志 tail**,v0.35)。
|
||||
@@ -739,6 +775,7 @@
|
||||
- 语音:上行 `voice_input`(base64 音频或已转写 text,**v0.59 起可带 `clientMsgId` 幂等**),下行 `voice_output`(合成音频 base64 + text + **`sessionId`/`deviceId?`**,**定向回发起那条连接、回合结算时才到**,空正文/TTS 失败带 `provider` 降级信号);**语音回合与文本回合走同一条路**(落库 / 跨端 / 忙时排队),见 §3.15。
|
||||
|
||||
## 5. 变更记录
|
||||
- `v0.64`(**LLM 用量落库 + 只读汇总端点 + 面板用量小卡**,依据主人 2026-10-04 夜裁决「**2.4 落**」与 T1(`72b95df`:每次模型调用已记结构化用量日志、但**只落日志**),2026-10-04 落实)。**三档标注**:**纯增量** = 新端点 `GET /api/v1/admin/llm/usage`、新迁移 `0017_llm_usage`、新 env 键 `LLM_USAGE_RETENTION`(默认 `720h`;`≤0` = 关闭清理)、面板 LLM 页新小卡、前端新 API `adminLlmUsage()`;**行为变更** = 无;**破坏性** = 无。**A. 为什么落库**:日志能看单次、看不了趋势,也做不出按用途/模型/会话的成本视图,面板更无数据源 —— 主人要的是「这些调用到底花了多少」。**B. 写入绝不阻塞调用(本版最关键的一处设计)**:`model` 层**不直接开库**,只在既有 `logUsage` 处增加**可选**接口 `model.UsageSink`(**`nil` ⇒ 仅日志、行为逐字不变**,因此既有 model 用例**零改动**即全绿);组合根注入 `model.UsageWriter` = **有界队列(1024)+ 单写者 + 批量事务(128 行/批、2s 兜底冲刷)**:队列满 ⇒ **丢弃 + 限流 WARN(30s 一条,带累计丢弃数)**,**绝不阻塞、绝不 panic、绝不无界增长**;整批失败只记一条带来源 WARN(不静默);停机时先 drain 写完再退。sink 挂在 `RuntimeLLM`(热重载时稳定)而非适配器上,因此面板每次保存 models.json 都不会把落库静默断掉;**每一条链**(主/备/本地第三级/按角色跨协议)都带上它。**C. 表**:`llm_usage`(`id/session_id/purpose/adapter/model/prompt_tokens/completion_tokens/cache_hit_tokens/cache_miss_tokens/reasoning_tokens/total_tokens/took_ms/created_at`)+ `idx_llm_usage_created_at` + `idx_llm_usage_session_id`。⚠️ **`session_id` 当前恒为 `NULL`**:模型适配器跑在 loop 之下、没有会话概念(`internal/runtime/model/stream.go` 对 `StreamEvent.SessionID` 有同样说明),填它需要 loop 在请求上打标,而那份文件属本轮并行切片在途,故**先留列与索引、后接线**;本端点也**不按会话分组**。**D. 读取**:`GET /api/v1/admin/llm/usage?since=&until=&purpose=`(**owner|admin 读**,与同页 `/admin/llm` 同门;**不审计** —— 查询不是动作)→ `{data:{window:{since,until,purpose},totals:{calls,prompt,completion,cacheHit,cacheMiss,hitRate},byPurpose:[…],byModel:[…]}}`;**缺省窗口 = 近 24h**;`since`/`until` 接受 **RFC3339 或 Unix 毫秒**;**跨度上限 90 天,超过 ⇒ `400`(不静默裁剪)**;**`hitRate` = cacheHit/(cacheHit+cacheMiss),分母为 0 ⇒ `0`**(口径已写进 §2 契约与前端展示规则:**分母 0 显示 `—`,不显示 `0%`**);分解**按调用次数降序、同次数按键升序**;**绝不含凭据与消息正文**(表里根本没有这些列)。**E. 保留期**:`LLM_USAGE_RETENTION` 默认 `720h`(30 天),**`≤0` = 关闭清理**(启动日志写明「llm usage retention disabled」);**启动期清一次 + 每小时清一次**(复用 `cmd/cyrene/wire_document_gc.go` 的既有模式;清扫间隔**不做成 env 键** —— 保留期才是部署者的旋钮)。**F. 面板**:LLM 页「用量(近 24h)」小卡 = **调用数 / 输入 token / 缓存命中率**,取数走既有 `useAsyncData` 加载/错误态(**读不到显示错误,绝不显示 0**)。**G. 失败不伪装**:汇总出口未接线或查库失败 ⇒ `500 {error:{code:"usage_unavailable"}}`,**不回全 0 假汇总**。**H. 可观测性端点的口径不变**:`GET /admin/observability/metrics` 仍只报 `llmCalls`/`subagentEvents`(它不统计 token/成本),token 与成本由本版端点单独回答。**实现落点**:`internal/runtime/model/{usage.go,usage_store.go,usage_query.go,openai.go,anthropic.go,factory.go,runtime.go}`、`internal/db/migrations/0017_llm_usage.{up,down}.sql`、`cmd/cyrene/{wire_usage.go,wire_runtime.go,wire_frontend.go,wire_services.go,main.go}`、`internal/services/gateway/{admin_llm_usage.go,routes.go,deps.go,contracts.go,server.go}`、`frontend/web/src/admin/modules/llm/LlmUsageCard.tsx`、`.env.example`。**验证**:sink 为 nil 逐字不变、队列满不阻塞(有超时判定)、批量写入→读回(含 hitRate 公式)、时间窗/用途过滤与超限 400、保留期清理(过期删 / 未过期留 / ≤0 不清理)、鉴权(无凭据 401 / viewer 403 / admin 放行)、不回显凭据(键集恰好六个);**两条变异**:① `RecordUsage` 改阻塞发送 ⇒ 「队列满不阻塞」红;② `hitRate` 分母写成 `cacheMiss` ⇒ 命中率断言红(还原后均复绿)。
|
||||
- `v0.63`(**客户端登记的两条落地:`documents[]` 增可选 `reason`(读不到的原因)+ `GET /sessions/{id}/messages` 对不存在/已删除的会话回 `404 session_not_found`**,依据 `Cyrene-For-Android/devdocs/02-前后端协同事项.md` 的两条登记(2026-10-05 凌晨)与后端「回执九」的接受与选择,2026-10-05 落实)。**三档标注**:**纯增量** = ① `documents[]` 元素增可选 `reason`(`omitempty`,**只在 `status:"unreadable"` 时下发**;不读该键的客户端行为逐字不变);**行为变更** = ② `messages` 对**不存在/已删除**的会话由 `200 {"data":[]}` 改为 **`404 {"error":{"code":"session_not_found"}}`**(**客户端需按 404 处理**:退回主会话 + 一句提示;回执九已提前告知);**破坏性** = 无。**A. `reason`(§1.2 ④)**——数据来源 = 服务端**已有的那份抽取失败原因文案**(就是注入模型清单里那条,`internal/runtime/documents/sidecar.go` 的 `reason*` 常量);装配期在给出 `status` 的同时给出 `Reason`(`internal/runtime/loop/document.go` 的 `documentReason`),**同一个字符串两处用**:既进模型可见上下文,也作为客户端可见的 `reason` —— 服务端因此不必为客户端另编一句,两处口径也不可能分叉。**只在「读不到」时下发**(`extracted`/`listed` 为空 ⇒ 键缺席;客户端按「缺失 ⇒ 回落通用句」实现)。**文案集合(逐字)**:`无法抽取正文:这是图片型/无文本层 PDF,需要 OCR 才能读(当前未做)` / `…PDF 已加密(无法用空口令打开),请解除密码后重新上传` / `…PDF 损坏或无法解析` / `…本期不支持 Word/Excel/PPT 解析(无文本层)` / `…内容不在可抽取的纯文本范围内` / `…文档文件不存在或已被清理` / `…读取文档失败` / `…文档引用形态非法` / `…文档上传目录未配置` / 解析器整条消息失败时的诚实兜底 `无法抽取正文:读取或解析失败`。**只放文案**:不含落盘路径、不含凭据、不含存储名以外的内容;原始错误(可能带路径)**只进日志** —— 端到端用例用一个**独特的临时目录名**做反证断言(响应体里搜不到它,且 reason 不含 `uploads`、`\documents` 等路径片段)。**抵达面**:它随 `history.DocumentRef`(**与落库同一个结构体**)由 S3.5 的回合后回写写进 `document` 列(`mergeDocumentStatus` 把 `reason` 与 `status`/`chars`/`truncated` **同批**搬运、并计入「有无变化」判据),因此**所有读取面**(`GET /sessions/{id}/messages`、握手回放)都有它;⚠️ `message_persisted` 帧在用户消息落库时就发出,那时抽取还没发生(**不带** `status`/`reason`,与既有 `status` 行为一致 —— 客户端以读接口为准)。**B. `404 session_not_found`(§1 行 6)**——现象:此前对**已删除/凭空编造**的会话都回 `200 {"data":[]}`,客户端分不清「空会话」与「会话已不在」(主人的真实遭遇:正看着的临时会话被清理,界面上只剩一片空白)。**理由(与 API 自身一致)**:`PATCH /sessions/{id}`、`POST /sessions/{id}/read` 对未知会话本来就是 `404`,让 `messages` 单独例外会让每个新客户端都踩一次。**实现**:判定**复用既有存储面**(`history.Store.SessionPersona` —— 既有存储里唯一一个「按 id 读**单个**会话 + 未知 id 报 `history.ErrSessionNotFound`」的方法;`ListSessions` 是全量列表扫描,在热分页端点上代价不成比例),**不新写 SQL、不新增存储方法**,落点 `internal/services/gateway/history_session_guard.go`(新文件)+ `handleListMessages` 一行收口。**存在但没有消息仍是 `200 {"data":[]}`**(空会话绝不能被答成 404);参数校验(`limit`/`before`/`after`/`q`)**先于**存在性判定(形状错了就是 `400`,与目标存不存在无关);存储**没有该视图**(测试替身 / 未接库的窄存储)视为**无从判定** ⇒ 保持旧的空集合语义(判不出来绝不等于「不存在」)。**C. 会话注册表口径(重要,随本版更正 §3.4 旧文)**:`chat_sessions` 是会话注册表;**隔离的 IM 会话(群聊/陌生人的 `sess-xxx`、内部 `sess-backthink`)不在其中** ⇒ 它们在本端点与 `POST /sessions/{id}/read` 拿到**同一个答案**(`404`),这是「与 API 自身一致」的必然推论;它们的消息仍可由 `GET /api/v1/admin/channels/{platform}/messages` 读出(`Docs/api-reference.md` §3.4 那句「IM 隔离会话 `sess-xxx` 亦可用」**随本版更正**)。**D. 错误码风格**:新码 **`session_not_found`**(点明是**会话**,与 `chat` 的 `document_not_found` 同风格),与 `PATCH /sessions/{id}` 的既有通用码 `not_found` **不同码** —— 本版**不动** PATCH 的既有Line truncated
|
||||
- `v0.62`(**面板「重置为 env 默认」动作落地:materialize 的反方向显式动作 + 一个新的 owner-only 管理写端点**,依据任务书 `DevDocs/_dispatch/R1-面板重置为env默认.md` 与 design/04 §3.1(`.env` 为默认/播种层、面板文件为一等来源)和 `DevDocs/开发时间线.md` 的欠账项(「Materialize 已导出、未接端点」),2026-10-04 落实)。**三档标注**:**纯增量** = 新端点 `POST /api/v1/admin/llm/reset-from-env`、面板 LLM 页的新卡片、审计动作 `llm.resetFromEnv`、2 个新错误码(`env_defaults_unreadable` / `models_unreadable`,另复用既有 `llm_persist_failed` / `llm_reload_failed` / `llm_unavailable`);**行为变更** = 无(既有端点 / 帧 / 字段一字未动);**破坏性** = 无(新端点的新用途;它本身的破坏性由请求体 `confirm:true` 强制)。**A. 新端点**(**owner-only**——与同页 `POST /admin/llm`、`POST /admin/llm/test` **同门**,破坏性写不比同族更宽;带审计 `llm.resetFromEnv`)body **必须** `{"confirm":true}`(缺失 / `false` / 非法 body ⇒ `400 bad_request` 且**一个字节都不动**),→ `200 {data:{reset:[{group,keys:[…]}], skipped:[{group,reason}], path}}`。**B. 语义(与 materialize 共用同一张键表 `runtimeconfig.groupFillSpecs`,方向相反)**:① **只覆盖 `.env` 声明过非空值的键**(`provider` / `baseUrl` / `apiKey` / `model` / `contextWindow`;协议族按**组自己的 `provider`** 选,`.env` 同时配扁平块与 `ANTHROPIC_*` 时两族各取自己的值);② **env 未声明的键一律保留面板值**(`fallback` / `multimodal` / `maxTokens` / `reasoning` / `personaModels` 在 `.env` 里没有出处 ⇒ 永不被本动作改动);③ **组集合不增减**(不复活被面板删掉的组、不删面板新增的组,**顺序也不动**);④ **面板值已与 env 一致的键不算「改回」**,不出现在 `reset` 里(`provider` 恒属此种情形);⑤ **无键可改时不写盘**(幂等,重复调用第二次回 `reset:[]` + 每组一条「已一致」的 `skipped`);⑥ **真的改了盘就热重载**运行中的 LLM 栈(无需重启;`contextWindow` 的**预算推导**仍只在启动时算一次,与 `POST /admin/llm` 同口径)。**C. 响应与凭据纪律**:`reset[].keys` 只有 **`models.json` 的 JSON 键名**、`skipped[].reason` 是中文原因,**凭据值(尤其 `apiKey`)绝不回显、绝不进日志**(`reset` / `skipped` 恒为数组,无记录时是 `[]` 而非 `null`;`path` 与 `POST /admin/llm` 同源)。**D. 对照基准**:**请求时刻按既有解析入口读到的 `.env` 默认层**(新增 `config.LoadLLMDefaults`,复用 `parseDotEnv` + 同一份 `env:"…"` 解码器 ⇒ 优先级与启动期 `config.Load` 逐字一致:进程环境 > `.env` > 标签默认);路径由组合根经 `Deps.EnvPath` 注入(与 `main.go` 的 `config.MustLoad(envPath)` 同一条),**网关自己绝不 fopen `.env`**。**E. 失败不伪装**(都**不改文件**且带审计 `error`):`.env` 源不可读 → `500 env_defaults_unreadable`;面板配置不存在/不可解析 → `500 models_unreadable`;写盘失败 → `500 llm_persist_failed`;已写盘但热重载失败 → `500 llm_reload_failed`(说明文件已改、未生效);LLM 栈未注入 → `500 llm_unavailable`。**F. 面板**:`frontend/web` 的 LLM 页新增「重置为 env 默认」卡片(`admin/modules/llm/LlmResetFromEnv.tsx`,标 `owner 专属`)——点按钮先展开**中文二次确认**文案,确认后才带 `confirm:true` 提交;结果按 `reset`/`skipped` 渲染成可复核的组名 + 键名摘要,失败原样显示后端错误文案。**G. 实现落点**:`internal/config/llm_defaults.go`(env 默认层读取)+ `internal/config/runtimeconfig/reset.go`(`ResetFromEnv` 语义,新文件)+ `internal/services/gateway/admin_write_llm_reset.go`(端点,新文件)+ `routes.go`(注册为 `ownerAuth`)+ `deps.go`/`server.go`(`Deps.EnvPath`)+ `cmd/cyrene/wire_frontend.go`(组合根接线)+ 前端 `lib/types/adminWrite.ts` / `lib/api/admin.ts` / `admin/modules/llm/LlmResetFromEnv.tsx` / `admin/modules/Llm.tsx`。**H. 未做 / 诚实边界**:**不支持「只重置某一组」**(一次调用对全部组生效;`skipped` 会如实报出没被改动的组与原因);**不做「重置 channels.json / plugins.json」**(materialize 本身也只覆盖 `models.json` 一族);**不做「重置后自动重启」**(provider 栈本身热更新,窗口推导需重启由面板文案写明)。
|
||||
- `v0.61`(**PDF 文本层抽取落地:白名单第 23 项 `.pdf` + 类型裁决由「嗅探只放行 `text/plain`」改为 `(扩展名, 嗅探)` 成对裁决**,依据 design/24 §7 的 **S6 第一段**(PDF/Office 的裁决:PDF 文本层引库,扫描件与 Office 仍诚实拒绝)与 commit `f82c76d`,2026-10-04 落实)。**三档标注**:**行为变更** = ① **`.pdf` 由「一律 `415`」变为「可上传 + 可读取 + 文本层可抽取进上下文」**(把「`.pdf` 必被拒」写死的客户端必须改);② **类型裁决收紧为成对判定**(`documents.ContentAllowed`)——**方向是收紧/细化,不是放宽**:HTML/纯文本改名成 `.pdf` ⇒ `415`(嗅探不是 PDF),**PDF 字节改名成 `.txt` ⇒ `415`**(PDF 字节也只收在 `.pdf` 名下);**纯增量** = 扩展名白名单 **22 → 23 项**(唯一新增 `.pdf`),其余 22 个文本类的判定与既有响应**一字未变**。**A. 上传(`POST /api/v1/documents`,owner|admin)**——白名单加 `.pdf`;裁决 = **字节嗅探是唯一裁决权 ∧ 扩展名白名单**,自本版起**按对判定**(纯文本类必须 `text/plain`、`.pdf` 必须 `application/pdf`;落点 `internal/runtime/documents/store.go` 的 `ExtAllowed`(23 项)/ `ContentAllowed`,网关名字形态校验经它判定);`mime` 仍是**嗅探基类型**(真 PDF → `application/pdf`);`415` 文案同时点明两条口径(「只接受白名单内的文档(如 .txt/.md/.csv/.json,或内容确为 PDF 的 `.pdf`),且内容必须与扩展名相符」)。**B. 读取(`GET /api/v1/documents/{name}`,必带 Bearer owner|admin)**——**真 PDF 如实下发 `Content-Type: application/pdf`**(读时按同一对裁决重嗅探,判不定者仍退 `application/octet-stream`),`Content-Disposition: attachment` + `X-Content-Type-Options: nosniff` **一字未变** ⇒ **PDF 永不被内联渲染**(与图片读侧刻意相反);`Cache-Control: private, max-age=86400, immutable` 与 Range 支持不变。**C. 抽取语义(DTO `status`)**——**文本层 PDF 抽正文**(页内容流里的 `Tj`/`TJ`/`'`/`"` 文本算子,页间以换行相接)⇒ `status:"extracted"`,与纯文本类走**同一条** sidecar / 注入路径;**扫描件/图片型 PDF(无文本层)⇒ 诚实拒绝 `status:"unreadable"`**,原因文案含「这是图片型/无文本层 PDF,需要 OCR 才能读(当前未做)」,**绝不假装成功、也绝不返回空正文**(`ErrNoTextLayer`);**加密 PDF** 与**损坏/截断 PDF** 是**可区分**的两类失败(`ErrPDFEncrypted` / `ErrPDFDamaged`,加密的提示解除密码后重试);安全阀 = 单份最多 **5000 页** + sidecar 正文 **≤8 MiB**(撞上即 `truncated`)。**D. 客户端可见的一次性重算**——抽取器版本内部升到 **`documents/v3`**(`internal/runtime/documents/extract.go`)⇒ **旧 sidecar 一次性失效重抽**:同一份**旧附件**的 `chars`/`truncated` 结论会在**某次回合之后**重新计算(旧 PDF 若是文本层 PDF,将从 `unreadable` 变为 `extracted`)——不是附件被改写,是结论被重算。**E. 未做(诚实边界)**——**不做 OCR / 扫描件栅格化**(纯 Go 无可靠实现,会撞「单二进制 + 交叉编译」「零跨进程 IPC」两条红线)、**不做 PDF 写/生成**(只读)、**不做 `.docx`/`.xlsx` 等 Office**(仍诚实拒绝);**无新端点、无新 WS 帧、无鉴权变更、无 `.env` 新增键**。**实现落点**:`internal/runtime/documents/extract_pdf.go`(文本层抽取 + 加密/损坏分类)+ `internal/runtime/documents/{extract.go,store.go,sidecar.go}`(版本号 / 白名单与成对裁决 / unreadable 原因文案)+ 依赖 `github.com/ledongthuc/pdf`(纯 Go、无 cgo)。
|
||||
|
||||
Reference in new issue
Block a user