module/agent/tools 的全部 Agent 工具通过 Spring AI MCP 暴露给外部需求:com.zsjz.ai.module.agent.tools 下的所有 tools 都要能通过 Spring AI MCP 给外部客户端用。
方案(适配而非重写):新增包 com.zsjz.ai.module.agent.mcp,3 个类:
| 文件 | 职责 |
|---|---|
AgentScopeToolCallback |
把 AgentScope 工具包成 Spring AI ToolCallback(ToolDefinition 直通 AgentScope 生成的 JSON Schema) |
AgentScopeMcpToolProvider |
@Component implements ToolCallbackProvider,另建一个独立 Toolkit(与内置 Agent 的实例隔离),注册同一批工具对象 + 5 个业务组全部激活,另附 4 个案件管理工具 |
McpCaseSession |
MCP 会话级「当前案件」绑定(按 McpSyncServerExchange#sessionId()),开案走 CaseDataSourceRegistry.open + CaseDataCache.initCache,不写 sa-token Token-Session(不干扰 Web 端) |
关键结论:
AgentToolRegistry 注册,MCP 侧自动出现;两边 schema 同源不会漂移。@DS("slave"),由 CaseRoutingDataSource 按 CaseContextHolder 路由到 case{caseId}。MCP 无登录态/无请求线程 ⇒ 调用前用 CaseContextHolder.callWith(caseId, null, supplier) 显式包裹。/mcpsse、/mcpstreamable 已加入 SaTokenWebConfig.WHITELIST(MCP 握手不带 x-token)。⚠️ 端点无鉴权,需外层限制来源。spring.ai.mcp.server.type: async ⇒ McpToolUtils.toAsyncToolSpecification 用 Schedulers.boundedElastic() 执行工具,所以在 call 里 .block(timeout) 是安全的。McpToolUtils 的 ToolContext 里带 McpSyncServerExchange(key = McpToolUtils.TOOL_CONTEXT_MCP_EXCHANGE_KEY),sessionId() 可拿会话 ID。ToolResultBlock → TextBlock 文本直出),与内置 Agent 一致。Json.toStr 会把 Long 序列化成字符串("caseId":"7"),写断言时别按数字写。测试:AgentScopeMcpToolProviderTest(9 例)——全量暴露且无重名、每个工具都能 McpToolUtils.toAsyncToolSpecification(这一步是 schema 兼容性的真正回归点,失败会让 MCP server 起不来)、render_graph 真实调用链路、入参校验错误文案、4 个案件工具与会话绑定、标量列表回归。
GET /js/a/mcpsse → event:endpoint → POST /js/a/mcp/message?sessionId=… 全链路打通:
tools/list = 74 个工具,0 重复,全部 inputSchema.type == object
(64 业务 + 6 基础设施含 search_table_schema + 4 案件管理)render_graph 返回规范化后的图谱 JSON(未被二次转义)list_cases → open_case 5 → execute_sql 查 call_record = 38263 行、
person_basic_info = 7 行;换案件 9 同样查询 = 0 行 ⇒ 案件上下文按 MCP 会话隔离、@DS 路由正确close_case 正常释放SSE 首帧下发的是 data:/mcp/message?sessionId=…(相对路径,不含 context-path),
客户端会拼成 http://host:8980/mcp/message → 404。
spring.ai.mcp.server.base-url: /js/a 修好后变成 /js/a/mcp/message?…。
另注:spring.ai.mcp.server.protocol 默认 SSE,此时 streamable-http.mcp-endpoint 不注册;
端点只接受对应方法(SSE 端点只认 GET,用 POST 探会得到 404,容易误判成「配置没生效」)。
list_person_names 在有人名的案件上必然报错ToolResultTable.toRows 按「元素都是 POJO」处理,对 List<String>(姓名清单)会抛
Cannot construct instance of java.util.LinkedHashMap ... from String value。
空人名时返回空列表所以一直没暴露,MCP 链路上在案件 5(7 个人名)实测到。
ToolResultTable.toRows 改为逐元素处理,标量(String/Number/Boolean/Character)兜底成单列 valuePersonAnalysisTool.listPersonNames 显式包成单列 nameAgentToolRegistryTest 5 例失败,原因是 AgentToolRegistry 里 OTG 组被注释、工具清单未做完
(实际 64 个业务工具,测试按计划中的 77 个断言):registersAllBusinessTools(NPE)、
activatingOneGroupRevealsOnlyThatGroup、metaToolEnumListsAllGroups、
noSchemaLeaksInjectionSurface、numericSpecFieldsBecomeJsonNumbers。
另 4 个测试类(Call/GraphRender/Sql/Track)全绿。
com.zsjz.ai.module.agent.llm)需求:封装一个不经过 Agent、直接调大模型拿结果的 service。
产出:
| 文件 | 职责 |
|---|---|
| LlmService | chat / chatDetail / chatAs / stream,核心是把 Model#stream 的分片拼成文本 |
| LlmRequest | @Builder(toBuilder=true):modelId / system / user / messages / temperature / maxTokens / timeout(默认 60s) |
| LlmResult | record:text / modelId / modelName / inputTokens / outputTokens / elapsedMs / totalTokens() |
设计要点:
agent_model 表(⚠️ 表名是 agent_model 不是 model),走 AgentModelFactory.create(),
与 Agent 链路同一套连接参数;不传 modelId 时取默认对话模型。ServerException(400/404/500/504),绝不返回 null —— 与
IntentService/FollowupService 的 fail-open 刻意相反(这是调用方主动要结果,不是辅助链路)。chatAs 不用 AgentScope 的 getStructuredData(那要 ReActAgent 的 generate_response 工具),
改为「提示词注入 schema + 宽松解析」(剥 ``` 围栏、截最外层 JSON)。踩到并修掉的坑:Flux.blockLast(Duration) 把流内任何错误都包成
IllegalStateException("Timeout on blocking read...") ⇒ 模型 401 被误报成 504。
改为 .timeout(...) + .onErrorMap(TimeoutException.class, …)。
验证(19 例 mock 单测 + 6 例真实 HTTP 测试,全绿):
本机 Ollama 未启动(11434 无监听)、且 shell 有 http_proxy=127.0.0.1:58859,
默认模型 Ollama:qwen 连不上。于是新增
ai-server/src/test/resources/mock-openai-server.py(HTTP/1.1 chunked 手写 SSE 的假 OpenAI 端点)
LlmServiceHttpTest(不启 Spring 上下文,秒级)真实跑通
「DB 配置 → AgentModelFactory → HTTP SSE → 分片拼接 → usage 提取 → 错误码映射」。
真厂商端点的 LlmServiceLiveTest 因本机无可用模型未验证。★ 一次诊断更正(重要):@SpringBootTest 加载上下文失败,曾被误判为
「PhoneIspMapper 漏了 @Mapper」——这个结论是错的。实测该 Mapper 的 @Mapper 一直都在,
module/plat/mapper 包下 8 个 mapper 全都有。
真因是 @DS("slave"):AppLoadEndEventListener#run() 裸调
GlobalCache#initCaseRocksDbData()(无 try-catch),其中 initIspData() 调
PhoneIspMapper.selectList();该 Mapper 带 @DS("slave")(查案件库),
测试环境没开案 → CaseRoutingDataSource 抛 ServerException(400, "请先打开案件后再操作数据")。
日志里 bean 是创建成功的(Creating MapperFactoryBean with name 'phoneIspMapper'),
报错发生在 SQL 执行阶段。前置的 CardIssuerBankMapper 不炸,是因为它只有 @Mapper、没有 @DS,走 master。
另注:AppLoadEndEventListener 里被 try-catch 吞掉的只有 initDir() 建目录
(日志「初始化系统文件失败」是它),initCache() / initCaseRocksDbData() 都是裸调用。
LlmServiceLiveTest 是全项目唯一的 @SpringBootTest,所以只有它需要
@MockitoBean PhoneIspMapper 这个补丁。
环境提示:探测本地端口必须 curl --noproxy '*',否则 http_proxy 会把请求转给代理并返回 502。
需求演进:最初是「放开到常见后缀 + 非表格文件登记为无法读取」, 最终明确为:前后端都不限制上传后缀;后端只校验后缀不是 csv / xls / xlsx 就跳过文件、 不执行上传(不落盘),但要在文件表插一条「不支持解析」的记录。
最终实现:
upload.vue 删掉 VALID_UPLOAD_EXTS / filterValidFiles / accept 属性,
任何文件都能进上传队列;文案统一为「xls / xlsx / csv 会自动解析,其余格式仅登记为『不支持解析』」GlobalCache.suffixList = ["xls","xlsx","csv"]
(中途加过的第二个白名单 uploadSuffixList 已删除,别再引入)DmService#preProcessUploadedFile 在 saveUploadedFile 之前就返回,
不落盘、不解析,只调新增的 buildUnsupportedFileInfo + saveParsedFileInfos 插一条
fileStatus = FILE_UNSUPPORTED_FAIL(前端显示「文件格式不正确」)的记录实现要点:
filePath 刻意留空(文件没落盘)→ SystemService#resolveDownloadFile 会明确
拒绝下载(「该记录没有可下载的源文件!」),而不是指向一个不存在的路径fileSize 用 MultipartFile#getSize()(浏览器实际传上来的字节数)fileType 记真实后缀,前端「名称」列显示成 报告.docx · docx关键实现依据(先验证再动手):
ExcelTypeEnum.recognitionExcelType() 按文件内容魔数判类型(XLSX=zip / XLS=OLE2 / 否则一律 CSV),
不按扩展名file_info 是案件 DuckDB 库的表(sql/case_table_1.sql),NOT NULL 字段:
id / pid / fileSize / dataNum / successNum / failNum —— 全部已赋值deletedFiles(按 fileId 删业务表,失败记录删 0 行不报错)、
clearUploadBatch(按 batchId 删)、cleanProgress.vue 的 findBatchId(对 children 做了数组检查)、
getFiles(无状态过滤,失败记录 pid=0 会被当根节点正常显示)顺带修掉一个由本次改动引入的边界问题:
失败记录也会进 resultTrees,而顶部「手动清洗」按钮原本只判断 resultTrees.length ——
用户只传一个 docx 时按钮会亮起,点进去是空页面。已新增 hasCleanableSheet(要求至少一个根节点有 children)
并同步 handleManualClean 守卫;上传完成 toast 也改为按「真正解析出 sheet 的数量」计数。
验证:后端 mvn compile + test-compile 均 BUILD SUCCESS;前端 vue-tsc 中 upload.vue 零错误
(全项目 89 个既有错误均在 trans 等模块,与本次无关);eslint 通过。
未动的地方(按「不要改其他问题」的要求):
DmService#isSupportedPreviewFile(/preFile 接口、Electron 遗留的文件夹扫描)仍用 suffixList 三种。
它只负责「哪些文件值得解析」,不是上传入口;Web 端「选择文件夹」走 preFileUpload,不受影响。SystemService#resolveDownloadFile 现只判「后缀非空」,不限白名单(能传就能下)。新增 ai-server/src/test/java/com/zsjz/ai/module/dm/service/DmServiceUploadSuffixTest.java,
纯 Mockito、无需 Spring 上下文 / 案件库(不支持后缀在 saveUploadedFile 之前就 return 了,
整条路径不碰 StateManager、不碰 DuckDB),秒级跑完。
cd /e/workspace/zsjz-ai && bash /tmp/mvnw.sh -o -pl ai-server test \
-Dtest='DmServiceUploadSuffixTest' -DfailIfNoTests=false -B -Dstyle.color=never
# Tests run: 5, Failures: 0, Errors: 0, Skipped: 0 -- BUILD SUCCESS
测试技巧(可复用):验证「表格后缀确实会走到落盘分支」不需要真实案件库 ——
saveUploadedFile 第一行就是 if (!StateManager.instance().isCaseOpened()) throw ServerException.spe("请先打开案件后再上传文件!"),
所以「未开案时抛这个异常」本身就是进入了上传分支的证据,再用
verify(fileInfoMapper, never()).insert(anyCollection()) 反证没有写文件表。
踩到的编译细节:FileInfoMapper extends BaseMapper<FileInfo>,本身没有 insert;
fileInfoMapper.insert(records) 传的是 List<FileInfo>,用的是 MyBatis-Plus 3.5.7+ 的
BaseMapper#insert(Collection<T>) 默认方法(不是 insert(T))。Mockito 侧要用 anyCollection() 匹配。
另:FileInfo#children 字段有默认值 new ArrayList<>()(@TableField(exist=false)),
所以失败节点 getChildren().isEmpty() 不会 NPE。
需求:非 xls/xlsx/csv 的文件用 Tika 读、取部分样本、直连大模型识别「是什么文件、干什么用的」、 存起来生成摘要;用虚拟线程访问大模型、限流、同时 3 个、其余排队。用户补充:新建一张表, 分类要覆盖「交易/统计/笔录/其他」等,后续要写工具对这些文件做数据分析。
| 关注点 | 结论 |
|---|---|
| 存哪 | 新表 file_ai_profile(案件 DuckDB 库),不往 file_info 塞字段 —— 那是表格解析流水线的主表,两条状态线纠缠会更乱 |
| 抽样本在哪做 | 请求线程内。MultipartFile 的 multipart 临时文件在请求结束被容器清理,异步线程读不到流 |
| 异步做什么 | 只有模型调用。样本抽完只剩 4000 字字符串,任务很轻 |
| 不落盘 | 沿用上一轮口径:非表格文件不落盘,样本直接从流里抽 |
| 限流 | Semaphore(3, fair=true) + Executors.newThreadPerTaskExecutor(虚拟线程);AtomicInteger inflight 做积压上限(超了标 SKIP),防一次拖入几千个文件把队列堆爆 |
| 表怎么建 | CaseDataSourceRegistry#ensureAiProfileTable 在开案时幂等跑 CREATE TABLE IF NOT EXISTS,新老案件库全覆盖;没改模板库(二进制资产,不动更安全) |
common/enums/FileCategoryEnum(TRANSACTION/STATISTICS/RECORD/COMMUNICATION/PERSON/DOCUMENT/OTHER,
promptOptions() 自动生成给模型的类别清单,parse() 把模型返回的中文名归一化成枚举名)common/enums/AiParseStatusEnum(PENDING/RUNNING/SUCCESS/FAIL/SKIP)common/model/dm/entity/FileAiProfile + module/dm/mapper/FileAiProfileMapper(@DS("slave"))common/model/dm/query/FileAiProfileQuerymodule/dm/ai/:FileSampleExtractor、FileSampleResult、FileRecognitionResult、
FileRecognitionService(调度)、FileAiProfileService(建档 + 查询 + 清理)DmController#fileAiProfile(POST /dm/fileAiProfile,对外前缀 /js/a)application.yaml 新增 zsjz.file-ai.{max-concurrent:3, max-pending:200, timeout-seconds:120}Tika#parseToString(in, metadata, maxLength) 而非 Spring AI 的 TikaDocumentReader ——
后者读全文没有上限。前者内部是 WriteOutContentHandler(maxLength),写满即中断解析,
大文件不会被读完,这正是「取部分样本」要的行为。detect() 的流必须自己包 BufferedInputStream 且 buffer ≥ 64KB ——
Tika 内部 mark(64KB)→探测→reset();若交给它一个不支持 mark 的流,它会自己包一层
BufferedInputStream 然后丢掉,原始流位置就回不去了。BufferedInputStream 的 mark 一旦被读过
缓冲区大小就失效(抛 "Resetting to invalid mark"),所以给了 128KB。deletedFiles 与 clearUploadBatch 同步删 file_ai_profile
(按 fileId / batchId),否则识别列表里会留下指向已删文件的记录。mvn -o -pl ai-server test)FileSampleExtractorTest(7):跑真 Tika。txt / 无后缀按内容识别 / 真 docx(手拼最小 OOXML zip,
不用 POI —— POI 只是 fesod 的传递依赖,写进测试会变隐式依赖)/ 空文件 / 51MB 跳过且不读流 / 随机二进制不抛异常FileRecognitionServiceTest(7):并发上限 3 的硬断言(6 个任务用 latch 卡住模型调用,
断言同时只有 3 个在跑、峰值 ≤ 3、其余在 inflight 里排队;放行后全部跑完)、积压超限标 SKIP、
成功/失败回写、空样本不占队列、无案件上下文不写库、提示词内容FileAiProfileSchemaTest(1):用真 DuckDB 执行生产代码里那份 DDL(AI_PROFILE_DDL 特意做成
package-private 供测试引用,复制一份就会漂移),校验 ①DDL 幂等 ②列名与实体注解一致
③列名与 MyBatis-Plus 真实解析(TableInfoHelper.initTableInfo)结果一致DmServiceUploadSuffixTest(5):补 fileAiProfileService mock 与调用断言newService() 再 when(...)。mock 字段是在 newService() 里赋值的,
先 stub 就是 stub 到 null,表现是一堆 InvalidUseOfMatchers + NPE,看着像 Mockito 坏了。Wrappers.lambdaUpdate().eq(FileAiProfile::getId, ...)
会抛「can not find lambda cache for this entity」,被 writeBack 的 try-catch 吞成日志后
表现为「零交互」。需在 @BeforeAll 里
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), FileAiProfile.class)。真厂商端点的端到端调用(本机无可用模型配置),沿用既有 LlmServiceLiveTest 的状态。
复查 FileSampleExtractor 时发现一个脆弱契约:extract(InputStream, String) 的 javadoc 要求
调用方「传入支持 mark/reset 的流」,但这个约定没法在类型上表达,而 FileInputStream、
部分网络流都不支持 mark。一旦有人踩了,Tika#detect 会自己包一层 BufferedInputStream
再把包装流丢掉,原始流位置回不去 —— 后续 parseToString 从 EOF 读,样本为空,
报错是「文件内没有可提取的文本内容」,排查方向会完全跑偏到编码问题上。
修复:extract(InputStream, String) 内部无条件再包一层 BufferedInputStream(128KB),
调用方传什么流都行;extract(MultipartFile) 简化为「拿流 → 委托」。
回归测试 FileSampleExtractorTest#handlesStreamWithoutMarkSupport(匿名 ByteArrayInputStream
覆盖 markSupported() 返回 false)—— 修复前该用例会失败(failReason 非空),是有效回归。
当前 extract(InputStream, String) 在生产代码里没有其他调用方(只有 MultipartFile 版本),
所以这是个防御性修复,不是线上 bug。
顺带:AI_AGENT.md §7 补上了 module/dm/ai 目录与 file_ai_profile 的约定
(非表格文件不落盘、fail-open、抽样本必须在请求线程内)。
后端识别结果落了 file_ai_profile 表,但界面上看不到 —— 功能对用户是隐形的。
本轮把上传页接上:上传完在右侧结果表新增「AI 识别」列,轮询拉取识别结果。
ai-frontend/src/case/api/govern/governApi.ts
FileAiProfileItem / FileAiProfileQuery 两个接口 + fileAiProfileList()
(POST {adminPath}/dm/fileAiProfile,与既有 importFileList 一样用 postJson<XxxItem[]>)。ai-frontend/src/case/types/enum.ts
新增 FileCategoryEnum(name → 中文),与后端 com.zsjz.ai.common.enums.FileCategoryEnum 一一对应。
刻意不加 AiParseStatusEnum —— 列的 pending/成功态用硬编码文案更省代码,加枚举是死代码。ai-frontend/src/case/views/data/upload.vue
aiProfileMap = ref<Record<string, FileAiProfileItem>>({})(fileId → 档案)stopAiPolling() + onUnmounted(stopAiPolling),离开页面立刻停,不留后台打接口的定时器。fileStatus === FILE_UNSUPPORTED_FAIL)才轮询,
纯表格上传不产生任何多余请求。—;PENDING|RUNNING → 「识别中…」;
FAIL → 「识别失败」;SKIP → 「未识别」(两者 title 带 failReason);
SUCCESS → 分类 · 摘要,单行省略,hover 看全文。.ai-cell(inline-block + ellipsis)+ .is-empty 灰色。record.id 就是 file_info.id(也就是 file_ai_profile.file_id),
所以 aiProfileMap 直接按 String(record.id) 对齐;sheet 子节点没有档案(表格文件才不做识别)。categoryLabel() 用 (FileCategoryEnum as unknown as Record<string,string>)[category] || category:
后端加新类别而前端没同步时显示英文 name,不至于空白。eslint --fix / prettier --check / vue-tsc --noEmit 三个文件全部通过。vue-tsc 唯一一条报错 governApi.ts(170,29) TS2315: Type 'Result' is not generic 是既有问题
(git show HEAD: 里同一行原文就是 defHttp.uploadFile<Result<FileInfo>>,未被我改动),
按「不要顺手改其他 bug」的要求没动。enum.ts 的 eslint --fix 顺带把该文件整体重排成项目风格(2 空格 / 单引号 / 末尾换行)——
它是全项目唯一一个用双引号 + 4 空格的异类,重排后与 prettier 配置一致。用户提到的「对这些文件进行数据分析的工具」尚未开始。
file 工具组)需求原文:「识别出来的文件,新建一个表存储下来……后续写一个工具,可能会对这些文件进行数据分析。」
表(file_ai_profile)和前端展示都已落地,这一步补上「工具」。
module/agent/tools/ 下的 XxxAnalysisTool(@Tool 方法)+ XxxToolSpecs(入参 POJO),
由 AgentToolRegistry 统一注册并分组。新增 GROUP_FILE = "file",3 个工具:
| 工具 | 作用 |
|---|---|
list_file_profiles |
按 category / keyword / parseStatus 检索材料清单,可翻页表格 |
stat_file_profiles_by_category |
分类分布(份数降序 + 该分类出现过的后缀) |
get_file_sample |
读某份材料的正文样本(≤4000 字),全链路唯一能看到材料原文的入口 |
ToolResultTable.toRows(entity)(那会把 sampleText 一起摊开)。
正文只能显式单份取 —— 刻意的按需下钻。count 探明,再按 MAX_LIMIT = 1000 翻页(服务侧 Math.clamp 会静默截到 1000,
传更大的 limit 是无效的 —— 这一点写进 MAX_LIMIT 的注释当公开契约)。FileCategoryEnum.parse 认不出会返回 OTHER,
直接用它会让「过滤笔录」静默变成「过滤其他」—— 返回一张合法但全错的表。
只接受 OTHER/其他 字面量,其余抛错并附合法取值清单。其他:枚举一律翻中文再给模型;sampleText == null 时给 note + failReason 而不是空字符串;
SAMPLE_MAX_CHARS 直接引用 FileSampleExtractor.MAX_SAMPLE_CHARS(不复制 4000)。
FileAiProfileService:list 与新增的 count 共用 buildWrapper(q) ——
两份条件分开写迟早漂移,出现「统计说 300 条、列表只返回 200 条」。MAX_LIMIT 改为 public。AgentToolRegistryTest 的 5 例既有失败已修绿(原先 8 例里 4 失败 1 错误)。
根因是测试按设计稿断言(77 个工具 / trans 19 / 6 个组),而实际是 64 个 / 16 个 / 5 个组
(otg 组被整段注释)。修法:TOTAL_BUSINESS_TOOLS 64+3=67、trans 16、
组名集合抽成共享常量 ALL_GROUPS(原先手抄 4 份)、pageSize 阈值 50→45(实际 48)。AgentToolRegistry 类注释与各组描述里的「N 个工具」原来是错的(trans 写 19 实为 16、
graph 写 6 实为 1、总计写 77 实为 67)。这些描述是给模型看的,
写错会让模型去找不存在的工具(如 get_graph_trans_detail),已按实际值更正。AgentScopeMcpToolProvider 的激活清单补上 GROUP_FILE ——
MCP 侧没有 reset_equipped_tools,只能全量激活,漏了外部客户端就看不到该组工具。mvn -o -pl ai-server test -Dtest='...11 个测试类...' → 91 例全绿,BUILD SUCCESS。
其中新增 FileAnalysisToolTest 13 例,含两个真实 Toolkit + callTool 的绑定回归:
bindsNestedQueryPojoThroughRealToolkit / bindsNumericSampleLengthThroughRealToolkit。
为什么要单独测绑定:@ToolParam POJO 参数若注解写错,框架不报错,
而是按 isUserContextPojo() 注入 null;工具里对 null 有兜底,于是「过滤器全失效、永远返回全量」
却毫无报错。断言「mock 收到的 Query 里 category=RECORD」才能锁死。
(ToolUseBlock 必须同时给 content 原始 JSON 与 input Map —— 框架校验读的是 content。)
file_ai_profile 单独做页面,只在上传页结果表里展示「AI 识别」列;
「已导入文件列表」页看不到历史材料的档案。用户原话:「前端什么也不要做。请你还原回去。后端识别存储了就行。跟前端点关系都没有。」
上面那两节(「前端展示:非表格文件的 AI 识别结果」)已作废,改动全部撤回:
ai-frontend/src/case/api/govern/governApi.ts、case/types/enum.ts、case/views/data/upload.vue
整体还原到 HEAD(git diff 为空、git status 干净)。
做法:git show HEAD:<path> > <path>(不用 git checkout -- / git restore —— 仓库红线);
enum.ts / upload.vue 再把 LF 转回 CRLF,否则 git status 会一直显示 modified
(core.autocrlf=true 下 git 期望工作区是 CRLF;内容其实一致,但状态不干净会误导人)。DmController 的 @PostMapping("/fileAiProfile") 接口、fileAiProfileService 字段、
3 个 import 一并删掉 —— 它当初只为前端那列供数,删前已确认无任何代码引用。AI_AGENT.md 第 7 节的前端 bullet 删掉;技能 §15.11 改成「前端:明确不做」并留了醒目警示。保留(后端识别 + 存储 + AI 工具):
Tika 抽样本 → file_ai_profile 落库 → 虚拟线程 3 路限流识别回写;
file 工具组(list_file_profiles / stat_file_profiles_by_category / get_file_sample);
FileAiProfileService 的 list / count / getByFileId / delete*。
「写个工具对这些文件做数据分析」不等于「做个页面」。以后接到这类需求, 先问清楚要不要前端,别默认「存了库就该能看见」——前端一加就是三四个文件、 还要轮询、还要枚举映射、还要样式,撤起来同样是三四个文件。
用户原话:「ai 识别 只识别 pdf、md、word、ppt、txt 等文本类型的文件,其他的都不 ai 识别。」
common/constants/FileTypeConstants —— 这个类原本全仓库零引用,正好改造成准入规则的唯一事实来源:
PPT / PPTX 常量、isPpt();AI_RECOGNIZABLE_SUFFIXES(31 个后缀)+ isAiRecognizable(String)(大小写/空白不敏感,null/空串 false)。module/dm/ai/FileAiProfileService#register —— 在早退检查之后、try 之前插入守卫:
if (!FileTypeConstants.isAiRecognizable(fileInfo.getFileType())) {
log.debug("非文本类文件不做 AI 识别: name={}, fileType={}", fileInfo.getFileName(), fileInfo.getType());
return;
}
(顺手补了漏掉的 import。)
测试:新建 FileAiProfileServiceTest(8 例)、FileTypeConstantsTest(6 例);
DmServiceUploadSuffixTest 的 @DisplayName 与注释对齐语义(它断言的只是「递过去了」)。
DmService:绕过是静默的(只是多花钱、识别不出东西,不报错就发现不了),
只有放在唯一入口才拦得住以后新增的调用点。所以 DmService 里不做后缀判断,无条件调 register。verifyNoInteractions(sampleExtractor) 就是为了钉死顺序 —— 只断言「没 insert」的话顺序错了也能过。★ csv / xls / xlsx 明确不加白名单(走表格流水线,根本到不了这条路)。
bash /tmp/mvnw.sh -o -pl ai-server test → 196 例,0 失败,BUILD SUCCESS。
FileAiProfileServiceTest#missingFileInfoIsIgnored 想测「id 为空」,但 helper 把 id 设成 1001 了,
于是走了正常路径 → verifyNoInteractions(sampleExtractor) 失败。写「负例」时先确认前置条件真的成立。
AI_AGENT.md 第 7 节、技能 qingjian-ai-dev §15(新增「准入规则」小节 + 文件地图 + 验证命令)都已补。
file_ai_profile 加 caseId 列(绑定到案件上)用户原话:「这个表还要创建 AI_PROFILE_DDL 案件 id 字段,绑定到案件上的」。
CREATE TABLE IF NOT EXISTS 对已存在的表是空操作老案件库里 file_ai_profile 已经建好了,光改建表语句永远加不上新列。
表现是「新装环境一切正常,老案件一识别就报 Column "caseId" not found」,
而这条链路是异步的,报错只在日志里,没人立刻发现。
所以后加的列必须写进两个地方:AI_PROFILE_DDL(新库)+ AI_PROFILE_MIGRATIONS(老库补列)。
ensureAiProfileTable 按 DDL → migrations 顺序执行。
补列语句必须幂等(DuckDB 1.5.5.1 支持 ALTER TABLE t ADD COLUMN IF NOT EXISTS c TYPE)——
一句不幂等会抛 Duplicate column name,被 catch 吞掉后后面的补列语句也一起不执行。
FileAiProfile 实体:新增 @TableField("caseId") private Long caseId;CaseDataSourceRegistry:DDL 加列 + 新增 AI_PROFILE_MIGRATIONS(List<String>)+ 循环执行sql/case_table_1.sql:同步加列 + 注明「改列要同步 3 处」FileAiProfileService:register 里只读一次 CaseContextHolder.get(),
同时给 build(...) 写列和 submit(profile, caseId, userId) —— 读两次万一中间上下文变了,
会落出「档案说 A 案、识别写回 B 案」的脏数据FileAiProfileSchemaTest:新增 legacyTableGetsCaseIdColumn(用冻结的旧版 DDL 造老表 +
一行数据 → 跑 DDL+补列 → 断言列补上、数据没丢、老行 caseId 是 NULL 不是 0)FileAiProfileServiceTest:+2 例(有上下文写对 caseId 且与 submit 一致 / 无上下文落 null)caseId 物理上冗余(表就在 case{caseId} 库里)。存它的理由:溯源(库文件会被拷走)、
跨案聚合(DuckDB ATTACH)、防御(路由串了能看出来)。
查询不按它过滤 —— 路由已限定案件范围,再叠一层只会制造「两边不一致就查不到」的假故障。
bash /tmp/mvnw.sh -o -pl ai-server test → 199 例,0 失败,BUILD SUCCESS
(含真 DuckDB 的补列回归)。