跳转到内容
快速开始

① 采集

文件处理

上传的文档如何变成文本、摘要与健康 reading:支持的格式、视觉 LLM 的分层抽取、按内容哈希去重,以及控制这一切的开关。

上传一份化验报告是健康数据进入引擎的三条接入路径之一,也是唯一一条数据源为自然语言文本而非 schema 的路径。引擎把文件存下来,从里面取出文本,让模型按一份 JSON schema 阅读这些文本,再把模型找到的指标写成 reading。全程都是 Python,没有任何构建期开关。

上传按 content type 分发,判断是有顺序的,而顺序有两重讲究:基因文件也是 text/plain,所以要在文本分支有机会接手之前先判掉它;文本那一支是白名单text/plaintext/markdown)而不是 text/*,因为 text/csv 必须往下落到 CSV 分支。

格式判断依据文本从哪来抽指标吗
基因型导出text/plain 带 WeGene 文件头标记不抽文本,由基因型解析单独处理
图片content_typeimage/ 开头视觉 LLM
PDFapplication/pdf内嵌文本层,否则视觉 LLM
音频content_typeaudio/ 开头语音转文字
文本 / Markdowntext/plaintext/markdown直接读取
Excel扩展名 MIME:.xlsx .xls .xlsm .xlsb工作簿渲染成文本
CSV扩展名 MIME:.csv一个注入进来的处理器取决于该处理器

这里没有压缩包这一项:.zip 不是受支持的上传格式。

基因文件的判断刻意做得很窄:一个标记字符串、一个 MIME 类型,别无其它。判断只读前 100 字节,几乎不花代价。

通道路由形状用在哪
RESTPOST /files/uploadmultipart,files[] 加一个可选的 folder 查询参数批量上传。存下文件,逐个返回 key / URL / 大小 / 类型。
WebSocket/ws/upload-health-report?token=…upload_start → 多个 upload_chunkupload_end带实时进度的分片上传。可选的 connectionId 让客户端重连回自己的会话。

该 socket 从 token 查询参数取鉴权而不是请求头,因为浏览器无法在 WebSocket 握手上设置请求头。空闲 5 分钟后断开,有活跃上传时放宽到 30 分钟。存下的文件经 GET /files/{file_path} 读回,它代理对象存储,好让同一个 URL 在浏览器里和容器里都能用,并且直接拒绝 ..

三个行为对写客户端的人有直接影响:

  • 上传响应不等抽取。 摘要就绪即返回,指标抽取在后台跑完之后才把结果与指标条数写回 th_files
  • 上传失败也仍然返回 file_key 重试可以指向同一个对象,不会留下孤儿文件。
  • 只有产出了文本的格式才会抽指标。 音频与基因型刻意不参与。

取文本这一步是花钱的地方,所以做了分层:先走便宜的路,只在便宜的路空手而归时才为模型付钱。

PDF:内嵌文本层

先读文本层。去空白后超过 100 个字符就说明这是原生数字文档,一个模型都不用调。扫描件在这一步大约得到零个字符,于是继续往下落。

其它一律:视觉模型

文件连同抽取 prompt 送给视觉 provider。优先级是 gemini > openrouter > qwen > doubao,由配了哪个 API key 决定;一个都没配就直接抛错,不去瞎猜。

表格与文本文件:本地读取

工作簿渲染成文本,文本文件直接读。两者都不需要模型。

从文本再出摘要与文件名

第二次、很便宜的模型调用,把原始文本变成一段不超过 150 字符的摘要,和一个形如 Date_Content_Description.ext 的描述性文件名,只用前 8 000 个字符。这一步是同步的:摘要没落地上传就不返回,失败时用兜底摘要。

长 PDF 不会被当成一整块送出去:它先被切成单页文件,再并发处理,两页起并行、最多五页同时进行,明显不含数值的页会被跳过,临时页文件事后清掉。

原始文本连同一份 JSON schema 送给模型,temperature=0.1,prompt 按调用方的语言生成。模型返回三项内容:指标数组、拿来当 reading 时间戳的报告日期,以及一段文件摘要。

结果先去重,再写进 th_series_datasource_table'th_files'source_table_id 带上 file key,单位、参考区间与检测方法序列化为 JSON 写入加密的 comment。报告日期缺失时,这条 reading 会被打上用户当前的本地时间,而不是被丢掉。

同一份 PDF 常常会被上传两次:同一个人在两台设备上传,或者两位家人共享同一份报告。取文本是最贵的一步,所以键选的是内容而不是身份:对字节做 SHA-256,命中缓存就直接返回已抽好的文本,一个模型都不用调;未命中才抽取,并以「冲突即跳过」的方式写入,于是同样字节的两次并发上传不会撞车。

两个配置键管着这一块,都能用环境变量覆盖,没有一个会改变「编译进去了什么」:

config.yaml
# Vision model for image/PDF parsing. Key name = <PROVIDER>_VISION_MODEL.
GEMINI_VISION_MODEL: gemini-3.5-flash
EMBEDDING_PROVIDER: gemini
默认值作用
<PROVIDER>_VISION_MODELprovider 自带默认值覆盖视觉模型。键名随 provider 变,所以换 provider 就是读另一个名字的键。
EMBEDDING_PROVIDERgeminigeminiqwen。决定用哪个 embedding 模型,让抽出来的指标事后找得到。

这一块的源码在 mirobody/pulse/file_parser/