3 Commits

Author SHA1 Message Date
caoxiaozhu
545b31d32f chore(env): docker-compose 端口与服务配置微调并更新规则表与日志
- docker-compose(.full).yml 与 start.sh 微调端口/服务配置
- AGENTS.md 同步更新协作规范
- 更新交通/通信/差旅等财务规则表,补 2026-06-24 work-log
2026-06-24 12:36:03 +08:00
caoxiaozhu
8417a9f542 feat(web): 设置中心缓存管理与文件预览资产工具
- 新增 documentPreviewAssets 工具,统一从 URL/Blob/File 推断预览类型(image/pdf/file/unsupported)
- SettingsView/SettingsView.js/settingsModelHelper 新增系统缓存管理区块,调用 /settings/cache/clear 并展示清理结果;useSettings/services 适配
- WorkbenchAiFilePreviewDialog/useWorkbenchAiFilePreview 接入预览资产工具,workbenchAiComposerModel 调整文件处理
- ReceiptFolder/LogDetailView/DigitalEmployeeWorkRecords/travelReimbursementAttachmentModel 配套适配
- 新增 settings-cache-management-section 测试,更新 settings-llm/rendering/receipt-folder-view/composer-components/attachment-association 测试
2026-06-24 12:35:59 +08:00
caoxiaozhu
9a5ed0e94a feat(server): 系统缓存清理接口与 OCR 文本层兜底增强
- 新增 system_cache 模块与 POST /settings/cache/clear,管理员可一键清理 OCR 结果/运行时配置/模型失败冷却/知识库索引/地点语义等进程内缓存
- 各服务暴露 clear_*_cache 方法(ocr/runtime_settings/runtime_chat/knowledge/application_location_semantic),SettingsCacheClearRead 汇总清理项
- OCR 转图片失败时尝试用 PDF 文本层兜底构建识别文档(有效字符≥8),并写结果缓存;OcrService 暴露 clear_result_cache
- receipt_folder 车票过滤补充身份证号关键词,附件文档/操作/展示模块同步适配
- 新增 system_cache_endpoints 测试,更新 openapi_schema/ocr/receipt_folder/attachment_association_jobs 测试
2026-06-24 12:35:51 +08:00
48 changed files with 1900 additions and 124 deletions

View File

@@ -43,7 +43,7 @@
## 容器与运行环境(必读) ## 容器与运行环境(必读)
本项目代码是 Docker 容器 `x-financial-main`(镜像 `x-financial-dev:latest`)的源码映射。 本项目代码是 Docker 容器 `local-x-financial-linux`(镜像 `x-financial-dev:latest`)的源码映射。
- **容器映射**:宿主机 `D:\Code\Project\X-Financial` ↔ 容器内 `/app``docker-compose.yml``volumes: - .:/app``working_dir: /app`)。 - **容器映射**:宿主机 `D:\Code\Project\X-Financial` ↔ 容器内 `/app``docker-compose.yml``volumes: - .:/app``working_dir: /app`)。
- **后端 venv**:容器内位于 `/tmp/x-financial-server-venv`(环境变量 `SERVER_VENV_DIR`),不要假设宿主机上有相同的 venv。 - **后端 venv**:容器内位于 `/tmp/x-financial-server-venv`(环境变量 `SERVER_VENV_DIR`),不要假设宿主机上有相同的 venv。
@@ -51,14 +51,14 @@
## 验证规范(硬性约束) ## 验证规范(硬性约束)
> 本项目代码与运行环境以容器为唯一事实来源。所有后端测试、集成测试、依赖了 Qdrant / OnlyOffice / venv 的验证,都必须在 `x-financial-main` 容器内执行,**不要在宿主机上直接跑 pytest / pip / python**。 > 本项目代码与运行环境以容器为唯一事实来源。所有后端测试、集成测试、依赖了 Qdrant / OnlyOffice / venv 的验证,都必须在 `local-x-financial-linux` 容器内执行,**不要在宿主机上直接跑 pytest / pip / python**。
- **进入容器跑命令**(最常用): - **进入容器跑命令**(最常用):
```bash ```bash
docker exec -w /app -e SERVER_VENV_DIR=/tmp/x-financial-server-venv x-financial-main <cmd> docker exec -w /app -e SERVER_VENV_DIR=/tmp/x-financial-server-venv local-x-financial-linux <cmd>
``` ```
- 跑后端测试:`docker exec -w /app -e SERVER_VENV_DIR=/tmp/x-financial-server-venv x-financial-main /tmp/x-financial-server-venv/bin/pytest -q <path>` - 跑后端测试:`docker exec -w /app -e SERVER_VENV_DIR=/tmp/x-financial-server-venv local-x-financial-linux /tmp/x-financial-server-venv/bin/pytest -q <path>`
- 交互式排查:`docker exec -it -w /app x-financial-main bash`(登录后默认已在 `/app` - 交互式排查:`docker exec -it -w /app local-x-financial-linux bash`(登录后默认已在 `/app`
- **容器不可用时**(未启动、健康检查失败、镜像丢失):先 `docker compose up -d main` 恢复,再继续验证;不要绕开容器在宿主机另装 venv。 - **容器不可用时**(未启动、健康检查失败、镜像丢失):先 `docker compose up -d main` 恢复,再继续验证;不要绕开容器在宿主机另装 venv。
- **单元测试设置合理超时**避免长时间卡死。涉及外部服务Qdrant / OnlyOffice / LLM的测试要么 mock要么确认 compose 网络中依赖服务在线。 - **单元测试设置合理超时**避免长时间卡死。涉及外部服务Qdrant / OnlyOffice / LLM的测试要么 mock要么确认 compose 网络中依赖服务在线。
- **每次重构后至少运行对应服务的定向测试**;涉及公共协议时补充端到端或接口测试。 - **每次重构后至少运行对应服务的定向测试**;涉及公共协议时补充端到端或接口测试。

View File

@@ -1,7 +1,7 @@
services: services:
main: main:
image: x-financial-dev:latest image: x-financial-dev:latest
container_name: x-financial-main container_name: local-x-financial-linux
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:
postgres: postgres:
@@ -15,6 +15,7 @@ services:
WEB_PORT: "${WEB_PORT:-5173}" WEB_PORT: "${WEB_PORT:-5173}"
SERVER_HOST: 0.0.0.0 SERVER_HOST: 0.0.0.0
SERVER_PORT: "${SERVER_PORT:-8000}" SERVER_PORT: "${SERVER_PORT:-8000}"
SERVER_RELOAD: "${SERVER_RELOAD:-true}"
SERVER_VENV_DIR: /tmp/x-financial-server-venv SERVER_VENV_DIR: /tmp/x-financial-server-venv
X_FINANCIAL_PREFER_ENV_FILE: "false" X_FINANCIAL_PREFER_ENV_FILE: "false"
POSTGRES_HOST: postgres POSTGRES_HOST: postgres

View File

@@ -1,13 +1,14 @@
services: services:
main: main:
image: x-financial-dev:latest image: x-financial-dev:latest
container_name: x-financial-main container_name: local-x-financial-linux
restart: unless-stopped restart: unless-stopped
environment: environment:
WEB_HOST: 0.0.0.0 WEB_HOST: 0.0.0.0
WEB_PORT: "${WEB_PORT:-5173}" WEB_PORT: "${WEB_PORT:-5173}"
SERVER_HOST: 0.0.0.0 SERVER_HOST: 0.0.0.0
SERVER_PORT: "${SERVER_PORT:-8000}" SERVER_PORT: "${SERVER_PORT:-8000}"
SERVER_RELOAD: "${SERVER_RELOAD:-true}"
SERVER_VENV_DIR: /tmp/x-financial-server-venv SERVER_VENV_DIR: /tmp/x-financial-server-venv
X_FINANCIAL_PREFER_ENV_FILE: "true" X_FINANCIAL_PREFER_ENV_FILE: "true"
ONLYOFFICE_ENABLED: "${ONLYOFFICE_ENABLED:-false}" ONLYOFFICE_ENABLED: "${ONLYOFFICE_ENABLED:-false}"

View File

@@ -98,6 +98,16 @@
- 验证:`node --test web/tests/workbench-ai-composer-components.test.mjs` 通过 8/8`npm --prefix web run build` 构建通过;`git diff --check` 无输出。 - 验证:`node --test web/tests/workbench-ai-composer-components.test.mjs` 通过 8/8`npm --prefix web run build` 构建通过;`git diff --check` 无输出。
- 影响:用户点击附件打开预览时,弹窗会避开左侧 AI 工作台侧边栏,在右侧主工作区内居中展示,截图里的“偏左/不居中”观感会收敛。 - 影响:用户点击附件打开预览时,弹窗会避开左侧 AI 工作台侧边栏,在右侧主工作区内居中展示,截图里的“偏左/不居中”观感会收敛。
- 11:06我重设计了系统设置里的缓存管理页面让它从“单个按钮 + 原始错误块”变成可读的维护工具页。
- Git 提交检查:`git fetch --all --prune` 失败,错误是 `.git/FETCH_HEAD: Operation not permitted`;当前 `main...origin/main`,基于本地 ref`HEAD..@{u}``@{u}..HEAD` 均未输出新提交。
- 修改:`SettingsView.vue` 将缓存管理区改为维护页式结构包含顶部说明、清理范围概览、安全说明、4 类缓存范围清单、维护操作条和结果反馈区。
- 修改:`settings-view.css` 新增缓存管理布局样式,桌面 4 列、平板 2 列、手机 1 列;整体收敛为企业后台风格,减少截图里的空泛卡片感。
- 修改:`useSettings.js` 增加 `cacheClearFailed``normalizeCacheClearErrorMessage()`,把后端原始 `Not Found` 映射为“缓存清理接口暂不可用,请确认后端服务已加载最新路由后重试。”。
- 修改:`settings-cache-management-section.test.mjs` 增加回归断言,锁定范围清单、保护说明、失败态和友好错误文案。
- 操作:检查本地 `http://127.0.0.1:5173/app/settings?section=cacheManagement` 可达,返回 `HTTP/1.1 200 OK`;确认项目没有现成 Playwright/Puppeteer未新增浏览器依赖。
- 验证:`node --test web/tests/settings-cache-management-section.test.mjs` 通过 3/3`npm --prefix web run build` 构建通过;`git diff --check` 无输出。
- 影响:缓存管理页现在能清楚说明“清什么、不清什么、执行后结果如何”,异常时不再直接显示生硬的 `Not Found`
- 10:41我补修了票据夹 PDF 保存阶段的预览持久化,避免 OCR 后仍把源 PDF 当成附件预览展示。 - 10:41我补修了票据夹 PDF 保存阶段的预览持久化,避免 OCR 后仍把源 PDF 当成附件预览展示。
- Git 提交检查:`git fetch --all --prune` 失败,错误是 `error: cannot open '.git/FETCH_HEAD': Operation not permitted`;当前 `main...origin/main [ahead 1]`;基于本地 ref`HEAD..@{u}` 未输出 upstream 新提交;`@{u}..HEAD` 显示本地 ahead 提交 `9321260 chore(skills): add git checkpoint commit loop` - Git 提交检查:`git fetch --all --prune` 失败,错误是 `error: cannot open '.git/FETCH_HEAD': Operation not permitted`;当前 `main...origin/main [ahead 1]`;基于本地 ref`HEAD..@{u}` 未输出 upstream 新提交;`@{u}..HEAD` 显示本地 ahead 提交 `9321260 chore(skills): add git checkpoint commit loop`
- 修改:`receipt_folder.py``document.preview_data_url` 缺失且源文件是 `application/pdf` 时,保存阶段立即调用 `DocumentPreviewAssets.render_pdf_first_page()` 生成 `preview.png`,并把 `preview_kind``preview_media_type``preview_rendered_with` 写成图片预览元数据;只有渲染异常时才回退到源 PDF 预览。 - 修改:`receipt_folder.py``document.preview_data_url` 缺失且源文件是 `application/pdf` 时,保存阶段立即调用 `DocumentPreviewAssets.render_pdf_first_page()` 生成 `preview.png`,并把 `preview_kind``preview_media_type``preview_rendered_with` 写成图片预览元数据;只有渲染异常时才回退到源 PDF 预览。
@@ -106,6 +116,104 @@
- 验证:`python3 -m py_compile server/src/app/services/receipt_folder.py server/tests/test_receipt_folder_service.py` 通过;宿主机缺少 pytest 和后端依赖,容器 pytest 又因 Docker socket 权限被拒绝,暂未完成项目要求的容器定向测试。 - 验证:`python3 -m py_compile server/src/app/services/receipt_folder.py server/tests/test_receipt_folder_service.py` 通过;宿主机缺少 pytest 和后端依赖,容器 pytest 又因 Docker socket 权限被拒绝,暂未完成项目要求的容器定向测试。
- 影响:后续新上传或重新 OCR 保存的 PDF 票据会优先拥有 PNG 图片预览,前端票据夹预览应走 `<img>` 体验;既有已经写成 PDF fallback 的旧 meta 还需要单独刷新。 - 影响:后续新上传或重新 OCR 保存的 PDF 票据会优先拥有 PNG 图片预览,前端票据夹预览应走 `<img>` 体验;既有已经写成 PDF fallback 的旧 meta 还需要单独刷新。
- 10:56我把系统设置里的 `Agent Trace` 分区替换为“缓存管理”,补上管理员一键清理进程内缓存的前后端链路。
- Git 提交检查:`git fetch --all --prune` 失败,错误是 `error: cannot open '.git/FETCH_HEAD': Operation not permitted`;当前 upstream 为 `origin/main`;基于本地 ref`HEAD..@{u}` 未输出 upstream 新提交,`@{u}..HEAD` 未输出本地 ahead 提交。
- 修改:后端新增 `SystemCacheService``/api/v1/settings/cache/clear`,清理 OCR 识别结果缓存、运行时配置缓存、模型失败冷却缓存、知识库本地索引缓存和地点语义分析缓存,并返回每项清理数量;接口使用管理员依赖保护,不删除票据源文件、业务单据或数据库记录。
- 修改:`OcrService``runtime_chat.py``knowledge_rag_local.py``application_location_semantics.py``config.py` 增加可计数的缓存清理入口,避免继续靠重启服务才能摆脱旧 OCR 结果。
- 修改:前端 `settingsModelHelper.js``agentTraces` 分区替换为 `cacheManagement``SettingsView.vue` 增加“应用缓存”面板和“一键清理缓存”按钮;`useSettings.js` 接入 `clearSystemCaches()`,展示清理中、成功/失败和各缓存项数量;同时移除系统设置里对 `AgentTraceCenterView` 的加载。
- 修改:移除 `LogDetailView.vue``DigitalEmployeeWorkRecords.vue` 中跳转到已删除 `agentTraces` 设置分区的“查看 Trace”按钮避免用户点到死链。
- 修改:新增 `test_system_cache_endpoints.py``settings-cache-management-section.test.mjs`;顺手把设置页既有渲染/LLM 测试的断言目标从外层组件对齐到当前真实模型/子组件,并把渲染设置卡片间距恢复为测试要求的 24px。
- 操作:执行前端设置相关测试、`npm --prefix web run build`、Python `py_compile``git diff --check` 和旧 Agent Trace 设置入口残留搜索。
- 验证:前端设置测试全部通过,`npm --prefix web run build` 通过Python 编译通过,`git diff --check` 无输出;容器 pytest 仍因 Docker socket 权限被拒绝,未能执行 `server/tests/test_system_cache_endpoints.py`
- 影响:管理员可以在系统设置里手动清掉 OCR 等进程内缓存;生产上如果某次 OCR 结果错误,不必依赖重启服务才能让同一附件重新走识别链路。
- 10:57我复查了“重新上传后仍是 PDF 预览”的运行时证据,并补了前端按实际预览 blob 类型纠正展示方式的回归修复。
- Git 提交检查:`git fetch --all --prune` 失败,错误是 `error: cannot open '.git/FETCH_HEAD': Operation not permitted`;当前 `main...origin/main`;基于本地 ref`HEAD..@{u}``@{u}..HEAD` 均未输出提交。
- 发现:最新票据 `25be8906-d3c8-4236-934d-e769ee19d3a7``meta.json` 仍是 `preview_kind=pdf``preview_media_type=application/pdf`,目录里没有 `preview.png`;同时 10:43 的 `POST /api/v1/ocr/recognize` 只耗时 9ms说明运行中后端仍在吃旧 OCR 缓存,或尚未重启加载新代码。
- 发现10:41 之后 `server/logs/app.log` 没有新的 `Starting X-Financial`,所以用户随后测试时后端没有加载刚改的保存阶段预览逻辑;当前 Codex 沙箱执行 `docker ps` / `docker exec` 仍被 Docker socket 权限拒绝,无法替用户重启或检查容器内 `mutool`
- 修改:`ReceiptFolderView.vue``loadPreview()` 取回 blob 后,根据 `blob.type` 推断实际预览类型;如果后端懒刷新后返回 `image/png`,即使详情 JSON 里还是旧的 `preview_kind=pdf`,页面也会把 `selectedReceipt.preview_kind` 修正为 `image` 并走 `<img>`
- 修改:`receipt-folder-view.test.mjs` 新增静态回归断言,锁定 `inferPreviewKindFromBlob()``image/*``application/pdf``loadPreview()` 更新 `preview_kind` 的行为。
- 验证:先运行 `node --test web/tests/receipt-folder-view.test.mjs` 看到新增断言红灯;实现后同一测试通过;`npm --prefix web run build` 通过;`git diff --check` 无输出。容器内后端 pytest/运行时工具检查仍因 Docker socket 权限无法执行。
- 影响:根因层面仍需要重启/重建容器后端加载新代码和 PDF 渲染工具;前端层面已经避免“预览接口返回图片,但详情旧 kind 仍让 UI 当 PDF 展示”的二次误判。
- 11:09我排查了“一键清理缓存”点击后返回 `Not Found` 的原因,并给后端路由表补了 OpenAPI 回归断言。
- Git 提交检查:`git fetch --all --prune` 失败,错误是 `error: cannot open '.git/FETCH_HEAD': Operation not permitted`;当前 upstream 为 `origin/main`;基于本地 ref`HEAD..@{u}``@{u}..HEAD` 均未输出新提交。
- 发现:`server/logs/app.log` 已记录 `POST /api/v1/settings/cache/clear 404`,说明浏览器请求路径没有走偏,确实打进了当前 FastAPI 后端,但运行中的路由表还没包含新接口。
- 发现:后端最后一次启动日志是 10:01 左右,而 `settings.py` 的缓存清理路由是 10:49 后写入;后续没有新的 `Starting X-Financial``touch settings.py` 也没有触发 reload 日志,判断当前容器后端未热重载新代码。
- 修改:`test_openapi_schema.py` 增加 `/api/v1/settings/cache/clear` 的 OpenAPI 断言,要求运行时路由表必须暴露“清理系统缓存”接口,防止后续代码改了但 router 没挂上仍然漏测。
- 操作:尝试用 `docker ps``docker exec` 进入 `local-x-financial-linux`,以及通过 `2223` SSH 进入容器;当前 Codex 沙箱分别被 Docker socket 权限和本机网络策略拒绝,不能直接替用户重启容器或在容器内执行 pytest。
- 验证:`python3 -m py_compile server/tests/test_openapi_schema.py server/src/app/api/v1/endpoints/settings.py server/src/app/schemas/settings.py server/src/app/services/system_cache.py` 通过;`node web/tests/settings-cache-management-section.test.mjs` 通过 3/3容器内 `pytest server/tests/test_system_cache_endpoints.py server/tests/test_openapi_schema.py` 未能执行,原因仍是 Docker socket 权限拒绝。
- 影响:代码层面的接口和回归测试已经补齐;当前页面的 `Not Found` 需要运行中的后端容器重启/热重载后才会消失。
- 11:15我根据你的要求对系统设置里的缓存管理页面进行了全面的视觉和体验重设计升级为具备高级质感和微交互的控制台。
- Git 提交检查:`git fetch --all --prune` 成功执行;当前 `main...origin/main``HEAD..@{u}` 未输出 upstream 新提交;`@{u}..HEAD` 输出部分本地未 push 提交(如上文所述)。
- 修改:`SettingsView.vue` 调整了缓存管理的 DOM 结构,引入了如 `item-ocr`, `item-model` 等特定类名用于精细化配置图标颜色,将四类清理范围展示为动态可交互的卡片。
- 修改:`settings-view.css` 增加了微动效(卡片悬停浮起、图标放大效果)、阴影层级、渐变背景以及平滑过渡,同时升级了“一键清理缓存”的操作按钮质感和渐变绿色安全提示带。
- 验证:本地确认代码结构变更正确无冲突。由于当前沙箱没有可用浏览器自动化插件,未生成真实页面截图验证。
- 影响:缓存管理页面的视觉提升到了更专业现代的企业控制台风格。
- 11:19根据你提供的 ONLYOFFICE 页面截图,我发现你更偏好简洁平滑的系统原生表单设计,而不是之前那些丰富渐变的元素。
- 修改:撤销了刚刚给 `SettingsView.vue``settings-view.css` 中添加的高级渲染效果(去除了所有悬浮渐变、卡片光晕和彩色图标),并恢复为了扁平简约的标准卡片列表样式。
- 影响:现在“应用缓存清理”的样式已经和系统其他选项卡保持了更好的统一。
- 11:23针对“视觉上依然颜色居多”的反馈我进一步清除了缓存管理面板遗留的底层硬编码色彩。
- 发现:在我最初接手前,缓存管理的“清理面板”带有橙色背景(`#fffaf5`)和橙色边框(`#fed7aa`),“安全提示条”带有绿色背景(`#f0fdf4`)和绿色边框(`#bbf7d0`)。
- 修改:在 `settings-view.css` 中,将 `.cache-management-panel``.cache-safety-strip``.cache-clear-button` 的这些定制颜色全部移除,统一使用与系统基调一致的 `#e2e8f0` 边框、`#f8fafc` 浅灰背景和 `#334155` 标准文本色。
- 影响现在整个“缓存管理”页面真正做到了去色彩化和截图里“ONLYOFFICE”配置页一样纯粹克制。
- 11:29我修复了安全提示条和操作面板“顶头”撑满整个卡片边缘的问题。
- 发现:之前这两个模块作为 `.settings-card` 的直接子元素,导致父级的 `padding-left: 24px; padding-right: 24px;` 直接作用在它们身上,而它们自己又带有边框和背景,结果就是在视觉上“边框贴着父容器的边缘”。
- 修改:在 `SettingsView.vue` 中,给这两个模块外面包裹了一层标准的 `<div>` 容器。
- 影响:现在父级的 padding 被外层 `div` 吸收,安全提示条和操作面板恢复了正常的内缩边距,完美对齐了中间的 4 个范围网格,不再有“顶破边界”的突兀感。
- 11:20我继续排查“一键清理缓存”仍不可用并修正容器名与容器 reload 启动配置。
- Git 提交检查:`git fetch --all --prune` 失败,错误是 `error: cannot open '.git/FETCH_HEAD': Operation not permitted`;当前 upstream 为 `origin/main`;基于本地 ref`HEAD..@{u}``@{u}..HEAD` 均未输出新提交。
- 发现:`server/logs/app.log` 继续记录 03:07、03:11、03:14 的 `POST /api/v1/settings/cache/clear 404`;请求仍然打进 FastAPI但运行路由表仍是旧进程。
- 发现:`server/server_start.sh` 在容器内默认关闭 `SERVER_RELOAD`,而外层 `start.sh` 之前没有容器判断,会误以为 `APP_DEBUG=true` 就是 reload 模式,导致复用旧后端时没有提示。
- 修改:`AGENTS.md``docker-compose.yml``docker-compose.full.yml` 将主容器名统一为 `local-x-financial-linux`compose 同时显式注入 `SERVER_RELOAD=${SERVER_RELOAD:-true}`,让本地开发容器后端默认开启 uvicorn reload。
- 修改:`start.sh` 增加 `is_container()` 判断,容器内默认 reload 状态与 `server/server_start.sh` 保持一致;如果没有显式开启 reload复用既有 FastAPI 时会准确提示可能是旧后端。
- 修改:`settings-cache-management-section.test.mjs` 对齐当前缓存管理页 DOM 结构,避免继续断言已经不存在的旧 `cache-management-hero` 容器。
- 验证:`sh -n start.sh && sh -n server/server_start.sh` 通过;`python3 -m py_compile ...` 通过;`node web/tests/settings-cache-management-section.test.mjs` 通过 3/3`git diff --check` 无输出;容器内 pytest 仍因 Docker socket 权限被拒绝。
- 影响:重启/重建 `local-x-financial-linux` 后,缓存清理接口应加载进运行路由表;后续本地开发容器里的后端改动也不会再静默停留在旧进程。
- 11:39我修复了“清缓存后对话归集附件详情页变成 PDF 预览、识别信息退化为其他单据”的问题。
- Git 提交检查:`git fetch --all --prune` 失败,错误是 `error: cannot open '.git/FETCH_HEAD': Operation not permitted`;当前 upstream 为 `origin/main`;基于本地 ref`HEAD..@{u}``@{u}..HEAD` 均未输出新提交;工作区仍有多项既有未提交改动。
- 根因:对话上传附件先写入票据夹,再归集到报销单附件;归集时会重新跑一次附件 OCR如果这次结果没有 `preview_data_url`,即使票据夹里已有 PNG 预览,报销单附件 meta 仍会回退到源 PDF。清 OCR 缓存后,详情页不再有会话临时结果兜底,就暴露为 PDF 预览和“其他单据”。
- 修改:`expense_claim_attachment_operations.py` 读取 `source_receipt_id` 对应的票据夹预览资源,并在 OCR 来源选择时保留不弱于新 OCR 且带图片预览的票据夹结果,避免被一次同等质量但无预览图的新 OCR 覆盖。
- 修改:`expense_claim_attachment_presentation.py` 支持把票据夹已有的 PNG 预览复制到报销单附件目录,写入 `preview_kind=image``preview_media_type=image/png` 和新的 `preview_storage_key`
- 修改:`expense_claim_attachment_document.py` 增加历史坏 meta 自动修复:当附件 meta 仍有 `source_receipt_id`,且当前是 PDF 预览、`other` 类型或无字段时,详情页读取 meta/预览会从票据夹重新补回 OCR 字段和 PNG 预览。
- 修改:`test_attachment_association_jobs.py` 增加两条回归测试,分别覆盖清 OCR 缓存后归集仍保留票据夹 PNG/字段,以及已经退化成 PDF/其他单据的历史附件能通过 `source_receipt_id` 自动修复。
- 验证:`python3 -m py_compile server/src/app/services/expense_claim_attachment_presentation.py server/src/app/services/expense_claim_attachment_operations.py server/src/app/services/expense_claim_attachment_document.py server/tests/test_attachment_association_jobs.py` 通过;`git diff --check` 通过;容器内定向 pytest 仍因 Docker socket 权限被拒绝,命令未能进入 `local-x-financial-linux`
- 影响:后续从 AI 对话上传并自动归集的 PDF 票据,应在单据详情页展示报销单附件目录里的 PNG 预览,并稳定保留火车票字段;已经坏写入的附件只要 meta 里还保留 `source_receipt_id`,打开详情页时会尝试自动修复。
- 11:55我修复了 AI 工作台上传 PDF 后 OCR 退化成“其他单据/空字段”的问题,并修复了同文件重复上传继续复用旧坏 meta 的链路。
- Git 提交检查:`git fetch --all --prune` 成功;当前 `main...origin/main``HEAD..@{u}``@{u}..HEAD` 均未输出新提交;工作区仍有多项既有未提交改动,本轮只新增 OCR/票据夹相关修改。
- 根因:运行容器缺 `poppler-data` / `mutool`,中文 PDF 转图失败;`OcrService` 虽然先提取了 `pdftotext` 文本层,但 `_prepare_pdf_inputs()` 转图失败后直接返回失败结果,文本层没有机会进入分类和字段抽取。
- 根因:同一 PDF 之前已经以 `other/空字段` 写入票据夹时,`persist_ocr_batch()` 的重复文件路径会读取旧 meta 并覆盖新 OCR 结果,导致用户重新上传同一文件仍看到“当前会话已识别 · 其他单据”。
- 修改:`ocr.py` 增加 PDF 文本层兜底结果构建;当转图失败但文本层有有效字符时,继续生成结构化识别结果,同时保留转图失败 warning不把坏 PNG 当成预览。
- 修改:`receipt_folder.py` 在重复文件命中旧票据时,如果新 OCR 结果明显更强(非 `other`、有字段、有文本),会刷新旧票据的 OCR 派生 meta再返回带重复上传 warning 的新结果;同时把“身份证号”这类标签排除出乘车人候选,避免字段误填。
- 修改:`test_ocr_service.py``test_receipt_folder_service.py` 增加红绿回归,覆盖 PDF 转图失败但文本层可用、旧坏 meta 被新 OCR 修复,以及测试间 OCR cache 隔离。
- 操作:重启当前实际运行容器 `x-financial-local-linux`,等待 `http://127.0.0.1:5173/api/v1/health` 恢复;随后用 `caoxiaozhu@xf.com` 用户头对 `2月20_武汉-上海.pdf``2月23_上海-武汉.pdf` 重新调用 `/api/v1/ocr/recognize`,修复同批两条旧坏票据 meta。
- 验证:容器内 `pytest -q server/tests/test_ocr_service.py server/tests/test_receipt_folder_service.py server/tests/test_ocr_endpoints.py server/tests/test_attachment_association_jobs.py` 通过 28/28`python -m py_compile` 通过;`git diff --check` 无输出;真实 5173 OCR 接口返回两张 PDF 均为 `火车/高铁票`,字段包含时间、车次、行程、金额、身份证号、车厢、座位号和商户。
- 影响:后续即使中文 PDF 转图依赖暂时缺失AI 工作台也不再把可读文本层的火车票退化成“其他单据”;已坏写入的同文件重复上传会被新 OCR 结果修复,而不是继续复用旧空字段。
- 12:04我补齐当前实际运行容器的 PDF 渲染依赖,并把同批两张火车票 PDF 的预览刷新成 PNG 图片。
- Git 提交检查:`git fetch --all --prune` 成功;当前 `main...origin/main``HEAD..@{u}``@{u}..HEAD` 均未输出新提交;工作区仍有多项既有未提交改动。
- 根因:仓库 `docker-compose.yml` 已写入 `poppler-data mupdf-tools`,但当前占用 5173 的仍是旧容器 `x-financial-local-linux`,它没有按最新 compose 重建,容器内只有 `pdftoppm/pdftocairo/pdftotext`,缺 `poppler-data``mutool`,所以后端只能回退到源 PDF 预览。
- 操作:在当前开发容器内执行 `apt-get update && apt-get install -y --no-install-recommends poppler-data mupdf-tools`;随后访问两条 receipt 的 `/preview` 接口触发 `_refresh_pdf_preview_asset_if_needed()` 生成 `preview.png`
- 验证:两个 `/api/v1/receipt-folder/{id}/preview` 响应都已从 `application/pdf` 变为 `image/png``file` 确认为 `PNG image data, 1323 x 882`;两条 `meta.json` 均写入 `preview_kind=image``preview_media_type=image/png``preview_file_name=preview.png``preview_rendered_with=pdf-raster-cjk-safe-v3`;人工查看 `preview.png` 确认中文票面正常渲染。
- 影响:当前 5173 页面重新打开这两条票据预览时应直接走图片预览;后续当前容器内新上传同类中文 PDF 也会优先生成 PNG 预览。
- 12:23我把会话上传附件、票据夹和报销附件相关的预览判定收敛到统一的前端预览资产模型并让 OCR 返回带回票据夹图片预览类型。
- Git 提交检查:`git fetch --all --prune` 成功;当前 upstream 为 `origin/main``HEAD..@{u}``@{u}..HEAD` 均未输出新提交;工作区仍有多项既有未提交改动。
- 根因AI 会话上传附件条之前只按原始 `File.type/name` 判断卡片类型,所以 PDF 票据即使 OCR 已识别为火车票、票据夹预览接口已返回 PNG卡片仍显示 PDF弹窗也只优先吃 `preview_data_url`,没有统一处理 `receipt_preview_url`
- 修改:新增 `documentPreviewAssets.js`统一提供文件、URL、blob、OCR document 的预览类型/资产解析;`travelReimbursementAttachmentModel.js``ReceiptFolderView.vue``workbenchAiComposerModel.js``useWorkbenchAiFilePreview.js` 改为复用同一套解析逻辑。
- 修改:会话附件卡片在 OCR document 带 `preview_kind=image` + `receipt_preview_url` 时优先显示图片类图标,不再继续按原始 PDF 类型展示;附件预览弹窗遇到票据夹预览接口时通过 `fetchReceiptFolderAsset()` 带鉴权拉取 blob再按真实 `Content-Type` 选择图片/PDF 展示。
- 修改:`receipt_folder.py` 在 OCR 持久化后把票据夹 meta 里的 `preview_kind` 回填到返回给会话层的 OCR document新增后端回归测试覆盖 PDF OCR document 持久化后返回 `preview_kind=image`
- 验证:前端 `node --test web/tests/attachment-association-confirmation.test.mjs web/tests/workbench-ai-composer-components.test.mjs` 通过 24/24`node --test web/tests/receipt-folder-view.test.mjs` 通过;容器内 `pytest -q server/tests/test_ocr_endpoints.py server/tests/test_ocr_service.py server/tests/test_receipt_folder_service.py` 通过 25/25`npm --prefix web run build` 通过;`python -m py_compile` 通过;`git diff --check` 无输出。
- 操作:重启当前实际运行容器 `x-financial-local-linux` 并确认 `/api/v1/health` 正常;真实 5173 `/api/v1/ocr/recognize` 重新上传 `2月23_上海-武汉.pdf` 后返回 `document_type=train_ticket``preview_kind=image``receipt_preview_url=/receipt-folder/.../preview`,对应 `/preview` 响应 `content-type: image/png`
- 影响AI 会话上传 PDF 火车票后,附件条和预览弹窗都会走统一预览资产判定;后续其它入口只要使用 `documentPreviewAssets.js`,就不会再各自维护一套 PDF/图片判断。
## 遗留问题 ## 遗留问题
- 09:41当前 Skill 是新建在项目级 `.codex/skills` 目录里,本轮可以通过文件检查验证结构,但是否被未来会话自动加载还依赖 Codex 对项目 Skill 的刷新机制。建议后续新开会话或下一次任务时确认 Skill 列表是否出现 `agent-change-log` - 09:41当前 Skill 是新建在项目级 `.codex/skills` 目录里,本轮可以通过文件检查验证结构,但是否被未来会话自动加载还依赖 Codex 对项目 Skill 的刷新机制。建议后续新开会话或下一次任务时确认 Skill 列表是否出现 `agent-change-log`
@@ -114,11 +222,20 @@
- 10:02本地 ahead 提交也可能来自其他智能体,不能只看 upstream behind。建议后续日志固定同时记录 `HEAD..@{u}``@{u}..HEAD` 两个方向。 - 10:02本地 ahead 提交也可能来自其他智能体,不能只看 upstream behind。建议后续日志固定同时记录 `HEAD..@{u}``@{u}..HEAD` 两个方向。
- 10:21自动日志触发时发现 fetch 未成功失败error: cannot open '.git/FETCH_HEAD': Operation not permitted。建议后续在有 Git 写权限和网络权限的环境里重新执行拉取检查。 - 10:21自动日志触发时发现 fetch 未成功失败error: cannot open '.git/FETCH_HEAD': Operation not permitted。建议后续在有 Git 写权限和网络权限的环境里重新执行拉取检查。
- 10:21当前环境不能写 `.git/hooks`,所以 post-commit hook 模板已经入库,但尚未安装到本 checkout。建议在有 `.git` 写权限的环境执行 `tools/agent-change-log/install_post_commit_hook.sh` - 10:21当前环境不能写 `.git/hooks`,所以 post-commit hook 模板已经入库,但尚未安装到本 checkout。建议在有 `.git` 写权限的环境执行 `tools/agent-change-log/install_post_commit_hook.sh`
- 10:24本轮未能在容器内执行 pytest也未能实际确认容器是否已安装 `mupdf-tools`。建议在 Docker 权限恢复后重建/重启 `x-financial-main`,执行定向 OCR 测试,并在 5173 用真实 PDF 票据确认预览图和 OCR 字段都保留中文。 - 10:24本轮未能在容器内执行 pytest也未能实际确认容器是否已安装 `mupdf-tools`。建议在 Docker 权限恢复后重建/重启 `local-x-financial-linux`,执行定向 OCR 测试,并在 5173 用真实 PDF 票据确认预览图和 OCR 字段都保留中文。
- 10:2810:06 那条既有票据记录已经写成 PDF fallback代码修复不会自动改写旧 meta。建议重新上传一次同一 PDF或在容器权限恢复后触发票据重识别/重建预览,确认新记录变为 PNG 预览。 - 10:2810:06 那条既有票据记录已经写成 PDF fallback代码修复不会自动改写旧 meta。建议重新上传一次同一 PDF或在容器权限恢复后触发票据重识别/重建预览,确认新记录变为 PNG 预览。
- 10:32最新 10:29 上传仍然写成 PDF说明运行中的后端可能还没加载最新代码或本轮上传发生在缓存版本修复之前。建议重启后端/重建容器后再重新上传,确认 OCR cache key 已包含 `pdf-image-ocr:` - 10:32最新 10:29 上传仍然写成 PDF说明运行中的后端可能还没加载最新代码或本轮上传发生在缓存版本修复之前。建议重启后端/重建容器后再重新上传,确认 OCR cache key 已包含 `pdf-image-ocr:`
- 10:40本轮可以确认 5173 服务可达、结构测试和生产构建通过,但当前环境没有可调用的浏览器自动化插件,项目也没有现成 Playwright/Puppeteer 依赖,所以未生成真实页面截图。建议在具备浏览器自动化的环境回放一次附件预览,确认主内容区居中效果。 - 10:40本轮可以确认 5173 服务可达、结构测试和生产构建通过,但当前环境没有可调用的浏览器自动化插件,项目也没有现成 Playwright/Puppeteer 依赖,所以未生成真实页面截图。建议在具备浏览器自动化的环境回放一次附件预览,确认主内容区居中效果。
- 11:06缓存管理 UI 本轮已完成结构测试、构建和 5173 路由可达性检查,但没有真实浏览器截图证据。建议后续在本机浏览器打开系统设置 / 缓存管理,确认视觉密度和按钮位置是否符合预期。
- 10:41票据夹 PDF 保存阶段已补主动生成图片预览,但当前沙箱不能访问 Docker socket无法运行容器内 pytest也无法刷新已有 PDF 票据的旧 meta。建议 Docker 权限恢复后先跑定向测试,再对旧记录触发 `resolve_preview` 或重识别来补 `preview.png` - 10:41票据夹 PDF 保存阶段已补主动生成图片预览,但当前沙箱不能访问 Docker socket无法运行容器内 pytest也无法刷新已有 PDF 票据的旧 meta。建议 Docker 权限恢复后先跑定向测试,再对旧记录触发 `resolve_preview` 或重识别来补 `preview.png`
- 10:56系统缓存清理接口已通过语法检查和前端构建但后端容器定向 pytest 仍被 Docker socket 权限挡住。建议 Docker 权限恢复后运行 `server/tests/test_system_cache_endpoints.py`,并在 5173 系统设置页实际点击一次“一键清理缓存”确认接口返回明细。
- 10:57用户最新测试仍看到 PDF 预览,当前证据指向运行中的后端未重启加载新代码,且容器内 PDF 渲染工具状态无法由本沙箱确认。建议在有 Docker 权限的终端重建/重启 `local-x-financial-linux` 或当前 compose main 服务,确认 `mutool` / `pdftoppm` 可用后重新打开票据预览。
- 11:09`POST /api/v1/settings/cache/clear` 的 404 已确认来自运行中 FastAPI 未加载新路由;当前沙箱不能通过 Docker socket 或 SSH 进入容器重启。建议在有 Docker 权限的终端重启/重建 `local-x-financial-linux`,然后检查 `/api/v1/openapi.json` 是否包含 `/api/v1/settings/cache/clear`
- 11:15高级 UI 视觉重构暂无本地真实页面渲染的自动化截图,且 11:19 已按截图反馈收敛回简洁风格。建议后续以真实浏览器截图为准继续细调。
- 11:20当前 Codex 沙箱仍无法访问 Docker socket不能直接替用户重启 `local-x-financial-linux` 或运行容器内 pytest。建议在有 Docker 权限的终端重启/重建该容器,再重新点击“一键清理缓存”确认不再 404。
- 11:39本轮新增的附件归集回归测试还没有在容器内真正执行原因仍是 Docker socket 权限拒绝。建议 Docker 权限恢复后优先运行 `server/tests/test_attachment_association_jobs.py` 新增两条测试,并在 5173 重新打开刚才那张坏票据验证自动修复是否生效。
- 11:55当前实际运行容器仍叫 `x-financial-local-linux`,且容器内 `poppler-data` 未安装、`mutool` 不存在;本轮文本层兜底已恢复 OCR 字段,但 PDF 图片预览仍会带转图失败 warning。12:04 已在当前运行容器补齐依赖并刷新本批票据预览;建议后续仍按最新 compose 统一到 `local-x-financial-linux`,避免旧容器继续抢占 5173。
- 12:23本轮没有拿到可用的浏览器自动化插件来生成真页截图已用前端构建、组件测试和真实 5173 OCR/preview 接口替代验证。建议用户侧刷新页面后重新上传同类 PDF若历史会话里旧附件卡片仍停留在旧状态则重新选择附件触发 OCR 状态刷新。
## TODO ## TODO
@@ -130,9 +247,20 @@
- [x] ~~补上提交后自动写日志的可执行脚本和 hook 模板。~~(完成于 10:21证据`update_change_log.py` dry-run 与真实写入成功,`.githooks/post-commit` 已新增) - [x] ~~补上提交后自动写日志的可执行脚本和 hook 模板。~~(完成于 10:21证据`update_change_log.py` dry-run 与真实写入成功,`.githooks/post-commit` 已新增)
- [ ] 在有 `.git` 写权限的环境执行 `tools/agent-change-log/install_post_commit_hook.sh`让提交后自动写日志真正启用。来源10:21 当前沙箱安装失败) - [ ] 在有 `.git` 写权限的环境执行 `tools/agent-change-log/install_post_commit_hook.sh`让提交后自动写日志真正启用。来源10:21 当前沙箱安装失败)
- [ ] 在后续每次 bugfix、新功能、重构或配置/文档修改后,调用 `agent-change-log` 并增量更新当天日志。来源09:41 用户要求) - [ ] 在后续每次 bugfix、新功能、重构或配置/文档修改后,调用 `agent-change-log` 并增量更新当天日志。来源09:41 用户要求)
- [ ] 重建/重启 `x-financial-main`,确认容器内存在 `mutool`,并执行 OCR 定向 pytest。来源10:24 PDF 中文转图链路修复) - [ ] 重建/重启 `local-x-financial-linux`,确认容器内存在 `mutool`,并执行 OCR 定向 pytest。来源10:24 PDF 中文转图链路修复)
- [ ] 在 5173 真页重新上传/预览火车票 PDF确认预览 PNG 不再丢中文OCR 字段不再出现“乘车人/站点”等中文缺失或错位。来源10:24 PDF 中文转图链路修复) - [ ] 在 5173 真页重新上传/预览火车票 PDF确认预览 PNG 不再丢中文OCR 字段不再出现“乘车人/站点”等中文缺失或错位。来源10:24 PDF 中文转图链路修复)
- [ ] 重新上传 10:06 同款 PDF 或触发该票据重识别,确认新的 `meta.json` 写入 `preview_kind=image``preview_media_type=image/png`来源10:28 PNG 预览保留修复) - [ ] 重新上传 10:06 同款 PDF 或触发该票据重识别,确认新的 `meta.json` 写入 `preview_kind=image``preview_media_type=image/png`来源10:28 PNG 预览保留修复)
- [ ] 后端加载缓存版本修复后,重新上传同一 PDF确认不会再命中旧 OCR 缓存,`ocr_line_count` 和 PNG 预览都来自新转图流程。来源10:32 OCR cache key 修复) - [ ] 后端加载缓存版本修复后,重新上传同一 PDF确认不会再命中旧 OCR 缓存,`ocr_line_count` 和 PNG 预览都来自新转图流程。来源10:32 OCR cache key 修复)
- [ ] 在有浏览器自动化能力的环境回放 AI 工作台附件预览弹窗截图确认弹窗按侧边栏右侧主内容区居中。来源10:40 附件预览弹窗布局修复) - [ ] 在有浏览器自动化能力的环境回放 AI 工作台附件预览弹窗截图确认弹窗按侧边栏右侧主内容区居中。来源10:40 附件预览弹窗布局修复)
- [ ] 在真实浏览器回看系统设置 / 缓存管理页,确认 4 类缓存范围清单、维护操作条和错误反馈在当前主题下没有拥挤或错位。来源11:06 缓存管理 UI 重设计)
- [ ] Docker 权限恢复后运行票据夹 PDF 预览定向测试,并刷新已有 `preview_kind=pdf` 的旧票据 meta。来源10:41 PDF 保存阶段主动生成图片预览修复) - [ ] Docker 权限恢复后运行票据夹 PDF 预览定向测试,并刷新已有 `preview_kind=pdf` 的旧票据 meta。来源10:41 PDF 保存阶段主动生成图片预览修复)
- [ ] Docker 权限恢复后运行 `server/tests/test_system_cache_endpoints.py`,确认 `/api/v1/settings/cache/clear` 在容器内清理 OCR 缓存并拒绝非管理员。来源10:56 系统缓存管理入口)
- [ ] 在 5173 系统设置页点击“一键清理缓存”,确认按钮 loading、toast 和清理明细符合预期。来源10:56 系统缓存管理入口)
- [ ] 重启/重建运行中的后端容器后,重新打开 `25be8906-d3c8-4236-934d-e769ee19d3a7` 这类旧 PDF 票据详情,确认预览接口能生成 `preview.png`,且前端根据 `image/png` blob 切到图片预览。来源10:57 运行时未加载新代码与前端 kind 同步修复)
- [ ] 重启/重建 `local-x-financial-linux` 后重新点击“一键清理缓存”,确认 `server/logs/app.log` 不再出现 `POST /api/v1/settings/cache/clear 404`并返回清理明细。来源11:09 缓存清理接口运行时未重载)
- [ ] 重启/重建 `local-x-financial-linux` 后确认 `SERVER_RELOAD=true` 已生效,再修改一个后端入口文件验证日志出现新的 `Starting X-Financial`来源11:20 容器 reload 配置修复)
- [ ] Docker 权限恢复后运行 `server/tests/test_attachment_association_jobs.py::test_attachment_association_keeps_receipt_folder_preview_and_fields_after_cache_clear``server/tests/test_attachment_association_jobs.py::test_attachment_meta_repairs_existing_pdf_fallback_from_source_receipt`来源11:39 对话归集附件预览/字段持久化修复)
- [ ] 在 5173 打开刚才清缓存后退化为 PDF/其他单据的报销单附件详情,确认 meta 自动修复后预览为 PNG识别信息恢复火车/高铁票和字段列表。来源11:39 历史坏 meta 自动修复)
- [x] ~~重新跑截图同批火车票 PDF 的 OCR 接口,确认不再返回“其他单据/空字段”。~~(完成于 11:55证据5173 `/api/v1/ocr/recognize` 返回 `2月20_武汉-上海.pdf``2月23_上海-武汉.pdf` 均为 `火车/高铁票`,并写回票据夹字段)
- [x] ~~重建或补齐当前运行容器的 `poppler-data` / `mupdf-tools`,确认 `mutool` 可用后再上传同类中文 PDF目标是同时恢复 PNG 预览和 OCR 字段。~~(完成于 12:04证据`apt-get install poppler-data mupdf-tools` 成功,`/usr/bin/mutool` 可用;两条 `/preview` 返回 `image/png`,并写入 `preview_kind=image`
- [x] ~~统一 AI 会话附件、票据夹和报销附件的预览类型判定,避免会话上传卡片继续把已生成 PNG 预览的 PDF 当成 PDF 展示。~~(完成于 12:23证据新增 `documentPreviewAssets.js`;前端相关测试 24/24 通过,真实 OCR 返回 `preview_kind=image``/preview``image/png`

View File

@@ -5,18 +5,20 @@ from typing import Annotated
from fastapi import APIRouter, Depends, Header, HTTPException, status from fastapi import APIRouter, Depends, Header, HTTPException, status
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.api.deps import get_db from app.api.deps import CurrentUserContext, get_db, require_admin_user
from app.core.config import get_settings as get_runtime_settings from app.core.config import get_settings as get_runtime_settings
from app.schemas.common import ErrorResponse from app.schemas.common import ErrorResponse
from app.schemas.settings import ( from app.schemas.settings import (
ModelConnectivityTestRead, ModelConnectivityTestRead,
ModelConnectivityTestRequest, ModelConnectivityTestRequest,
RuntimeModelConfigRead, RuntimeModelConfigRead,
SettingsCacheClearRead,
SettingsRead, SettingsRead,
SettingsWrite, SettingsWrite,
) )
from app.services.model_connectivity import probe_model_connectivity from app.services.model_connectivity import probe_model_connectivity
from app.services.settings import SettingsService from app.services.settings import SettingsService
from app.services.system_cache import SystemCacheService
router = APIRouter(prefix="/settings") router = APIRouter(prefix="/settings")
DbSession = Annotated[Session, Depends(get_db)] DbSession = Annotated[Session, Depends(get_db)]
@@ -93,6 +95,24 @@ def test_model_connectivity(
return probe_model_connectivity(resolved_payload) return probe_model_connectivity(resolved_payload)
@router.post(
"/cache/clear",
response_model=SettingsCacheClearRead,
summary="清理系统缓存",
description="清理 OCR、模型失败冷却、知识库索引和运行时配置等进程内缓存不删除业务文件或数据库记录。",
responses={
status.HTTP_403_FORBIDDEN: {
"model": ErrorResponse,
"description": "只有管理员可以清理系统缓存。",
}
},
)
def clear_system_cache(
_: Annotated[CurrentUserContext, Depends(require_admin_user)],
) -> SettingsCacheClearRead:
return SystemCacheService().clear_all()
@router.get( @router.get(
"/runtime-models/{slot}", "/runtime-models/{slot}",
response_model=RuntimeModelConfigRead, response_model=RuntimeModelConfigRead,

View File

@@ -169,6 +169,12 @@ def _clear_settings_cache() -> None:
_settings_cache_signature = None _settings_cache_signature = None
def clear_runtime_settings_cache() -> int:
cleared_count = int(_settings_cache is not None)
_clear_settings_cache()
return cleared_count
def get_settings() -> Settings: def get_settings() -> Settings:
global _settings_cache, _settings_cache_signature global _settings_cache, _settings_cache_signature

View File

@@ -222,6 +222,17 @@ class ModelConnectivityTestRead(BaseModel):
checked_at: datetime checked_at: datetime
class SettingsCacheClearItemRead(BaseModel):
cacheKey: str
label: str
clearedCount: int = Field(default=0, ge=0)
class SettingsCacheClearRead(BaseModel):
totalCleared: int = Field(default=0, ge=0)
items: list[SettingsCacheClearItemRead] = Field(default_factory=list)
class RuntimeModelConfigRead(BaseModel): class RuntimeModelConfigRead(BaseModel):
slot: Literal["main", "backup", "embedding", "reranker"] slot: Literal["main", "backup", "embedding", "reranker"]
provider: str provider: str

View File

@@ -123,6 +123,14 @@ def _load_jieba_posseg() -> Any:
return pseg return pseg
def clear_application_location_semantic_caches() -> int:
cleared_count = _load_lac_analyzer.cache_info().currsize
cleared_count += _load_jieba_posseg.cache_info().currsize
_load_lac_analyzer.cache_clear()
_load_jieba_posseg.cache_clear()
return cleared_count
def _iter_jieba_custom_words() -> Iterable[str]: def _iter_jieba_custom_words() -> Iterable[str]:
yield from JIEBA_CUSTOM_WORDS yield from JIEBA_CUSTOM_WORDS
yield from DIRECT_MUNICIPALITY_DISPLAY yield from DIRECT_MUNICIPALITY_DISPLAY

View File

@@ -111,9 +111,20 @@ from app.services.ocr import OcrService
class ExpenseClaimAttachmentDocumentMixin: class ExpenseClaimAttachmentDocumentMixin:
def _build_attachment_payload(self, item: ExpenseClaimItem) -> dict[str, Any]: def _build_attachment_payload(
self,
item: ExpenseClaimItem,
*,
current_user: CurrentUserContext | None = None,
) -> dict[str, Any]:
file_path, media_type, filename = self._resolve_item_attachment_content(item) file_path, media_type, filename = self._resolve_item_attachment_content(item)
metadata = self._attachment_storage.read_meta(file_path) metadata = self._attachment_storage.read_meta(file_path)
metadata = self._repair_attachment_metadata_from_source_receipt_if_needed(
file_path=file_path,
metadata=metadata,
item=item,
current_user=current_user,
)
metadata = self._repair_pdf_text_layer_metadata_if_needed( metadata = self._repair_pdf_text_layer_metadata_if_needed(
file_path=file_path, file_path=file_path,
metadata=metadata, metadata=metadata,
@@ -164,6 +175,108 @@ class ExpenseClaimAttachmentDocumentMixin:
"requirement_check": requirement_check, "requirement_check": requirement_check,
} }
def _repair_attachment_metadata_from_source_receipt_if_needed(
self,
*,
file_path: Path,
metadata: dict[str, Any],
item: ExpenseClaimItem,
current_user: CurrentUserContext | None,
) -> dict[str, Any]:
if not metadata or current_user is None:
return metadata
source_receipt_id = str(metadata.get("source_receipt_id") or "").strip()
if not source_receipt_id:
return metadata
if not self._attachment_metadata_needs_source_receipt_repair(metadata):
return metadata
source_document = self._resolve_source_receipt_document(
source_receipt_id=source_receipt_id,
current_user=current_user,
fallback_filename=str(metadata.get("file_name") or file_path.name),
fallback_media_type=str(metadata.get("media_type") or ""),
)
if source_document is None:
return metadata
document_info = self._build_attachment_document_info(source_document)
requirement_check = self._build_attachment_requirement_check(
item=item,
document_info=document_info,
)
preview_meta = self._attachment_presentation.build_preview_meta(
file_path=file_path,
media_type=str(
metadata.get("media_type")
or self._attachment_presentation.resolve_media_type(file_path.name)
),
ocr_document=source_document,
)
metadata.update(
{
"previewable": bool(preview_meta["previewable"]),
"preview_kind": str(preview_meta["preview_kind"]),
"preview_storage_key": str(preview_meta["preview_storage_key"]),
"preview_media_type": str(preview_meta["preview_media_type"]),
"preview_file_name": str(preview_meta["preview_file_name"]),
"preview_rendered_with": str(preview_meta.get("preview_rendered_with") or ""),
"analysis": self._build_attachment_analysis(
document=source_document,
item=item,
claim=getattr(item, "claim", None),
document_info=document_info,
requirement_check=requirement_check,
),
"document_info": document_info,
"requirement_check": requirement_check,
"ocr_status": "recognized",
"ocr_error": "",
"ocr_text": str(getattr(source_document, "text", "") or ""),
"ocr_summary": str(getattr(source_document, "summary", "") or ""),
"ocr_avg_score": float(getattr(source_document, "avg_score", 0.0) or 0.0),
"ocr_line_count": int(getattr(source_document, "line_count", 0) or 0),
"ocr_classification_source": str(
getattr(source_document, "classification_source", "") or ""
),
"ocr_classification_confidence": float(
getattr(source_document, "classification_confidence", 0.0) or 0.0
),
"ocr_classification_evidence": [
str(value)
for value in list(getattr(source_document, "classification_evidence", []) or [])
if str(value).strip()
],
"ocr_warnings": [
str(value)
for value in list(getattr(source_document, "warnings", []) or [])
if str(value).strip()
],
}
)
self._attachment_storage.write_meta(file_path, metadata)
return metadata
@classmethod
def _attachment_metadata_needs_source_receipt_repair(cls, metadata: dict[str, Any]) -> bool:
document_info = metadata.get("document_info")
document_type = ""
fields: list[Any] = []
if isinstance(document_info, dict):
document_type = str(document_info.get("document_type") or "").strip()
fields = list(document_info.get("fields") or [])
return (
str(metadata.get("preview_kind") or "").strip() != "image"
or document_type in {"", "other"}
or not any(
isinstance(field, dict) and str(field.get("value") or "").strip()
for field in fields
)
)
@classmethod @classmethod
def _attachment_metadata_needs_analysis_refresh(cls, metadata: dict[str, Any]) -> bool: def _attachment_metadata_needs_analysis_refresh(cls, metadata: dict[str, Any]) -> bool:
analysis = metadata.get("analysis") analysis = metadata.get("analysis")

View File

@@ -313,8 +313,9 @@ class ExpenseClaimAttachmentOperationsMixin:
if not normalized_receipt_id: if not normalized_receipt_id:
return None return None
receipt_service = ReceiptFolderService()
try: try:
receipt = ReceiptFolderService().get_receipt(normalized_receipt_id, current_user) receipt = receipt_service.get_receipt(normalized_receipt_id, current_user)
except FileNotFoundError: except FileNotFoundError:
return None return None
@@ -325,6 +326,20 @@ class ExpenseClaimAttachmentOperationsMixin:
if not fields: if not fields:
fields = self._normalize_receipt_document_fields(raw_meta.get("document_fields")) fields = self._normalize_receipt_document_fields(raw_meta.get("document_fields"))
preview_source_path = None
preview_media_type = ""
preview_file_name = ""
if str(raw_meta.get("preview_kind") or "").strip() == "image":
try:
preview_source_path, preview_media_type, preview_file_name = receipt_service.resolve_preview(
normalized_receipt_id,
current_user,
)
except FileNotFoundError:
preview_source_path = None
preview_media_type = ""
preview_file_name = ""
document = SimpleNamespace( document = SimpleNamespace(
filename=str(receipt.file_name or fallback_filename or "").strip(), filename=str(receipt.file_name or fallback_filename or "").strip(),
media_type=str(receipt.media_type or fallback_media_type or "application/octet-stream").strip(), media_type=str(receipt.media_type or fallback_media_type or "application/octet-stream").strip(),
@@ -359,6 +374,9 @@ class ExpenseClaimAttachmentOperationsMixin:
document_fields=fields, document_fields=fields,
preview_kind=str(raw_meta.get("preview_kind") or ""), preview_kind=str(raw_meta.get("preview_kind") or ""),
preview_data_url="", preview_data_url="",
preview_source_path=str(preview_source_path or ""),
preview_media_type=preview_media_type,
preview_file_name=preview_file_name,
warnings=[ warnings=[
str(value) str(value)
for value in list(receipt.warnings or raw_meta.get("ocr_warnings") or []) for value in list(receipt.warnings or raw_meta.get("ocr_warnings") or [])
@@ -399,8 +417,16 @@ class ExpenseClaimAttachmentOperationsMixin:
source_type = cls._attachment_document_type(source_receipt_document) source_type = cls._attachment_document_type(source_receipt_document)
upload_type = cls._attachment_document_type(upload_ocr_document) upload_type = cls._attachment_document_type(upload_ocr_document)
if source_type in {"", "other"} and upload_type not in {"", "other"}:
return upload_ocr_document
if source_type not in {"", "other"} and upload_type in {"", "other"}: if source_type not in {"", "other"} and upload_type in {"", "other"}:
return source_receipt_document return source_receipt_document
if (
cls._attachment_has_image_preview(source_receipt_document)
and not cls._attachment_has_image_preview(upload_ocr_document)
and source_score >= upload_score
):
return source_receipt_document
if ( if (
source_type == upload_type source_type == upload_type
and cls._attachment_document_field_count(source_receipt_document) and cls._attachment_document_field_count(source_receipt_document)
@@ -438,6 +464,15 @@ class ExpenseClaimAttachmentOperationsMixin:
return 0 return 0
return len(list(getattr(document, "document_fields", []) or [])) return len(list(getattr(document, "document_fields", []) or []))
@staticmethod
def _attachment_has_image_preview(document: Any | None) -> bool:
if document is None:
return False
return str(getattr(document, "preview_kind", "") or "").strip() == "image" and bool(
str(getattr(document, "preview_data_url", "") or "").strip()
or str(getattr(document, "preview_source_path", "") or "").strip()
)
def get_claim_item_attachment_meta( def get_claim_item_attachment_meta(
self, self,
*, *,
@@ -453,7 +488,7 @@ class ExpenseClaimAttachmentOperationsMixin:
if claim is None: if claim is None:
return None return None
return self._build_attachment_payload(item) return self._build_attachment_payload(item, current_user=current_user)
def get_claim_item_attachment_content( def get_claim_item_attachment_content(
self, self,
@@ -487,7 +522,7 @@ class ExpenseClaimAttachmentOperationsMixin:
if claim is None: if claim is None:
return None return None
return self._resolve_item_attachment_preview_content(item) return self._resolve_item_attachment_preview_content(item, current_user=current_user)
def delete_claim_item_attachment( def delete_claim_item_attachment(
self, self,
@@ -740,9 +775,20 @@ class ExpenseClaimAttachmentOperationsMixin:
self._attachment_storage.write_meta(file_path, metadata) self._attachment_storage.write_meta(file_path, metadata)
return metadata return metadata
def _resolve_item_attachment_preview_content(self, item: ExpenseClaimItem) -> tuple[Path, str, str]: def _resolve_item_attachment_preview_content(
self,
item: ExpenseClaimItem,
*,
current_user: CurrentUserContext | None = None,
) -> tuple[Path, str, str]:
file_path, media_type, filename = self._resolve_item_attachment_content(item) file_path, media_type, filename = self._resolve_item_attachment_content(item)
metadata = self._attachment_storage.read_meta(file_path) metadata = self._attachment_storage.read_meta(file_path)
metadata = self._repair_attachment_metadata_from_source_receipt_if_needed(
file_path=file_path,
metadata=metadata,
item=item,
current_user=current_user,
)
metadata = self._repair_pdf_text_layer_metadata_if_needed( metadata = self._repair_pdf_text_layer_metadata_if_needed(
file_path=file_path, file_path=file_path,
metadata=metadata, metadata=metadata,

View File

@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import mimetypes import mimetypes
import shutil
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from urllib.parse import quote from urllib.parse import quote
@@ -43,6 +44,25 @@ class ExpenseClaimAttachmentPresentation:
"preview_rendered_with": DocumentPreviewAssets.renderer_id_for_source(media_type), "preview_rendered_with": DocumentPreviewAssets.renderer_id_for_source(media_type),
} }
preview_source_path = getattr(ocr_document, "preview_source_path", None)
if preview_source_kind == "image" and preview_source_path:
preview_asset = self._copy_preview_asset_from_source(
attachment_dir=file_path.parent,
original_filename=filename,
preview_source_path=Path(preview_source_path),
preview_media_type=str(getattr(ocr_document, "preview_media_type", "") or ""),
)
if preview_asset is not None:
preview_path, preview_media_type, preview_file_name = preview_asset
return {
"previewable": True,
"preview_kind": "image",
"preview_storage_key": self.storage.to_storage_key(preview_path),
"preview_media_type": preview_media_type,
"preview_file_name": preview_file_name,
"preview_rendered_with": DocumentPreviewAssets.renderer_id_for_source(media_type),
}
if preview_kind: if preview_kind:
return { return {
"previewable": True, "previewable": True,
@@ -88,6 +108,28 @@ class ExpenseClaimAttachmentPresentation:
preview_data_url=preview_data_url, preview_data_url=preview_data_url,
) )
def _copy_preview_asset_from_source(
self,
*,
attachment_dir: Path,
original_filename: str,
preview_source_path: Path,
preview_media_type: str,
) -> tuple[Path, str, str] | None:
if not preview_source_path.exists() or not preview_source_path.is_file():
return None
suffix = preview_source_path.suffix or DocumentPreviewAssets.PDF_PREVIEW_SUFFIX
preview_name = f"{Path(original_filename).stem}.preview{suffix}"
preview_path = attachment_dir / preview_name
shutil.copyfile(preview_source_path, preview_path)
resolved_media_type = (
preview_media_type
or mimetypes.guess_type(preview_source_path.name)[0]
or DocumentPreviewAssets.PDF_PREVIEW_MEDIA_TYPE
)
return preview_path, resolved_media_type, preview_name
@staticmethod @staticmethod
def build_preview_client_path(claim_id: str, item_id: str) -> str: def build_preview_client_path(claim_id: str, item_id: str) -> str:
return ( return (

View File

@@ -108,6 +108,13 @@ _index_lock = threading.RLock()
_index_cache: dict[Path, tuple[tuple[int, int], list[dict[str, Any]]]] = {} _index_cache: dict[Path, tuple[tuple[int, int], list[dict[str, Any]]]] = {}
def clear_local_knowledge_index_cache() -> int:
with _index_lock:
cleared_count = len(_index_cache)
_index_cache.clear()
return cleared_count
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class LocalKnowledgeSearchResult: class LocalKnowledgeSearchResult:
hits: list[dict[str, Any]] hits: list[dict[str, Any]]

View File

@@ -148,13 +148,23 @@ class OcrService:
for item in pdf_inputs: for item in pdf_inputs:
cache_keys_by_source.setdefault(item.source_key, cache_key) cache_keys_by_source.setdefault(item.source_key, cache_key)
except RuntimeError as exc: except RuntimeError as exc:
documents.append( fallback_document = self._build_pdf_text_layer_fallback_document(
OcrRecognizeDocumentRead( filename=normalized_name,
filename=normalized_name, media_type=resolved_media_type,
media_type=resolved_media_type, text_layer=text_layer,
warnings=[str(exc)], render_warning=str(exc),
)
) )
if fallback_document is not None:
documents.append(fallback_document)
self._write_cached_document(cache_key, fallback_document)
else:
documents.append(
OcrRecognizeDocumentRead(
filename=normalized_name,
media_type=resolved_media_type,
warnings=[str(exc)],
)
)
continue continue
source_key = uuid4().hex source_key = uuid4().hex
@@ -328,6 +338,13 @@ class OcrService:
while len(cls._result_cache) > OCR_RESULT_CACHE_LIMIT: while len(cls._result_cache) > OCR_RESULT_CACHE_LIMIT:
cls._result_cache.popitem(last=False) cls._result_cache.popitem(last=False)
@classmethod
def clear_result_cache(cls) -> int:
with cls._cache_lock:
cleared_count = len(cls._result_cache)
cls._result_cache.clear()
return cleared_count
@classmethod @classmethod
def _resolve_worker_semaphore(cls, limit: int) -> threading.Semaphore: def _resolve_worker_semaphore(cls, limit: int) -> threading.Semaphore:
normalized_limit = max(1, int(limit or 1)) normalized_limit = max(1, int(limit or 1))
@@ -425,6 +442,36 @@ class OcrService:
) )
return descriptors return descriptors
def _build_pdf_text_layer_fallback_document(
self,
*,
filename: str,
media_type: str,
text_layer: str,
render_warning: str,
) -> OcrRecognizeDocumentRead | None:
normalized_text = self._normalize_extracted_text(text_layer)
if self._meaningful_char_count(normalized_text) < 8:
return None
aggregated = AggregatedOcrDocument(
filename=filename,
media_type=media_type,
source_key=uuid4().hex,
page_count=1,
warnings=[
str(render_warning or "").strip() or "PDF 转图片失败。",
"PDF 转图片失败,已使用 PDF 文本层继续抽取识别信息。",
],
lines=[
OcrRecognizeLineRead(text=line, page_index=0)
for line in normalized_text.splitlines()
if line.strip()
],
)
aggregated.text_layer_fragments.append(normalized_text)
return self._finalize_document(aggregated)
def _extract_pdf_text_layer(self, pdf_path: Path) -> str: def _extract_pdf_text_layer(self, pdf_path: Path) -> str:
try: try:
completed = subprocess.run( completed = subprocess.run(

View File

@@ -889,6 +889,8 @@ class ReceiptFolderTrainTicketMixin:
"无效", "无效",
"二维码", "二维码",
"座席", "座席",
"身份",
"身份证号",
"证件", "证件",
) )
): ):
@@ -993,6 +995,11 @@ class ReceiptFolderService(ReceiptFolderStorageMixin, ReceiptFolderItemMixin, Re
current_user=current_user, current_user=current_user,
) )
if duplicate_receipt is not None: if duplicate_receipt is not None:
duplicate_receipt = self._refresh_duplicate_receipt_from_document_if_stronger(
receipt=duplicate_receipt,
document=document,
current_user=current_user,
)
warning = "已上传过同样的单据,请不要重复上传。" warning = "已上传过同样的单据,请不要重复上传。"
existing_warnings = [str(item) for item in list(document.warnings or []) if str(item).strip()] existing_warnings = [str(item) for item in list(document.warnings or []) if str(item).strip()]
enriched.append( enriched.append(
@@ -1061,6 +1068,7 @@ class ReceiptFolderService(ReceiptFolderStorageMixin, ReceiptFolderItemMixin, Re
if str(value).strip() if str(value).strip()
], ],
"document_fields": self._build_ocr_document_fields_from_meta(meta), "document_fields": self._build_ocr_document_fields_from_meta(meta),
"preview_kind": str(meta.get("preview_kind") or document.preview_kind or ""),
} }
) )
@@ -1073,6 +1081,62 @@ class ReceiptFolderService(ReceiptFolderStorageMixin, ReceiptFolderItemMixin, Re
update["warnings"] = list(dict.fromkeys(warnings)) update["warnings"] = list(dict.fromkeys(warnings))
return document.model_copy(update=update) return document.model_copy(update=update)
def _refresh_duplicate_receipt_from_document_if_stronger(
self,
*,
receipt: ReceiptFolderItemRead,
document: OcrRecognizeDocumentRead,
current_user: CurrentUserContext,
) -> ReceiptFolderItemRead:
try:
meta = self._read_receipt_meta(receipt.id, current_user)
except FileNotFoundError:
return receipt
incoming_meta = self._build_document_meta(document)
if not self._is_incoming_document_meta_stronger(meta, incoming_meta):
return receipt
for key in (
"engine",
"model",
"ocr_text",
"summary",
"ocr_avg_score",
"ocr_line_count",
"page_count",
"document_type",
"document_type_label",
"scene_code",
"scene_label",
"ocr_classification_source",
"ocr_classification_confidence",
"ocr_classification_evidence",
"document_fields",
"ocr_warnings",
):
meta[key] = incoming_meta[key]
meta["updated_at"] = datetime.now(UTC).isoformat()
self._write_meta(self._receipt_dir(self._owner_key(current_user), receipt.id), meta)
return self._build_item(meta)
@staticmethod
def _is_incoming_document_meta_stronger(existing_meta: dict[str, Any], incoming_meta: dict[str, Any]) -> bool:
existing_type = str(existing_meta.get("document_type") or "other").strip() or "other"
incoming_type = str(incoming_meta.get("document_type") or "other").strip() or "other"
existing_fields = [field for field in list(existing_meta.get("document_fields") or []) if isinstance(field, dict)]
incoming_fields = [field for field in list(incoming_meta.get("document_fields") or []) if isinstance(field, dict)]
existing_text = str(existing_meta.get("ocr_text") or "").strip()
incoming_text = str(incoming_meta.get("ocr_text") or "").strip()
if incoming_type != "other" and existing_type == "other":
return True
if incoming_fields and not existing_fields:
return True
if incoming_text and not existing_text:
return True
return False
def _build_ocr_document_fields_from_meta(self, meta: dict[str, Any]) -> list[OcrRecognizeFieldRead]: def _build_ocr_document_fields_from_meta(self, meta: dict[str, Any]) -> list[OcrRecognizeFieldRead]:
return [ return [
OcrRecognizeFieldRead( OcrRecognizeFieldRead(

View File

@@ -29,6 +29,12 @@ DEFAULT_RUNTIME_CHAT_FAILURE_COOLDOWN_SECONDS = 90
_slot_failure_until: dict[str, float] = {} _slot_failure_until: dict[str, float] = {}
def clear_runtime_chat_failure_cache() -> int:
cleared_count = len(_slot_failure_until)
_slot_failure_until.clear()
return cleared_count
@dataclass(slots=True) @dataclass(slots=True)
class RuntimeChatCallTrace: class RuntimeChatCallTrace:
slot: str slot: str

View File

@@ -0,0 +1,41 @@
from __future__ import annotations
from app.core.config import clear_runtime_settings_cache
from app.schemas.settings import SettingsCacheClearItemRead, SettingsCacheClearRead
from app.services.application_location_semantics import clear_application_location_semantic_caches
from app.services.knowledge_rag_local import clear_local_knowledge_index_cache
from app.services.ocr import OcrService
from app.services.runtime_chat import clear_runtime_chat_failure_cache
class SystemCacheService:
def clear_all(self) -> SettingsCacheClearRead:
items = [
SettingsCacheClearItemRead(
cacheKey="ocr_result_cache",
label="OCR 识别结果缓存",
clearedCount=OcrService.clear_result_cache(),
),
SettingsCacheClearItemRead(
cacheKey="runtime_settings_cache",
label="运行时配置缓存",
clearedCount=clear_runtime_settings_cache(),
),
SettingsCacheClearItemRead(
cacheKey="runtime_chat_failure_cache",
label="模型调用失败冷却缓存",
clearedCount=clear_runtime_chat_failure_cache(),
),
SettingsCacheClearItemRead(
cacheKey="knowledge_local_index_cache",
label="知识库本地索引缓存",
clearedCount=clear_local_knowledge_index_cache(),
),
SettingsCacheClearItemRead(
cacheKey="application_location_semantic_cache",
label="地点语义分析缓存",
clearedCount=clear_application_location_semantic_caches(),
),
]
total_cleared = sum(item.clearedCount for item in items)
return SettingsCacheClearRead(totalCleared=total_cleared, items=items)

View File

@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import base64
from collections.abc import Generator from collections.abc import Generator
from datetime import UTC, date, datetime from datetime import UTC, date, datetime
from decimal import Decimal from decimal import Decimal
@@ -16,6 +17,7 @@ from app.models.employee import Employee
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
from app.schemas.ocr import OcrRecognizeBatchRead, OcrRecognizeDocumentRead, OcrRecognizeFieldRead from app.schemas.ocr import OcrRecognizeBatchRead, OcrRecognizeDocumentRead, OcrRecognizeFieldRead
from app.services.attachment_association_jobs import clear_attachment_association_jobs_for_tests from app.services.attachment_association_jobs import clear_attachment_association_jobs_for_tests
from app.services.expense_claims import ExpenseClaimService
from app.services.expense_claim_attachment_storage import ExpenseClaimAttachmentStorage from app.services.expense_claim_attachment_storage import ExpenseClaimAttachmentStorage
from app.services.ocr import OcrService from app.services.ocr import OcrService
from app.services.receipt_folder import ReceiptFolderService from app.services.receipt_folder import ReceiptFolderService
@@ -149,6 +151,13 @@ def fake_ocr_recognize(
) )
def fake_ocr_recognize_without_preview(
self,
files: list[tuple[str, bytes, str | None]],
) -> OcrRecognizeBatchRead:
return fake_ocr_recognize(self, files)
def test_attachment_association_job_links_receipts_after_conversation_exit(monkeypatch, tmp_path) -> None: def test_attachment_association_job_links_receipts_after_conversation_exit(monkeypatch, tmp_path) -> None:
monkeypatch.setenv("STORAGE_ROOT_DIR", str(tmp_path / "storage")) monkeypatch.setenv("STORAGE_ROOT_DIR", str(tmp_path / "storage"))
get_settings.cache_clear() get_settings.cache_clear()
@@ -233,6 +242,233 @@ def test_attachment_association_job_links_receipts_after_conversation_exit(monke
get_settings.cache_clear() get_settings.cache_clear()
def test_attachment_association_keeps_receipt_folder_preview_and_fields_after_cache_clear(
monkeypatch,
tmp_path,
) -> None:
preview_bytes = b"receipt-folder-preview-png"
preview_data_url = f"data:image/png;base64,{base64.b64encode(preview_bytes).decode('ascii')}"
monkeypatch.setenv("STORAGE_ROOT_DIR", str(tmp_path / "storage"))
get_settings.cache_clear()
clear_attachment_association_jobs_for_tests()
monkeypatch.setattr(OcrService, "recognize_files", fake_ocr_recognize_without_preview)
monkeypatch.setattr(ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path / "attachments")
try:
client, session_factory = build_client(monkeypatch)
current_user = CurrentUserContext(
username="zhangsan@example.com",
name="张三",
role_codes=["user"],
is_admin=False,
employee_no="E10001",
)
with session_factory() as db:
seed_travel_claim(db)
receipt = ReceiptFolderService().save_receipt(
filename="2月20 武汉-上海.pdf",
content=b"%PDF-1.7 fake-ticket",
media_type="application/pdf",
current_user=current_user,
document=OcrRecognizeDocumentRead(
filename="2月20 武汉-上海.pdf",
media_type="application/pdf",
text="电子发票(铁路电子客票) 武汉站 G458 上海虹桥站 2026年02月20日 07:55开 二等座 票价 354.00",
summary="铁路电子客票,武汉-上海,票价 354 元。",
avg_score=0.96,
line_count=1,
page_count=1,
document_type="train_ticket",
document_type_label="火车/高铁票",
scene_code="travel",
scene_label="差旅票据",
preview_kind="image",
preview_data_url=preview_data_url,
document_fields=[
OcrRecognizeFieldRead(key="date", label="列车出发时间", value="2026-02-20 07:55"),
OcrRecognizeFieldRead(key="route", label="行程", value="武汉-上海"),
OcrRecognizeFieldRead(key="amount", label="金额", value="354元"),
],
),
)
OcrService.clear_result_cache()
headers = {
"x-auth-username": "zhangsan@example.com",
"x-auth-name": "Zhang San",
"x-auth-employee-no": "E10001",
"x-auth-role-codes": "user",
}
response = client.post(
"/api/v1/reimbursements/attachment-association-jobs",
headers=headers,
json={
"receipt_ids": [receipt.id],
"prompt": "请帮我处理已上传的附件。",
"conversation_id": "inline-test",
},
)
assert response.status_code == 202
job_id = response.json()["job_id"]
status_response = client.get(
f"/api/v1/reimbursements/attachment-association-jobs/{job_id}",
headers=headers,
)
assert status_response.status_code == 200
assert status_response.json()["status"] == "succeeded"
with session_factory() as db:
claim = db.scalar(
select(ExpenseClaim)
.options(selectinload(ExpenseClaim.items))
.where(ExpenseClaim.id == "claim-bg-association")
)
assert claim is not None
attached_item = next(item for item in claim.items if item.invoice_id)
metadata = ExpenseClaimService(db).get_claim_item_attachment_meta(
claim_id=claim.id,
item_id=attached_item.id,
current_user=current_user,
)
assert metadata is not None
assert metadata["preview_kind"] == "image"
assert metadata["document_info"]["document_type"] == "train_ticket"
assert metadata["document_info"]["document_type_label"] == "火车/高铁票"
assert {
(field["label"], field["value"])
for field in metadata["document_info"]["fields"]
} >= {
("列车出发时间", "2026-02-20 07:55"),
("行程", "武汉-上海"),
("金额", "354元"),
}
preview_path, media_type, filename = ExpenseClaimService(db).get_claim_item_attachment_preview_content(
claim_id=claim.id,
item_id=attached_item.id,
current_user=current_user,
)
assert media_type == "image/png"
assert filename.endswith(".png")
assert preview_path.read_bytes() == preview_bytes
finally:
clear_attachment_association_jobs_for_tests()
get_settings.cache_clear()
def test_attachment_meta_repairs_existing_pdf_fallback_from_source_receipt(
monkeypatch,
tmp_path,
) -> None:
preview_bytes = b"legacy-repaired-preview-png"
preview_data_url = f"data:image/png;base64,{base64.b64encode(preview_bytes).decode('ascii')}"
monkeypatch.setenv("STORAGE_ROOT_DIR", str(tmp_path / "storage"))
get_settings.cache_clear()
monkeypatch.setattr(ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path / "attachments")
try:
current_user = CurrentUserContext(
username="zhangsan@example.com",
name="张三",
role_codes=["user"],
is_admin=False,
employee_no="E10001",
)
client, session_factory = build_client(monkeypatch)
client.close()
with session_factory() as db:
claim = seed_travel_claim(db)
item = claim.items[0]
receipt = ReceiptFolderService().save_receipt(
filename="2月20 武汉-上海.pdf",
content=b"%PDF-1.7 fake-ticket",
media_type="application/pdf",
current_user=current_user,
document=OcrRecognizeDocumentRead(
filename="2月20 武汉-上海.pdf",
media_type="application/pdf",
text="电子发票(铁路电子客票) 武汉站 G458 上海虹桥站 2026年02月20日 07:55开 二等座 票价 354.00",
summary="铁路电子客票,武汉-上海,票价 354 元。",
avg_score=0.96,
line_count=1,
page_count=1,
document_type="train_ticket",
document_type_label="火车/高铁票",
scene_code="travel",
scene_label="差旅票据",
preview_kind="image",
preview_data_url=preview_data_url,
document_fields=[
OcrRecognizeFieldRead(key="date", label="列车出发时间", value="2026-02-20 07:55"),
OcrRecognizeFieldRead(key="route", label="行程", value="武汉-上海"),
OcrRecognizeFieldRead(key="amount", label="金额", value="354元"),
],
),
)
attachment_dir = tmp_path / "attachments" / claim.id / item.id
attachment_dir.mkdir(parents=True)
file_path = attachment_dir / "2月20_武汉-上海.pdf"
file_path.write_bytes(b"%PDF-1.7 persisted-but-bad-meta")
storage = ExpenseClaimAttachmentStorage()
item.invoice_id = storage.to_storage_key(file_path)
storage.write_meta(
file_path,
{
"file_name": file_path.name,
"storage_key": storage.to_storage_key(file_path),
"media_type": "application/pdf",
"size_bytes": file_path.stat().st_size,
"previewable": True,
"preview_kind": "pdf",
"preview_storage_key": storage.to_storage_key(file_path),
"preview_media_type": "application/pdf",
"preview_file_name": file_path.name,
"document_info": {
"document_type": "other",
"document_type_label": "其他单据",
"scene_code": "other",
"scene_label": "其他票据",
"fields": [],
},
"source_receipt_id": receipt.id,
},
)
db.commit()
service = ExpenseClaimService(db)
metadata = service.get_claim_item_attachment_meta(
claim_id=claim.id,
item_id=item.id,
current_user=current_user,
)
assert metadata is not None
assert metadata["preview_kind"] == "image"
assert metadata["document_info"]["document_type"] == "train_ticket"
assert metadata["document_info"]["document_type_label"] == "火车/高铁票"
assert {
(field["label"], field["value"])
for field in metadata["document_info"]["fields"]
} >= {
("列车出发时间", "2026-02-20 07:55"),
("行程", "武汉-上海"),
("金额", "354元"),
}
preview_path, media_type, filename = service.get_claim_item_attachment_preview_content(
claim_id=claim.id,
item_id=item.id,
current_user=current_user,
)
assert media_type == "image/png"
assert filename.endswith(".png")
assert preview_path.read_bytes() == preview_bytes
finally:
get_settings.cache_clear()
def test_attachment_association_job_fails_without_editable_claim(monkeypatch, tmp_path) -> None: def test_attachment_association_job_fails_without_editable_claim(monkeypatch, tmp_path) -> None:
monkeypatch.setenv("STORAGE_ROOT_DIR", str(tmp_path / "storage")) monkeypatch.setenv("STORAGE_ROOT_DIR", str(tmp_path / "storage"))
get_settings.cache_clear() get_settings.cache_clear()

View File

@@ -308,6 +308,7 @@ def test_ocr_service_rejects_pdf_ocr_when_rendered_image_fonts_are_broken(
monkeypatch.setattr(OcrService, "_convert_pdf_to_images", fake_convert_pdf_to_images) monkeypatch.setattr(OcrService, "_convert_pdf_to_images", fake_convert_pdf_to_images)
monkeypatch.setattr(OcrService, "_invoke_worker", fake_invoke_worker) monkeypatch.setattr(OcrService, "_invoke_worker", fake_invoke_worker)
get_settings.cache_clear() get_settings.cache_clear()
OcrService._result_cache.clear()
try: try:
result = OcrService().recognize_files( result = OcrService().recognize_files(
[ [
@@ -315,6 +316,7 @@ def test_ocr_service_rejects_pdf_ocr_when_rendered_image_fonts_are_broken(
] ]
) )
finally: finally:
OcrService._result_cache.clear()
get_settings.cache_clear() get_settings.cache_clear()
failed = result.documents[0] failed = result.documents[0]
@@ -324,6 +326,63 @@ def test_ocr_service_rejects_pdf_ocr_when_rendered_image_fonts_are_broken(
assert failed.warnings == ["PDF 转图片失败:检测到中文字体映射缺失,未生成可 OCR 的图片。"] assert failed.warnings == ["PDF 转图片失败:检测到中文字体映射缺失,未生成可 OCR 的图片。"]
def test_ocr_service_uses_pdf_text_layer_when_rendering_fails(
monkeypatch,
tmp_path: Path,
) -> None:
def fake_convert_pdf_to_images(self, *, pdf_path: Path, output_dir: Path) -> tuple[list[Path], bool]:
raise RuntimeError("PDF 转图片失败Missing language pack for Adobe-GB1")
def fake_invoke_worker(
self,
*,
python_bin: str,
worker_path: str,
input_paths: list[Path],
) -> dict:
raise AssertionError("PDF 转图失败但文本层可用时,不应调用 OCR worker。")
monkeypatch.setenv("STORAGE_ROOT_DIR", str(tmp_path / "storage"))
monkeypatch.setattr(OcrService, "_resolve_python_bin", lambda self: "python")
monkeypatch.setattr(OcrService, "_resolve_worker_path", lambda self: "worker.py")
monkeypatch.setattr(OcrService, "_convert_pdf_to_images", fake_convert_pdf_to_images)
monkeypatch.setattr(OcrService, "_invoke_worker", fake_invoke_worker)
monkeypatch.setattr(
OcrService,
"_extract_pdf_text_layer",
lambda self, pdf_path: (
"G458\n"
"Wuhan Shanghaihongqiao\n"
"2026 02 20 07:55\n"
"票价: 354.00\n"
"12306 95306"
),
)
get_settings.cache_clear()
OcrService._result_cache.clear()
try:
result = OcrService().recognize_files(
[
("2月20_武汉-上海.pdf", b"%PDF-1.7 text-layer-fallback", "application/pdf"),
]
)
finally:
OcrService._result_cache.clear()
get_settings.cache_clear()
recovered = result.documents[0]
assert result.success_count == 1
assert recovered.document_type == "train_ticket"
assert recovered.document_type_label == "火车/高铁票"
assert recovered.preview_kind == ""
assert recovered.preview_data_url == ""
assert any(field.label == "金额" and field.value == "354元" for field in recovered.document_fields)
assert any(field.label == "车次/航班" and field.value == "G458" for field in recovered.document_fields)
assert any(field.label == "行程" and field.value == "武汉-上海" for field in recovered.document_fields)
assert "PDF 转图片失败" in recovered.warnings[0]
assert "已使用 PDF 文本层" in recovered.warnings[1]
def test_ocr_pdf_conversion_tries_next_renderer_when_poppler_font_mapping_fails( def test_ocr_pdf_conversion_tries_next_renderer_when_poppler_font_mapping_fails(
monkeypatch, monkeypatch,
tmp_path: Path, tmp_path: Path,
@@ -339,6 +398,7 @@ def test_ocr_pdf_conversion_tries_next_renderer_when_poppler_font_mapping_fails(
text: bool, text: bool,
timeout: int, timeout: int,
check: bool, check: bool,
env: dict[str, str] | None = None,
) -> subprocess.CompletedProcess[str]: ) -> subprocess.CompletedProcess[str]:
calls.append(Path(command[0]).name) calls.append(Path(command[0]).name)
if Path(command[0]).name == "pdftoppm": if Path(command[0]).name == "pdftoppm":
@@ -437,6 +497,7 @@ def test_ocr_service_invokes_worker_even_when_pdf_text_layer_is_usable(
), ),
) )
get_settings.cache_clear() get_settings.cache_clear()
OcrService._result_cache.clear()
try: try:
result = OcrService().recognize_files( result = OcrService().recognize_files(
[ [
@@ -444,6 +505,7 @@ def test_ocr_service_invokes_worker_even_when_pdf_text_layer_is_usable(
] ]
) )
finally: finally:
OcrService._result_cache.clear()
get_settings.cache_clear() get_settings.cache_clear()
recognized = result.documents[0] recognized = result.documents[0]

View File

@@ -49,5 +49,8 @@ def test_openapi_schema_includes_documented_backend_routes() -> None:
analytics_get = schema["paths"]["/api/v1/analytics/system-dashboard"]["get"] analytics_get = schema["paths"]["/api/v1/analytics/system-dashboard"]["get"]
assert analytics_get["summary"] == "查询系统看板真实指标" assert analytics_get["summary"] == "查询系统看板真实指标"
settings_cache_clear_post = schema["paths"]["/api/v1/settings/cache/clear"]["post"]
assert settings_cache_clear_post["summary"] == "清理系统缓存"
root_get = schema["paths"]["/"]["get"] root_get = schema["paths"]["/"]["get"]
assert root_get["summary"] == "服务根检查" assert root_get["summary"] == "服务根检查"

View File

@@ -4,7 +4,7 @@ import base64
from app.api.deps import CurrentUserContext from app.api.deps import CurrentUserContext
from app.core.config import get_settings from app.core.config import get_settings
from app.schemas.ocr import OcrRecognizeDocumentRead, OcrRecognizeFieldRead from app.schemas.ocr import OcrRecognizeBatchRead, OcrRecognizeDocumentRead, OcrRecognizeFieldRead
from app.services.document_preview import DocumentPreviewAssets from app.services.document_preview import DocumentPreviewAssets
from app.services.receipt_folder import ReceiptFolderService from app.services.receipt_folder import ReceiptFolderService
@@ -121,6 +121,53 @@ def test_receipt_folder_pdf_save_eagerly_renders_image_preview(monkeypatch, tmp_
get_settings.cache_clear() get_settings.cache_clear()
def test_receipt_folder_persist_enriches_pdf_ocr_document_with_image_preview(monkeypatch, tmp_path) -> None:
monkeypatch.setenv("STORAGE_ROOT_DIR", str(tmp_path / "storage"))
get_settings.cache_clear()
try:
current_user = CurrentUserContext(
username="pytest",
name="Py Test",
role_codes=[],
is_admin=False,
)
def fake_render_pdf_first_page(*, pdf_path, preview_path, timeout_seconds):
preview_path.write_bytes(b"rendered-preview")
return preview_path
monkeypatch.setattr(DocumentPreviewAssets, "render_pdf_first_page", fake_render_pdf_first_page)
service = ReceiptFolderService()
result = service.persist_ocr_batch(
files=[("2月23_上海-武汉.pdf", b"%PDF-1.4 fake", "application/pdf")],
result=OcrRecognizeBatchRead(
total_file_count=1,
success_count=1,
documents=[
OcrRecognizeDocumentRead(
filename="2月23_上海-武汉.pdf",
media_type="application/pdf",
text="铁路电子客票 上海虹桥 武汉 G456 354.00",
summary="铁路电子客票,上海虹桥至武汉。",
document_type="train_ticket",
document_type_label="火车/高铁票",
scene_code="travel",
scene_label="差旅票据",
),
],
),
current_user=current_user,
)
document = result.documents[0]
assert document.receipt_id
assert document.receipt_preview_url.endswith(f"/receipt-folder/{document.receipt_id}/preview")
assert document.preview_kind == "image"
finally:
get_settings.cache_clear()
def test_receipt_folder_pdf_preview_regenerates_stale_cached_image(monkeypatch, tmp_path) -> None: def test_receipt_folder_pdf_preview_regenerates_stale_cached_image(monkeypatch, tmp_path) -> None:
monkeypatch.setenv("STORAGE_ROOT_DIR", str(tmp_path / "storage")) monkeypatch.setenv("STORAGE_ROOT_DIR", str(tmp_path / "storage"))
get_settings.cache_clear() get_settings.cache_clear()
@@ -433,6 +480,75 @@ def test_receipt_folder_delete_removes_duplicate_marker(monkeypatch, tmp_path) -
get_settings.cache_clear() get_settings.cache_clear()
def test_receipt_folder_duplicate_uses_newer_ocr_when_existing_meta_is_weaker(monkeypatch, tmp_path) -> None:
monkeypatch.setenv("STORAGE_ROOT_DIR", str(tmp_path / "storage"))
get_settings.cache_clear()
try:
current_user = CurrentUserContext(
username="pytest",
name="Py Test",
role_codes=[],
is_admin=False,
)
service = ReceiptFolderService()
content = b"%PDF-1.7 same train ticket"
stale_receipt = service.save_receipt(
filename="2月20_武汉-上海.pdf",
content=content,
media_type="application/pdf",
current_user=current_user,
document=OcrRecognizeDocumentRead(
filename="2月20_武汉-上海.pdf",
media_type="application/pdf",
document_type="other",
document_type_label="其他单据",
scene_code="other",
scene_label="其他票据",
warnings=["PDF 转图片失败Missing language pack for Adobe-GB1"],
),
)
result = service.persist_ocr_batch(
files=[("2月20_武汉-上海.pdf", content, "application/pdf")],
result=OcrRecognizeBatchRead(
total_file_count=1,
success_count=1,
documents=[
OcrRecognizeDocumentRead(
filename="2月20_武汉-上海.pdf",
media_type="application/pdf",
text="G458 Wuhan Shanghaihongqiao 2026 02 20 07:55 票价: 354.00 12306",
summary="Wuhan Shanghaihongqiao G458 354.00",
document_type="train_ticket",
document_type_label="火车/高铁票",
scene_code="travel",
scene_label="差旅票据",
document_fields=[
OcrRecognizeFieldRead(key="amount", label="金额", value="354元"),
OcrRecognizeFieldRead(key="trip_no", label="车次/航班", value="G458"),
OcrRecognizeFieldRead(key="route", label="行程", value="武汉-上海"),
],
),
],
),
current_user=current_user,
)
document = result.documents[0]
assert document.receipt_id == stale_receipt.id
assert document.document_type == "train_ticket"
assert document.document_type_label == "火车/高铁票"
assert any(field.label == "金额" and field.value == "354元" for field in document.document_fields)
assert any("重复上传" in warning for warning in document.warnings)
repaired = service.get_receipt(stale_receipt.id, current_user)
assert repaired.document_type == "train_ticket"
assert repaired.document_type_label == "火车/高铁票"
assert {field.label: field.value for field in repaired.fields}["金额"] == "354元"
finally:
get_settings.cache_clear()
def test_receipt_folder_recovers_train_ticket_detail_from_other_english_ocr(monkeypatch, tmp_path) -> None: def test_receipt_folder_recovers_train_ticket_detail_from_other_english_ocr(monkeypatch, tmp_path) -> None:
monkeypatch.setenv("STORAGE_ROOT_DIR", str(tmp_path / "storage")) monkeypatch.setenv("STORAGE_ROOT_DIR", str(tmp_path / "storage"))
get_settings.cache_clear() get_settings.cache_clear()

View File

@@ -0,0 +1,91 @@
from __future__ import annotations
from collections.abc import Generator
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
from app.api.deps import get_db
from app.db.base import Base
from app.main import create_app
from app.schemas.ocr import OcrRecognizeDocumentRead
from app.services.ocr import OcrService
def build_client() -> TestClient:
engine = create_engine(
"sqlite+pysqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(bind=engine)
session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
app = create_app()
def override_db() -> Generator[Session, None, None]:
db = session_factory()
try:
yield db
finally:
db.close()
app.dependency_overrides[get_db] = override_db
return TestClient(app)
def _seed_ocr_cache() -> None:
OcrService._write_cached_document(
"pytest-cache-key",
OcrRecognizeDocumentRead(
filename="receipt.pdf",
media_type="application/pdf",
text="旧 OCR 缓存",
summary="旧 OCR 缓存",
),
)
def test_clear_settings_cache_endpoint_clears_ocr_result_cache() -> None:
OcrService.clear_result_cache()
_seed_ocr_cache()
assert len(OcrService._result_cache) == 1
client = build_client()
response = client.post(
"/api/v1/settings/cache/clear",
headers={
"x-auth-username": "admin",
"x-auth-name": "Admin",
"x-auth-is-admin": "true",
},
)
assert response.status_code == 200
payload = response.json()
assert payload["totalCleared"] >= 1
assert {
"cacheKey": "ocr_result_cache",
"label": "OCR 识别结果缓存",
"clearedCount": 1,
} in payload["items"]
assert len(OcrService._result_cache) == 0
def test_clear_settings_cache_endpoint_requires_admin() -> None:
OcrService.clear_result_cache()
_seed_ocr_cache()
client = build_client()
response = client.post(
"/api/v1/settings/cache/clear",
headers={
"x-auth-username": "ordinary-user",
"x-auth-name": "Ordinary User",
},
)
assert response.status_code == 403
assert len(OcrService._result_cache) == 1
OcrService.clear_result_cache()

View File

@@ -27,6 +27,16 @@ info() { printf '%b\n' "${GREEN}[INFO]${NC} $*"; }
warn() { printf '%b\n' "${YELLOW}[WARN]${NC} $*"; } warn() { printf '%b\n' "${YELLOW}[WARN]${NC} $*"; }
error() { printf '%b\n' "${RED}[ERROR]${NC} $*"; exit 1; } error() { printf '%b\n' "${RED}[ERROR]${NC} $*"; exit 1; }
is_container() {
[ -f "/.dockerenv" ] && return 0
if [ -r /proc/1/cgroup ] && grep -Eq "(docker|containerd|kubepods)" /proc/1/cgroup 2>/dev/null; then
return 0
fi
return 1
}
if [ ! -f "$ENV_FILE" ]; then if [ ! -f "$ENV_FILE" ]; then
if [ -f "$ENV_EXAMPLE_FILE" ]; then if [ -f "$ENV_EXAMPLE_FILE" ]; then
warn ".env not found. Creating it from .env.example" warn ".env not found. Creating it from .env.example"
@@ -126,6 +136,10 @@ if [ "$APP_DEBUG" = "true" ]; then
DEFAULT_SERVER_RELOAD="true" DEFAULT_SERVER_RELOAD="true"
fi fi
if is_container; then
DEFAULT_SERVER_RELOAD="false"
fi
EFFECTIVE_SERVER_RELOAD="${SERVER_RELOAD:-$DEFAULT_SERVER_RELOAD}" EFFECTIVE_SERVER_RELOAD="${SERVER_RELOAD:-$DEFAULT_SERVER_RELOAD}"
setup_ready() { setup_ready() {

View File

@@ -687,7 +687,243 @@
} }
.rendering-settings-card .switch-group { .rendering-settings-card .switch-group {
margin-bottom: 20px; margin-bottom: 24px;
}
.cache-management-card {
display: grid;
gap: 16px;
}
.cache-management-hero-metrics {
display: grid;
grid-template-columns: repeat(2, minmax(92px, 1fr));
gap: 8px;
}
.cache-management-hero-metrics span {
min-height: 64px;
display: grid;
align-content: center;
gap: 2px;
padding: 10px 12px;
border: 1px solid #e2e8f0;
border-radius: 6px;
background: #ffffff;
}
.cache-management-hero-metrics strong {
color: #0f172a;
font-size: 18px;
font-weight: 850;
line-height: 1.1;
}
.cache-management-hero-metrics small {
color: #64748b;
font-size: 12px;
font-weight: 700;
}
.cache-safety-strip {
min-height: 42px;
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
border: 1px solid #e2e8f0;
border-radius: 6px;
background: #f8fafc;
color: #475569;
font-size: 13px;
font-weight: 700;
line-height: 1.5;
}
.cache-safety-strip i {
flex: 0 0 auto;
font-size: 18px;
}
.cache-scope-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.cache-scope-item {
min-width: 0;
min-height: 148px;
display: grid;
align-content: start;
gap: 8px;
padding: 16px;
border: 1px solid #e2e8f0;
border-radius: 6px;
background: #ffffff;
}
.cache-scope-item i {
width: 32px;
height: 32px;
display: grid;
place-items: center;
border-radius: 6px;
background: #f1f5f9;
color: #475569;
font-size: 17px;
}
.cache-scope-item strong {
color: #0f172a;
font-size: 14px;
font-weight: 800;
line-height: 1.35;
}
.cache-scope-item span {
color: #64748b;
font-size: 12.5px;
font-weight: 600;
line-height: 1.55;
}
.cache-management-panel {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
padding: 16px 18px;
border: 1px solid #e2e8f0;
border-radius: 6px;
background: #ffffff;
}
.cache-management-copy {
min-width: 0;
display: grid;
gap: 4px;
}
.cache-management-copy strong {
color: #0f172a;
font-size: 15px;
font-weight: 800;
line-height: 1.35;
}
.cache-management-copy span {
color: #64748b;
font-size: 12.5px;
font-weight: 650;
line-height: 1.5;
}
.cache-clear-button {
min-height: 36px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 0 16px;
border: 1px solid #cbd5e1;
border-radius: 4px;
background: #ffffff;
color: #334155;
font-size: 13px;
font-weight: 700;
white-space: nowrap;
transition: all 0.2s ease;
cursor: pointer;
}
.cache-clear-button:hover:not(:disabled) {
border-color: var(--theme-primary);
background: var(--theme-primary-soft);
color: var(--theme-primary-active);
}
.cache-clear-button:disabled {
cursor: not-allowed;
opacity: 0.65;
}
.cache-clear-result,
.cache-clear-empty {
padding: 14px 16px;
border: 1px solid #dbe4ee;
border-radius: 6px;
background: #ffffff;
}
.cache-clear-result.is-error {
border-color: #fecaca;
background: #fff7f7;
}
.cache-clear-result-head {
display: flex;
align-items: center;
gap: 8px;
}
.cache-clear-result-head i {
color: #16a34a;
font-size: 18px;
}
.cache-clear-result.is-error .cache-clear-result-head i {
color: #dc2626;
}
.cache-clear-result-head strong {
color: #0f172a;
font-size: 13.5px;
font-weight: 800;
line-height: 1.4;
}
.cache-clear-result ul {
display: grid;
gap: 8px;
margin: 12px 0 0;
padding: 0;
list-style: none;
}
.cache-clear-result li {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding-top: 8px;
border-top: 1px solid #eef2f7;
color: #475569;
font-size: 12.5px;
font-weight: 650;
}
.cache-clear-result li strong {
color: #0f172a;
font-weight: 800;
white-space: nowrap;
}
.cache-clear-empty {
display: inline-flex;
align-items: center;
gap: 8px;
justify-self: start;
color: #64748b;
font-size: 12.5px;
font-weight: 650;
}
.cache-clear-empty i {
color: var(--theme-primary);
font-size: 15px;
} }
.log-policy-card .card-head { .log-policy-card .card-head {
@@ -751,6 +987,15 @@
.save-button { .save-button {
justify-content: center; justify-content: center;
} }
.cache-management-panel {
grid-template-columns: 1fr;
align-items: stretch;
}
.cache-scope-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
} }
@media (max-width: 960px) { @media (max-width: 960px) {
@@ -801,6 +1046,21 @@
justify-items: start; justify-items: start;
padding: 20px; padding: 20px;
} }
.cache-management-hero-metrics {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.cache-management-panel {
flex-direction: column;
align-items: stretch;
}
.cache-clear-button {
width: 100%;
}
} }
@media (max-width: 640px) { @media (max-width: 640px) {
@@ -850,4 +1110,16 @@
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.cache-scope-item,
.cache-management-panel,
.cache-clear-result,
.cache-clear-empty {
border-radius: 6px;
}
.cache-scope-grid,
.cache-management-hero-metrics {
grid-template-columns: 1fr;
}
} }

View File

@@ -250,10 +250,6 @@
</template> </template>
<template #actions> <template #actions>
<button class="minor-action" type="button" :disabled="!selectedRunDetail?.run_id" @click="openTraceCenter">
<i class="mdi mdi-timeline-text-outline"></i>
<span>查看 Trace</span>
</button>
<button class="minor-action" type="button" :disabled="detailLoading" @click="reloadSelectedDetail"> <button class="minor-action" type="button" :disabled="detailLoading" @click="reloadSelectedDetail">
<i class="mdi mdi-refresh"></i> <i class="mdi mdi-refresh"></i>
<span>{{ detailLoading ? '刷新中...' : '刷新详情' }}</span> <span>{{ detailLoading ? '刷新中...' : '刷新详情' }}</span>
@@ -266,7 +262,6 @@
<script setup> <script setup>
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import AuditPickerFilter from './AuditPickerFilter.vue' import AuditPickerFilter from './AuditPickerFilter.vue'
import DigitalEmployeeRunProducts from './DigitalEmployeeRunProducts.vue' import DigitalEmployeeRunProducts from './DigitalEmployeeRunProducts.vue'
@@ -303,7 +298,6 @@ const props = defineProps({
const emit = defineEmits(['summary-change', 'detail-open-change', 'detail-topbar-change']) const emit = defineEmits(['summary-change', 'detail-open-change', 'detail-topbar-change'])
const { toast } = useToast() const { toast } = useToast()
const router = useRouter()
const runs = ref([]) const runs = ref([])
const loading = ref(false) const loading = ref(false)
const errorMessage = ref('') const errorMessage = ref('')
@@ -586,14 +580,6 @@ function closeWorkRecordDetail() {
detailError.value = '' detailError.value = ''
} }
function openTraceCenter() {
const runId = String(selectedRunDetail.value?.run_id || selectedRunId.value || '').trim()
if (!runId) {
return
}
router.push({ name: 'app-settings', query: { section: 'agentTraces', run_id: runId } })
}
watch( watch(
() => props.focusRunId, () => props.focusRunId,
(runId) => { (runId) => {

View File

@@ -36,6 +36,10 @@
class="workbench-ai-file-preview-frame" class="workbench-ai-file-preview-frame"
title="附件 PDF 预览" title="附件 PDF 预览"
></iframe> ></iframe>
<div v-else-if="preview.sourceKind === 'loading'" class="workbench-ai-file-preview-state">
<i class="mdi mdi-loading"></i>
<span>正在加载统一预览</span>
</div>
<div v-else class="workbench-ai-file-preview-state"> <div v-else class="workbench-ai-file-preview-state">
<i class="mdi mdi-file-eye-outline"></i> <i class="mdi mdi-file-eye-outline"></i>
<span>当前附件暂不支持直接预览</span> <span>当前附件暂不支持直接预览</span>

View File

@@ -3,7 +3,7 @@ import { useRoute, useRouter } from 'vue-router'
import { useSystemState } from './useSystemState.js' import { useSystemState } from './useSystemState.js'
import { useThemeSkin } from './useThemeSkin.js' import { useThemeSkin } from './useThemeSkin.js'
import { fetchSettings, saveSettings } from '../services/settings.js' import { clearSystemCaches, fetchSettings, saveSettings } from '../services/settings.js'
import { useToast } from './useToast.js' import { useToast } from './useToast.js'
import { import {
isHermesEmployeeSettingsReady isHermesEmployeeSettingsReady
@@ -56,6 +56,10 @@ export function useSettings() {
const sessionRetentionPickerOpen = ref(false) const sessionRetentionPickerOpen = ref(false)
const sessionRetentionPickerRef = ref(null) const sessionRetentionPickerRef = ref(null)
const logoInputRef = ref(null) const logoInputRef = ref(null)
const cacheClearing = ref(false)
const cacheClearItems = ref([])
const cacheClearMessage = ref('')
const cacheClearFailed = ref(false)
const sections = SECTION_DEFINITIONS const sections = SECTION_DEFINITIONS
const logLevels = LOG_LEVELS const logLevels = LOG_LEVELS
@@ -433,6 +437,46 @@ export function useSettings() {
}) })
} }
function normalizeCacheClearErrorMessage(error) {
const message = String(error?.message || '').trim()
if (!message || /^not found$/i.test(message)) {
return '缓存清理接口暂不可用,请确认后端服务已加载最新路由后重试。'
}
return message
}
async function clearAllCaches() {
if (cacheClearing.value) {
return
}
cacheClearing.value = true
cacheClearMessage.value = ''
cacheClearItems.value = []
cacheClearFailed.value = false
try {
const payload = await clearSystemCaches()
const items = Array.isArray(payload?.items) ? payload.items : []
const totalCleared = Number(payload?.totalCleared || 0)
cacheClearItems.value = items
cacheClearMessage.value = totalCleared > 0
? `已清理 ${totalCleared} 条缓存。`
: '当前没有可清理的缓存。'
cacheClearFailed.value = false
toast(cacheClearMessage.value)
} catch (error) {
const message = normalizeCacheClearErrorMessage(error)
cacheClearFailed.value = true
cacheClearMessage.value = message
toast(message)
} finally {
cacheClearing.value = false
}
}
async function saveMailSection() { async function saveMailSection() {
const mailForm = pageState.value.mailForm const mailForm = pageState.value.mailForm
@@ -494,6 +538,10 @@ export function useSettings() {
return return
} }
if (activeSection.value === 'cacheManagement') {
return
}
if (activeSection.value === 'rendering') { if (activeSection.value === 'rendering') {
await saveRenderingSection() await saveRenderingSection()
return return
@@ -557,6 +605,11 @@ export function useSettings() {
activeThemeSkinId, activeThemeSkinId,
archiveCycleOptions, archiveCycleOptions,
activateSection, activateSection,
cacheClearFailed,
cacheClearItems,
cacheClearMessage,
cacheClearing,
clearAllCaches,
clearRenderSecretMask, clearRenderSecretMask,
completedSectionCount, completedSectionCount,
logLevels, logLevels,

View File

@@ -1,5 +1,13 @@
import { computed, onBeforeUnmount, ref, watch } from 'vue' import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { buildSelectedFileCards } from './workbenchAiComposerModel.js' import { buildSelectedFileCards } from './workbenchAiComposerModel.js'
import { fetchReceiptFolderAsset } from '../../services/receiptFolder.js'
import {
inferPreviewKindFromBlob,
inferPreviewKindFromFile,
isInlinePreviewUrl,
isTemporaryPreviewUrl,
resolveDocumentPreviewAsset
} from '../../utils/documentPreviewAssets.js'
function normalizePreviewText(value) { function normalizePreviewText(value) {
return String(value ?? '').replace(/\s+/g, ' ').trim() return String(value ?? '').replace(/\s+/g, ' ').trim()
@@ -40,10 +48,6 @@ function normalizePreviewField(field = {}) {
} }
} }
function resolveDocumentPreviewUrl(document = null) {
return normalizePreviewText(document?.preview_data_url || document?.previewDataUrl)
}
function resolveSourceKind(sourceUrl, rawFile = {}) { function resolveSourceKind(sourceUrl, rawFile = {}) {
const type = normalizePreviewText(rawFile?.type).toLowerCase() const type = normalizePreviewText(rawFile?.type).toLowerCase()
const name = normalizePreviewText(rawFile?.name).toLowerCase() const name = normalizePreviewText(rawFile?.name).toLowerCase()
@@ -72,8 +76,18 @@ export function useWorkbenchAiFilePreview({
scrollInlineConversationToBottom, scrollInlineConversationToBottom,
selectedFiles selectedFiles
}) { }) {
const filePreviewState = ref({ open: false, key: '', objectUrl: '' }) const filePreviewState = ref({
const selectedFileCards = computed(() => buildSelectedFileCards(selectedFiles.value).map((card, index) => ({ open: false,
key: '',
objectUrl: '',
objectKind: '',
objectSource: '',
loading: false
})
const selectedFileCards = computed(() => buildSelectedFileCards(
selectedFiles.value,
(file) => attachmentFlow.resolveAiModeReceiptRecognitionState(file)
).map((card, index) => ({
...card, ...card,
ocrState: attachmentFlow.resolveAiModeReceiptRecognitionState(selectedFiles.value[index]) ocrState: attachmentFlow.resolveAiModeReceiptRecognitionState(selectedFiles.value[index])
}))) })))
@@ -97,22 +111,94 @@ export function useWorkbenchAiFilePreview({
} }
} }
function resolveTargetDocument(target) {
if (!target?.rawFile) {
return null
}
const recognitionState = attachmentFlow.resolveAiModeReceiptRecognitionState(target.rawFile) || target.card.ocrState || null
return recognitionState?.document || null
}
function resolveRemotePreviewAsset(target) {
const asset = resolveDocumentPreviewAsset(resolveTargetDocument(target))
if (!asset?.url || isInlinePreviewUrl(asset.url) || isTemporaryPreviewUrl(asset.url)) {
return null
}
return asset
}
async function loadRemotePreviewAsset(fileKey) {
const target = findSelectedFile(fileKey)
const asset = resolveRemotePreviewAsset(target)
if (!target?.rawFile || !asset?.url) {
return
}
try {
const blob = await fetchReceiptFolderAsset(asset.url)
const objectUrl = createObjectUrl(blob)
const objectKind = inferPreviewKindFromBlob(blob) || asset.kind
if (!objectUrl || !['image', 'pdf'].includes(objectKind)) {
return
}
if (!filePreviewState.value.open || filePreviewState.value.key !== fileKey) {
URL.revokeObjectURL(objectUrl)
return
}
clearFilePreviewObjectUrl()
filePreviewState.value = {
...filePreviewState.value,
objectUrl,
objectKind,
objectSource: asset.source,
loading: false
}
} catch (error) {
console.warn('AI mode remote attachment preview unavailable:', error)
if (!filePreviewState.value.open || filePreviewState.value.key !== fileKey) {
return
}
clearFilePreviewObjectUrl()
filePreviewState.value = {
...filePreviewState.value,
objectUrl: createObjectUrl(target.rawFile),
objectKind: inferPreviewKindFromFile(target.rawFile),
objectSource: 'file',
loading: false
}
}
}
function openAiModeFilePreview(fileKey) { function openAiModeFilePreview(fileKey) {
const target = findSelectedFile(fileKey) const target = findSelectedFile(fileKey)
if (!target?.rawFile) { if (!target?.rawFile) {
return return
} }
clearFilePreviewObjectUrl() clearFilePreviewObjectUrl()
const remoteAsset = resolveRemotePreviewAsset(target)
filePreviewState.value = { filePreviewState.value = {
open: true, open: true,
key: fileKey, key: fileKey,
objectUrl: createObjectUrl(target.rawFile) objectUrl: remoteAsset ? '' : createObjectUrl(target.rawFile),
objectKind: remoteAsset ? '' : inferPreviewKindFromFile(target.rawFile),
objectSource: remoteAsset ? remoteAsset.source : 'file',
loading: Boolean(remoteAsset)
}
if (remoteAsset) {
void loadRemotePreviewAsset(fileKey)
} }
} }
function closeAiModeFilePreview() { function closeAiModeFilePreview() {
clearFilePreviewObjectUrl() clearFilePreviewObjectUrl()
filePreviewState.value = { open: false, key: '', objectUrl: '' } filePreviewState.value = {
open: false,
key: '',
objectUrl: '',
objectKind: '',
objectSource: '',
loading: false
}
} }
const activeAiModeFilePreview = computed(() => { const activeAiModeFilePreview = computed(() => {
@@ -128,9 +214,16 @@ export function useWorkbenchAiFilePreview({
const document = recognitionState?.document || null const document = recognitionState?.document || null
const documentFields = Array.isArray(document?.document_fields) ? document.document_fields : document?.fields || [] const documentFields = Array.isArray(document?.document_fields) ? document.document_fields : document?.fields || []
const ocrFields = documentFields.map((field) => normalizePreviewField(field)).filter(Boolean) const ocrFields = documentFields.map((field) => normalizePreviewField(field)).filter(Boolean)
const documentPreviewUrl = resolveDocumentPreviewUrl(document) const documentPreviewAsset = resolveDocumentPreviewAsset(document)
const sourceUrl = documentPreviewUrl || filePreviewState.value.objectUrl const inlinePreviewAvailable = documentPreviewAsset?.url && isInlinePreviewUrl(documentPreviewAsset.url)
const sourceKind = documentPreviewUrl ? 'image' : resolveSourceKind(sourceUrl, rawFile) const sourceUrl = inlinePreviewAvailable
? documentPreviewAsset.url
: filePreviewState.value.objectUrl
const sourceKind = inlinePreviewAvailable
? documentPreviewAsset.kind
: filePreviewState.value.loading
? 'loading'
: filePreviewState.value.objectKind || resolveSourceKind(sourceUrl, rawFile)
const documentTypeLabel = normalizePreviewText( const documentTypeLabel = normalizePreviewText(
document?.document_type_label || document?.document_type_label ||
document?.scene_label || document?.scene_label ||

View File

@@ -1,4 +1,5 @@
import { buildFileIdentity } from '../../views/scripts/travelReimbursementAttachmentModel.js' import { buildFileIdentity } from '../../views/scripts/travelReimbursementAttachmentModel.js'
import { resolveDocumentPreviewAsset } from '../../utils/documentPreviewAssets.js'
export const AI_COMPOSER_FILE_TYPE_META = { export const AI_COMPOSER_FILE_TYPE_META = {
pdf: { label: 'PDF', icon: 'mdi mdi-file-pdf-box', tone: 'pdf' }, pdf: { label: 'PDF', icon: 'mdi mdi-file-pdf-box', tone: 'pdf' },
@@ -44,7 +45,14 @@ export function resolveAiComposerFileName(file) {
return String(file?.name || '未命名附件').trim() || '未命名附件' return String(file?.name || '未命名附件').trim() || '未命名附件'
} }
export function resolveAiComposerFileType(file) { export function resolveAiComposerFileType(file, previewAsset = null) {
if (previewAsset?.kind === 'image') {
return AI_COMPOSER_FILE_TYPE_META.image
}
if (previewAsset?.kind === 'pdf') {
return AI_COMPOSER_FILE_TYPE_META.pdf
}
const fileName = resolveAiComposerFileName(file).toLowerCase() const fileName = resolveAiComposerFileName(file).toLowerCase()
const mimeType = String(file?.type || '').toLowerCase() const mimeType = String(file?.type || '').toLowerCase()
const extension = fileName.includes('.') ? fileName.split('.').pop() : '' const extension = fileName.includes('.') ? fileName.split('.').pop() : ''
@@ -66,12 +74,19 @@ export function resolveAiComposerFileType(file) {
return AI_COMPOSER_FILE_TYPE_META.file return AI_COMPOSER_FILE_TYPE_META.file
} }
export function buildSelectedFileCards(files = []) { export function buildSelectedFileCards(files = [], resolveRecognitionState = null) {
return files.map((file) => ({ return files.map((file, index) => {
key: buildFileIdentity(file), const recognitionState = typeof resolveRecognitionState === 'function'
name: resolveAiComposerFileName(file), ? resolveRecognitionState(file, index)
...resolveAiComposerFileType(file) : null
})) const previewAsset = resolveDocumentPreviewAsset(recognitionState?.document || null)
return {
key: buildFileIdentity(file),
name: resolveAiComposerFileName(file),
...resolveAiComposerFileType(file, previewAsset),
previewAsset: previewAsset || null
}
})
} }
export function isLikelyAiModeOcrFile(file = {}) { export function isLikelyAiModeOcrFile(file = {}) {

View File

@@ -17,3 +17,9 @@ export function testModelConnectivity(payload) {
body: JSON.stringify(payload) body: JSON.stringify(payload)
}) })
} }
export function clearSystemCaches() {
return apiRequest('/settings/cache/clear', {
method: 'POST'
})
}

View File

@@ -0,0 +1,118 @@
function normalizePreviewText(value) {
return String(value ?? '').replace(/\s+/g, ' ').trim()
}
function normalizePreviewKind(value) {
const normalized = normalizePreviewText(value).toLowerCase()
return ['image', 'pdf', 'file', 'unsupported'].includes(normalized) ? normalized : ''
}
export function inferPreviewKindFromUrl(url) {
const normalized = normalizePreviewText(url).toLowerCase()
if (!normalized) return ''
if (normalized.startsWith('data:image/') || /\.(png|jpe?g|webp|gif|bmp|svg)(?:[?#].*)?$/i.test(normalized)) {
return 'image'
}
if (normalized.startsWith('data:application/pdf') || /\.pdf(?:[?#].*)?$/i.test(normalized)) {
return 'pdf'
}
return ''
}
export function inferPreviewKindFromBlob(blob) {
const mediaType = normalizePreviewText(blob?.type).toLowerCase()
if (mediaType.startsWith('image/')) return 'image'
if (mediaType === 'application/pdf') return 'pdf'
return ''
}
export function inferPreviewKindFromFile(file) {
const mediaType = normalizePreviewText(file?.type).toLowerCase()
const filename = normalizePreviewText(file?.name).toLowerCase()
if (mediaType.startsWith('image/') || /\.(png|jpe?g|webp|gif|bmp|svg|heic)$/i.test(filename)) {
return 'image'
}
if (mediaType.includes('pdf') || /\.pdf$/i.test(filename)) {
return 'pdf'
}
return 'file'
}
export function isInlinePreviewUrl(url) {
return normalizePreviewText(url).toLowerCase().startsWith('data:')
}
export function isTemporaryPreviewUrl(url) {
return normalizePreviewText(url).toLowerCase().startsWith('blob:')
}
export function resolveDocumentPreviewKind(item = {}, fallback = '') {
const explicit = normalizePreviewKind(item?.preview_kind || item?.previewKind)
if (explicit) {
return explicit
}
return inferPreviewKindFromUrl(resolveDocumentPreviewUrl(item)) || normalizePreviewKind(fallback)
}
export function resolveDocumentPreviewUrl(item = {}) {
return normalizePreviewText(
item?.preview_data_url ||
item?.previewDataUrl ||
item?.preview_url ||
item?.previewUrl ||
item?.receipt_preview_url ||
item?.receiptPreviewUrl
)
}
export function resolveDocumentPreviewAsset(item = {}, options = {}) {
const sourceCandidates = [
{
source: 'inline',
url: normalizePreviewText(item?.preview_data_url || item?.previewDataUrl)
},
{
source: 'remote',
url: normalizePreviewText(item?.preview_url || item?.previewUrl)
},
{
source: 'receipt',
url: normalizePreviewText(item?.receipt_preview_url || item?.receiptPreviewUrl)
}
]
const explicitKind = normalizePreviewKind(item?.preview_kind || item?.previewKind)
for (const candidate of sourceCandidates) {
if (!candidate.url) continue
const kind = explicitKind || inferPreviewKindFromUrl(candidate.url)
if (!['image', 'pdf'].includes(kind)) continue
return {
kind,
url: candidate.url,
source: candidate.source
}
}
const fallbackUrl = normalizePreviewText(options.fallbackUrl)
if (fallbackUrl) {
const kind = inferPreviewKindFromUrl(fallbackUrl) || inferPreviewKindFromFile(options.fallbackFile)
if (['image', 'pdf'].includes(kind)) {
return {
kind,
url: fallbackUrl,
source: 'file'
}
}
}
const fallbackKind = inferPreviewKindFromFile(options.fallbackFile)
if (fallbackKind) {
return {
kind: fallbackKind,
url: '',
source: 'file'
}
}
return null
}

View File

@@ -84,11 +84,11 @@ export const SECTION_DEFINITIONS = [
actionLabel: '' actionLabel: ''
}, },
{ {
id: 'agentTraces', id: 'cacheManagement',
label: 'Agent Trace', label: '缓存管理',
title: 'Agent 链路追踪', title: '系统缓存管理',
desc: '对话链路、工具调用与事件重放', desc: 'OCR、模型与索引缓存',
longDesc: '按 Run ID 还原 Orchestrator 到下游 Agent 的语义识别、路由、工具调用、会话写回和最终回复,便于线上排障和审计复盘。', longDesc: '手动清理 OCR 识别结果、模型失败冷却、知识库本地索引和运行时配置等进程内缓存。',
actionLabel: '' actionLabel: ''
}, },
{ {
@@ -482,7 +482,7 @@ export function computeSectionStatus(state) {
normalizeValue(state.logForm.logPath) normalizeValue(state.logForm.logPath)
), ),
systemLogs: true, systemLogs: true,
agentTraces: true, cacheManagement: true,
mail: Boolean( mail: Boolean(
normalizeValue(state.mailForm.smtpHost) && normalizeValue(state.mailForm.smtpHost) &&
Number(state.mailForm.port) > 0 && Number(state.mailForm.port) > 0 &&

View File

@@ -34,10 +34,6 @@
<i class="mdi mdi-refresh"></i> <i class="mdi mdi-refresh"></i>
<span>刷新详情</span> <span>刷新详情</span>
</button> </button>
<button class="refresh-btn" type="button" @click="openAgentTraceCenter">
<i class="mdi mdi-timeline-text-outline"></i>
<span>查看 Trace</span>
</button>
</div> </div>
</article> </article>
@@ -445,14 +441,6 @@ function backToLogs() {
router.push({ name: 'app-settings', query: { section: 'systemLogs' } }) router.push({ name: 'app-settings', query: { section: 'systemLogs' } })
} }
function openAgentTraceCenter() {
const runId = String(hermesRun.value?.run_id || '').trim()
if (!runId) {
return
}
router.push({ name: 'app-settings', query: { section: 'agentTraces', run_id: runId } })
}
watch( watch(
() => [route.params.logKind, route.params.logId], () => [route.params.logKind, route.params.logId],
() => { () => {

View File

@@ -381,6 +381,7 @@ import {
fetchReceiptFolderItems, fetchReceiptFolderItems,
updateReceiptFolderItem updateReceiptFolderItem
} from '../services/receiptFolder.js' } from '../services/receiptFolder.js'
import { inferPreviewKindFromBlob } from '../utils/documentPreviewAssets.js'
import { createReceiptDetailDashboardModel } from './scripts/receiptFolderDetailDashboard.js' import { createReceiptDetailDashboardModel } from './scripts/receiptFolderDetailDashboard.js'
import { createReceiptDetailFieldModel } from './scripts/receiptFolderDetailFields.js' import { createReceiptDetailFieldModel } from './scripts/receiptFolderDetailFields.js'
import { createReceiptFolderListFilterModel } from './scripts/receiptFolderListFilters.js' import { createReceiptFolderListFilterModel } from './scripts/receiptFolderListFilters.js'
@@ -638,6 +639,13 @@ async function loadPreview(detail) {
if (!detail?.preview_url) return if (!detail?.preview_url) return
try { try {
const blob = await fetchReceiptFolderAsset(detail.preview_url) const blob = await fetchReceiptFolderAsset(detail.preview_url)
const resolvedPreviewKind = inferPreviewKindFromBlob(blob)
if (resolvedPreviewKind && selectedReceipt.value?.id === detail.id) {
selectedReceipt.value = {
...selectedReceipt.value,
preview_kind: resolvedPreviewKind
}
}
previewObjectUrl.value = URL.createObjectURL(blob) previewObjectUrl.value = URL.createObjectURL(blob)
} catch { } catch {
previewObjectUrl.value = '' previewObjectUrl.value = ''

View File

@@ -45,7 +45,7 @@
<div <div
class="settings-content" class="settings-content"
:class="{ 'settings-content-fill': ['systemLogs', 'agentTraces'].includes(activeSection) }" :class="{ 'settings-content-fill': ['systemLogs'].includes(activeSection) }"
> >
<template v-if="activeSection === 'profile'"> <template v-if="activeSection === 'profile'">
<section class="settings-card"> <section class="settings-card">
@@ -442,8 +442,95 @@
<LogsView v-else class="settings-logs-view" /> <LogsView v-else class="settings-logs-view" />
</template> </template>
<template v-else-if="activeSection === 'agentTraces'"> <template v-else-if="activeSection === 'cacheManagement'">
<AgentTraceCenterView class="settings-trace-center-view" /> <section class="settings-card cache-management-card">
<div class="card-head">
<div class="card-title-with-icon">
<div class="model-icon-box slate">
<i class="mdi mdi-cached"></i>
</div>
<div>
<h4>应用缓存清理</h4>
<p>清理运行时产生的临时结果释放进程内缓存并让下一次识别检索或模型调用重新构建最新状态</p>
</div>
</div>
<div class="cache-management-hero-metrics" aria-label="缓存清理范围概览">
<span>
<strong>4 </strong>
<small>清理范围</small>
</span>
<span>
<strong>0 </strong>
<small>数据删除</small>
</span>
</div>
</div>
<div>
<div class="cache-safety-strip">
<i class="mdi mdi-shield-check-outline" aria-hidden="true"></i>
<span>不会删除源文件业务单据和系统日志只清理可重新生成的进程内缓存</span>
</div>
</div>
<div class="cache-scope-grid" aria-label="缓存清理范围">
<article class="cache-scope-item">
<i class="mdi mdi-text-recognition" aria-hidden="true"></i>
<strong>OCR 识别结果</strong>
<span>清空票据识别的内存结果下次上传或查看时重新识别</span>
</article>
<article class="cache-scope-item">
<i class="mdi mdi-robot-outline" aria-hidden="true"></i>
<strong>模型失败冷却</strong>
<span>解除模型通道失败后的短期冷却便于恢复后立即重试</span>
</article>
<article class="cache-scope-item">
<i class="mdi mdi-book-search-outline" aria-hidden="true"></i>
<strong>知识库本地索引</strong>
<span>清理本地检索索引缓存下一次检索会重新读取知识库</span>
</article>
<article class="cache-scope-item">
<i class="mdi mdi-tune-variant" aria-hidden="true"></i>
<strong>运行时配置缓存</strong>
<span>刷新进程内配置读取结果避免继续使用旧配置快照</span>
</article>
</div>
<div>
<div class="cache-management-panel">
<div class="cache-management-copy">
<strong>立即执行维护清理</strong>
<span>建议在 OCR 配置模型接入或知识库文件调整后使用</span>
</div>
<button class="cache-clear-button" type="button" :disabled="cacheClearing" @click="clearAllCaches">
<i class="mdi" :class="cacheClearing ? 'mdi-loading mdi-spin' : 'mdi-delete-sweep-outline'"></i>
<span>{{ cacheClearing ? '清理中...' : '一键清理缓存' }}</span>
</button>
</div>
</div>
<div v-if="cacheClearMessage" class="cache-clear-result" :class="{ 'is-error': cacheClearFailed }">
<div class="cache-clear-result-head">
<i
class="mdi"
:class="cacheClearFailed ? 'mdi-alert-circle-outline' : 'mdi-check-circle-outline'"
aria-hidden="true"
></i>
<strong>{{ cacheClearMessage }}</strong>
</div>
<ul v-if="cacheClearItems.length">
<li v-for="item in cacheClearItems" :key="item.cacheKey">
<span>{{ item.label }}</span>
<strong>{{ item.clearedCount }} </strong>
</li>
</ul>
</div>
<div v-else class="cache-clear-empty">
<i class="mdi mdi-database-clock-outline"></i>
<span>等待执行缓存清理</span>
</div>
</section>
</template> </template>
<template v-else-if="activeSection === 'mail'"> <template v-else-if="activeSection === 'mail'">

View File

@@ -1,5 +1,4 @@
import HermesEmployeeSettingsPanel from '../HermesEmployeeSettingsPanel.vue' import HermesEmployeeSettingsPanel from '../HermesEmployeeSettingsPanel.vue'
import AgentTraceCenterView from '../AgentTraceCenterView.vue'
import LlmSettingsPanel from '../LlmSettingsPanel.vue' import LlmSettingsPanel from '../LlmSettingsPanel.vue'
import LogDetailView from '../LogDetailView.vue' import LogDetailView from '../LogDetailView.vue'
import LogsView from '../LogsView.vue' import LogsView from '../LogsView.vue'
@@ -10,7 +9,6 @@ import { useSettings } from '../../composables/useSettings.js'
export default { export default {
name: 'SettingsView', name: 'SettingsView',
components: { components: {
AgentTraceCenterView,
HermesEmployeeSettingsPanel, HermesEmployeeSettingsPanel,
EnterpriseSelect, EnterpriseSelect,
LlmSettingsPanel, LlmSettingsPanel,

View File

@@ -4,6 +4,12 @@ import {
resolveExpenseTypeCode resolveExpenseTypeCode
} from './travelReimbursementReviewModel.js' } from './travelReimbursementReviewModel.js'
import { normalizeExpenseQueryPayload } from './travelReimbursementExpenseQueryModel.js' import { normalizeExpenseQueryPayload } from './travelReimbursementExpenseQueryModel.js'
import {
inferPreviewKindFromFile,
isTemporaryPreviewUrl as isTemporaryDocumentPreviewUrl,
resolveDocumentPreviewAsset,
resolveDocumentPreviewKind as resolveUnifiedDocumentPreviewKind
} from '../../utils/documentPreviewAssets.js'
const SCENARIO_LABELS = { const SCENARIO_LABELS = {
expense: '报销', expense: '报销',
@@ -404,15 +410,7 @@ export function mergeUploadOcrDocuments(existingDocuments, incomingDocuments) {
} }
export function inferPreviewKind(file) { export function inferPreviewKind(file) {
const mediaType = String(file?.type || '').toLowerCase() return inferPreviewKindFromFile(file)
const filename = String(file?.name || '').toLowerCase()
if (mediaType.startsWith('image/') || /\.(png|jpg|jpeg|webp|bmp)$/i.test(filename)) {
return 'image'
}
if (mediaType.includes('pdf') || /\.pdf$/i.test(filename)) {
return 'pdf'
}
return 'file'
} }
export function buildFilePreviews(files, previewRegistry) { export function buildFilePreviews(files, previewRegistry) {
@@ -449,7 +447,7 @@ export function resolveDocumentPreview(filePreviews, filename) {
} }
export function isTemporaryPreviewUrl(url) { export function isTemporaryPreviewUrl(url) {
return String(url || '').trim().toLowerCase().startsWith('blob:') return isTemporaryDocumentPreviewUrl(url)
} }
export function buildFileIdentity(file) { export function buildFileIdentity(file) {
@@ -526,45 +524,35 @@ export function filterPersistableFilePreviews(filePreviews) {
}) })
} }
function inferPreviewKindFromUrl(url) {
const normalized = String(url || '').trim().toLowerCase()
if (!normalized) return ''
if (normalized.startsWith('data:image/') || /\.(png|jpg|jpeg|webp|bmp)(?:[?#].*)?$/i.test(normalized)) {
return 'image'
}
if (normalized.startsWith('data:application/pdf') || /\.pdf(?:[?#].*)?$/i.test(normalized)) {
return 'pdf'
}
return ''
}
function resolveDocumentPreviewKind(item) { function resolveDocumentPreviewKind(item) {
const explicit = String(item?.preview_kind || '').trim() return resolveUnifiedDocumentPreviewKind(item)
if (explicit) {
return explicit
}
return inferPreviewKindFromUrl(String(item?.preview_url || item?.preview_data_url || '').trim())
} }
export function buildOcrFilePreviews(payload) { export function buildOcrFilePreviews(payload) {
const documents = Array.isArray(payload?.documents) ? payload.documents : [] const documents = Array.isArray(payload?.documents) ? payload.documents : []
return documents return documents
.map((item) => ({ .map((item) => {
filename: String(item?.filename || '').trim(), const asset = resolveDocumentPreviewAsset(item)
kind: resolveDocumentPreviewKind(item), return {
url: String(item?.preview_url || item?.preview_data_url || '').trim() filename: String(item?.filename || '').trim(),
})) kind: asset?.kind || '',
url: asset?.url || ''
}
})
.filter((item) => item.filename && item.kind === 'image' && item.url) .filter((item) => item.filename && item.kind === 'image' && item.url)
} }
export function buildReviewFilePreviewsFromReviewPayload(reviewPayload) { export function buildReviewFilePreviewsFromReviewPayload(reviewPayload) {
const documents = Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : [] const documents = Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : []
return documents return documents
.map((item) => ({ .map((item) => {
filename: String(item?.filename || '').trim(), const asset = resolveDocumentPreviewAsset(item)
kind: resolveDocumentPreviewKind(item), return {
url: String(item?.preview_url || item?.preview_data_url || '').trim() filename: String(item?.filename || '').trim(),
})) kind: asset?.kind || '',
url: asset?.url || ''
}
})
.filter((item) => item.filename && item.kind === 'image' && item.url) .filter((item) => item.filename && item.kind === 'image' && item.url)
} }

View File

@@ -131,7 +131,7 @@ test('attachment upload association uses conversation selection instead of legac
assert.doesNotMatch(submitComposerSource, /查询可关联草稿失败,已继续按新单据识别/) assert.doesNotMatch(submitComposerSource, /查询可关联草稿失败,已继续按新单据识别/)
assert.match( assert.match(
submitDraftPreflightSource, submitDraftPreflightSource,
/const claims = await fetchExpenseClaims\(\)[\s\S]*const queryPayload = buildDraftAssociationQueryPayload\(claims\)[\s\S]*meta: \['等待选择关联单据'\][\s\S]*queryPayload/ /const claims = await fetchExpenseClaims\([^)]*\)[\s\S]*const queryPayload = buildDraftAssociationQueryPayload\(claims\)[\s\S]*meta: \['等待选择关联单据'\][\s\S]*queryPayload/
) )
assert.match(submitDraftPreflightSource, /meta: \['单据查询失败'\][\s\S]*return \{ handled: true, value: null \}/) assert.match(submitDraftPreflightSource, /meta: \['单据查询失败'\][\s\S]*return \{ handled: true, value: null \}/)
assert.match( assert.match(
@@ -186,6 +186,26 @@ test('OCR preview builders keep hotel receipt image previews when preview kind i
assert.deepEqual(reviewPreviews, [{ filename: 'hotel.png', kind: 'image', url: dataUrl }]) assert.deepEqual(reviewPreviews, [{ filename: 'hotel.png', kind: 'image', url: dataUrl }])
}) })
test('OCR preview builders reuse receipt folder image preview endpoints', () => {
const ocrPreviews = buildOcrFilePreviews({
documents: [
{
filename: '2月23 上海-武汉.pdf',
preview_kind: 'image',
receipt_preview_url: '/receipt-folder/receipt-train-1/preview'
}
]
})
assert.deepEqual(ocrPreviews, [
{
filename: '2月23 上海-武汉.pdf',
kind: 'image',
url: '/receipt-folder/receipt-train-1/preview'
}
])
})
test('OCR receipt folder ids are kept for final draft attachment association', () => { test('OCR receipt folder ids are kept for final draft attachment association', () => {
const files = [ const files = [
{ name: 'invoice.png' }, { name: 'invoice.png' },

View File

@@ -62,6 +62,15 @@ function testReceiptFolderViewSurface() {
assert.doesNotMatch(view, /const claims = await fetchExpenseClaims\(\)/) assert.doesNotMatch(view, /const claims = await fetchExpenseClaims\(\)/)
} }
function testReceiptPreviewKindFollowsReturnedBlobType() {
const view = readProjectFile('web/src/views/ReceiptFolderView.vue')
assert.match(view, /import \{ inferPreviewKindFromBlob \} from '\.\.\/utils\/documentPreviewAssets\.js'/)
assert.doesNotMatch(view, /function inferPreviewKindFromBlob\(blob\)/)
assert.match(view, /const resolvedPreviewKind = inferPreviewKindFromBlob\(blob\)/)
assert.match(view, /preview_kind: resolvedPreviewKind/)
}
function testReceiptFolderServiceContract() { function testReceiptFolderServiceContract() {
const service = readProjectFile('web/src/services/receiptFolder.js') const service = readProjectFile('web/src/services/receiptFolder.js')
const ocrService = readProjectFile('web/src/services/ocr.js') const ocrService = readProjectFile('web/src/services/ocr.js')
@@ -180,6 +189,7 @@ function testAssistantUnlinkedReceiptPrompt() {
function run() { function run() {
testReceiptFolderViewSurface() testReceiptFolderViewSurface()
testReceiptPreviewKindFollowsReturnedBlobType()
testReceiptFolderServiceContract() testReceiptFolderServiceContract()
testAppShellWiresReceiptFolder() testAppShellWiresReceiptFolder()
testSharedDocumentListStyleReuse() testSharedDocumentListStyleReuse()

View File

@@ -0,0 +1,39 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { readFileSync } from 'node:fs'
const settingsModel = readFileSync(new URL('../src/utils/settingsModelHelper.js', import.meta.url), 'utf8')
const settingsService = readFileSync(new URL('../src/services/settings.js', import.meta.url), 'utf8')
const settingsScript = readFileSync(new URL('../src/views/scripts/SettingsView.js', import.meta.url), 'utf8')
const settingsView = readFileSync(new URL('../src/views/SettingsView.vue', import.meta.url), 'utf8')
const useSettings = readFileSync(new URL('../src/composables/useSettings.js', import.meta.url), 'utf8')
test('system settings replace Agent Trace with cache management', () => {
assert.match(settingsModel, /id:\s*'cacheManagement'[\s\S]*label:\s*'缓存管理'/)
assert.doesNotMatch(settingsModel, /id:\s*'agentTraces'/)
assert.doesNotMatch(settingsScript, /AgentTraceCenterView/)
assert.match(settingsView, /activeSection === 'cacheManagement'/)
assert.match(settingsView, /一键清理缓存/)
})
test('cache management action calls the settings clear-cache endpoint', () => {
assert.match(settingsService, /export function clearSystemCaches/)
assert.match(settingsService, /\/settings\/cache\/clear/)
assert.match(useSettings, /clearSystemCaches/)
assert.match(settingsView, /@click="clearAllCaches"/)
})
test('cache management section uses an enterprise maintenance layout', () => {
assert.match(settingsView, /class="settings-card cache-management-card"/)
assert.match(settingsView, /class="card-title-with-icon"/)
assert.match(settingsView, /class="cache-management-hero-metrics"/)
assert.match(settingsView, /class="cache-scope-grid"/)
assert.match(settingsView, /OCR 识别结果/)
assert.match(settingsView, /模型失败冷却/)
assert.match(settingsView, /知识库本地索引/)
assert.match(settingsView, /不会删除源文件、业务单据和系统日志/)
assert.match(settingsView, /:class="\{ 'is-error': cacheClearFailed \}"/)
assert.match(useSettings, /const cacheClearFailed = ref\(false\)/)
assert.match(useSettings, /normalizeCacheClearErrorMessage/)
assert.doesNotMatch(settingsView, />\\s*Not Found\\s*</)
})

View File

@@ -1,17 +1,18 @@
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs' import { readFileSync } from 'node:fs'
const settingsScript = readFileSync(new URL('../src/views/scripts/SettingsView.js', import.meta.url), 'utf8') const settingsModel = readFileSync(new URL('../src/utils/settingsModelHelper.js', import.meta.url), 'utf8')
const settingsView = readFileSync(new URL('../src/views/SettingsView.vue', import.meta.url), 'utf8') const settingsView = readFileSync(new URL('../src/views/SettingsView.vue', import.meta.url), 'utf8')
const llmSettingsPanel = readFileSync(new URL('../src/views/LlmSettingsPanel.vue', import.meta.url), 'utf8')
function testLlmSectionReplacesVlmWithReranker() { function testLlmSectionReplacesVlmWithReranker() {
assert.doesNotMatch(settingsView, /VLM 模型/) assert.doesNotMatch(settingsView, /VLM 模型/)
assert.match(settingsView, /Reranker 模型配置/) assert.match(llmSettingsPanel, /Reranker 模型配置/)
assert.match(settingsScript, /rerankerProvider/) assert.match(settingsModel, /rerankerProvider/)
} }
function testRerankerCardRendersAfterEmbeddingCard() { function testRerankerCardRendersAfterEmbeddingCard() {
assert.match(settingsView, /Embedding 模型配置[\s\S]*Reranker 模型配置/) assert.match(llmSettingsPanel, /Embedding 模型配置[\s\S]*Reranker 模型配置/)
} }
function run() { function run() {

View File

@@ -1,13 +1,13 @@
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs' import { readFileSync } from 'node:fs'
const settingsScript = readFileSync(new URL('../src/views/scripts/SettingsView.js', import.meta.url), 'utf8') const settingsModel = readFileSync(new URL('../src/utils/settingsModelHelper.js', import.meta.url), 'utf8')
const settingsView = readFileSync(new URL('../src/views/SettingsView.vue', import.meta.url), 'utf8') const settingsView = readFileSync(new URL('../src/views/SettingsView.vue', import.meta.url), 'utf8')
const settingsStyles = readFileSync(new URL('../src/assets/styles/views/settings-view.css', import.meta.url), 'utf8') const settingsStyles = readFileSync(new URL('../src/assets/styles/views/settings-view.css', import.meta.url), 'utf8')
function testRenderingSectionUsesConciseToolbarTitle() { function testRenderingSectionUsesConciseToolbarTitle() {
assert.match(settingsScript, /title:\s*'文件渲染'/) assert.match(settingsModel, /title:\s*'文件渲染'/)
assert.doesNotMatch(settingsScript, /title:\s*'ONLYOFFICE 文件渲染配置'/) assert.doesNotMatch(settingsModel, /title:\s*'ONLYOFFICE 文件渲染配置'/)
} }
function testRenderingCardRemovesDuplicatedDescription() { function testRenderingCardRemovesDuplicatedDescription() {

View File

@@ -3,6 +3,8 @@ import { readFileSync } from 'node:fs'
import test from 'node:test' import test from 'node:test'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
import { buildSelectedFileCards } from '../src/composables/workbenchAiMode/workbenchAiComposerModel.js'
function readSource(path) { function readSource(path) {
return readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8') return readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')
} }
@@ -49,6 +51,31 @@ test('shared workbench file strip preserves OCR status badges', () => {
assert.match(fileStripComponent, /:title="file\.ocrState\.title \|\| file\.ocrState\.label"/) assert.match(fileStripComponent, /:title="file\.ocrState\.title \|\| file\.ocrState\.label"/)
}) })
test('shared workbench file strip uses recognized image preview metadata over raw PDF type', () => {
const [card] = buildSelectedFileCards([
{
name: '2月23 上海-武汉.pdf',
type: 'application/pdf',
size: 24940,
lastModified: 1760000000000
}
], () => ({
status: 'recognized',
document: {
preview_kind: 'image',
receipt_preview_url: '/receipt-folder/receipt-train-1/preview'
}
}))
assert.equal(card.tone, 'image')
assert.equal(card.icon, 'mdi mdi-file-image-outline')
assert.deepEqual(card.previewAsset, {
kind: 'image',
url: '/receipt-folder/receipt-train-1/preview',
source: 'receipt'
})
})
test('AI mode primes attachment OCR synchronously after file selection', () => { test('AI mode primes attachment OCR synchronously after file selection', () => {
assert.match( assert.match(
filePreviewRuntime, filePreviewRuntime,