Skip to content
Get Started

① Collect

File Processing

How an uploaded document becomes text, a summary and health readings: the supported formats, tiered vision-LLM extraction, content-hash deduplication, and the keys that control it.

Uploading a lab report is one of the three intake paths health data takes into the engine, and the only one whose source is natural-language text rather than a schema. The engine stores the file, pulls text out of it, has a model read that text against a JSON schema, and writes the indicators the model finds as readings. All of it is Python, with no build-time switches.

An upload is dispatched by content type, and the checks are ordered for two reasons: a genotype export is also text/plain, so it has to be recognised before the text branch can claim it; and the text branch is a whitelist (text/plain or text/markdown) rather than text/*, because text/csv has to fall through to the CSV branch.

FormatRecognised byWhere text comes fromExtracts indicators
Genotype exporttext/plain and a WeGene header markerNo text extraction — genotype parsing handles itNo
Imagescontent_type starting with image/Vision LLMYes
PDFapplication/pdfThe embedded text layer, else a vision LLMYes
Audiocontent_type starting with audio/Speech to textNo
Text / Markdowntext/plain or text/markdownRead directlyYes
ExcelExtension or MIME: .xlsx .xls .xlsm .xlsbThe workbook rendered as textYes
CSVExtension or MIME: .csvAn injected processorDepends on that processor

There is no archive row: .zip is not a supported upload format.

The genotype check is deliberately narrow: one marker string and one MIME type, nothing else. It reads only the first 100 bytes, so it costs almost nothing.

ChannelRouteShapeUsed for
RESTPOST /files/uploadmultipart files[] plus an optional folder query parameterBatch uploads. Stores the files and returns key / URL / size / type for each.
WebSocket/ws/upload-health-report?token=…upload_start → many upload_chunkupload_endChunked uploads with live progress. An optional connectionId lets a client reconnect to its own session.

The socket authenticates from the token query parameter rather than a header, because a browser cannot set headers on a WebSocket handshake. It disconnects after 5 idle minutes, relaxed to 30 while an upload is active. Stored files are read back through GET /files/{file_path}, which proxies object storage so one URL works both in a browser and inside a container, and rejects .. outright.

Three behaviours matter directly to whoever writes the client:

  • The upload response does not wait for extraction. It returns once the summary is ready; indicator extraction finishes in the background and then writes its results and an indicator count back to th_files.
  • A failed upload still returns its file_key. A retry can point at the same object instead of orphaning it.
  • Only formats that produce text extract indicators. Audio and genotype exports deliberately opt out.

Pulling text out is where the money goes, so it is tiered: the cheap route first, and a model is paid for only when the cheap route comes back empty.

PDF: the embedded text layer

The text layer is read first. More than 100 characters after stripping whitespace means this is a born-digital document and no model is called at all. A scan yields roughly zero characters here and falls through.

Everything else: a vision model

The file goes to a vision provider along with the extraction prompt. Priority is gemini > openrouter > qwen > doubao, decided by which API key is configured; with none configured it raises rather than guessing.

Spreadsheets and text files: read locally

A workbook is rendered as text and a text file is read directly. Neither needs a model.

Then a summary and a filename from that text

A second, cheap model call turns the original text into a summary of at most 150 characters and a descriptive filename shaped like Date_Content_Description.ext, using only the first 8,000 characters. This step is synchronous: the upload does not return until the summary lands, with a fallback summary if it fails.

A long PDF is not sent as one block: it is split into single-page files and processed concurrently — parallel from two pages up, at most five pages at a time — pages with obviously no numbers are skipped, and the temporary page files are cleaned up afterwards.

The original text goes to the model with a JSON schema, temperature=0.1, and a prompt generated in the caller’s language. The model returns three things: an array of indicators, the report date used as the readings’ timestamp, and a file summary.

Results are deduplicated and then written into th_series_data: source_table is 'th_files', source_table_id carries the file key, and the unit, reference range and detection method are serialised as JSON into the encrypted comment. When the report date is missing, the reading is stamped with the user’s current local time rather than dropped.

The same PDF often gets uploaded twice: one person on two devices, or two family members sharing one report. Pulling text out is the expensive step, so the key is the content rather than the identity: the bytes are hashed with SHA-256, a cache hit returns the already-extracted text with no model call, and a miss extracts and then inserts skip-on-conflict, so two concurrent uploads of the same bytes cannot collide.

Two config keys govern this part, both overridable by environment variable, and neither changes what was compiled in:

config.yaml
# Vision model for image/PDF parsing. Key name = <PROVIDER>_VISION_MODEL.
GEMINI_VISION_MODEL: gemini-3.5-flash
EMBEDDING_PROVIDER: gemini
KeyDefaultWhat it does
<PROVIDER>_VISION_MODELThe provider’s own defaultOverrides the vision model. The key name follows the provider, so switching provider means reading a differently named key.
EMBEDDING_PROVIDERgeminigemini or qwen. Decides which embedding model makes extracted indicators findable afterwards.

The source for this part lives in mirobody/pulse/file_parser/.