不按流水账写。一张卡片一个难题,底下是我们一步步啃它的过程 —— 日期是代码合并进主干的那天,做砸的也写。 Not a diary. Each card is one real problem; underneath is how it got solved, step by step. Dates are when the code merged. The things we got wrong are in here too.
我们本来有一份加密分享文件,医生下载下来能看整份病历。可真到门诊里,送达这一步常常卡住:文件得先传过去,医生还得找到它、打开它。我们试过微信 —— 文件能收到,iOS 上没有一键打开的办法。医院里那台连内网的工作站也指望不上,它和微信基本不会同时出现在一台机器上。于是换个问法:如果医生手里只有他自己那部手机,能不能让他三十秒看懂你现在什么情况?答案是一张码。而这张码有个很硬的上限。
# 之后 → 手机屏幕上显示成二维码。医生用自带的相机扫,查看器在他浏览器里解开。密钥也在 # 之后,按 HTTP 规范这部分不会随请求发给服务器 —— 医生那边只从静态页下载一个空壳查看器,病历数据不经过我们的服务器,只在两台手机之间。两个实现上的决定:fragment 带 q1. 前缀,查看器据此一眼分清「这是二维码载荷」还是「这是口令」;加密的绑定串用 medme-qr-v1,跟整份分享的 medme-share-v1 刻意不同,两种载荷不会被当成对方解开。解压直接用浏览器自带的 DecompressionStream,不引前端库。#166这张码换来的和放弃的,都摆在界面上。它只带当下病情:没有原件、没有影像、没有更早的记录 —— 那些要看,患者当场用自己手机翻给医生。这不是少做了一块,是这条通道本来就该只做这件事,整份病历有加密文件那条路。还有一句我们写进了出码页面:这张码就是钥匙。面对面出示码本身就算「在场证明」,所以不再另设口令;代价是被拍下就等于把这份摘要给了对方,没有过期,也没有撤销。顺带说清一件事:裁剪的那几个默认值(带几个病、每个病几条化验、几个点)现在是工程上的占位数,临床上该带多少,得等医生侧的反馈来定,到时候改几个常量就行,不用动管线。
We already had an encrypted share file: the doctor downloads it and sees the whole record. In an actual clinic, though, delivery is where it stalls. The file has to get there, and the doctor has to find it and open it. We tried WeChat — the file arrives, but on iOS there's no one-tap way to open it. The intranet workstation in the room is no help either; it and WeChat rarely live on the same machine. So we asked a different question: if the only thing the doctor holds is their own phone, can they understand where you stand in thirty seconds? The answer is a QR code. And a QR code has a hard ceiling.
# in a URL → render that as a QR code on screen. The doctor scans with their stock camera and the viewer decrypts in their browser. The key sits after the # too, and per the HTTP spec that part isn't sent to the server — the doctor's phone only pulls an empty viewer shell from a static page, and the record data travels strictly between the two phones. Two implementation calls: the fragment carries a q1. prefix so the viewer can tell "QR payload" from "passphrase" at a glance; and the AEAD binding string is medme-qr-v1, deliberately different from medme-share-v1 used by the full share, so neither payload can be opened as the other. Decompression uses the browser's own DecompressionStream — no frontend library. #166What this code buys and what it gives up are both printed on the screen. It carries the current picture only: no originals, no imaging, nothing from further back — for those, the patient pulls them up on their own phone right there. That isn't a missing piece; it's the job this channel is meant to do, and the full record has the encrypted-file route. One more line we put on the sharing screen: the code is the key. Holding it out face to face is itself the proof of presence, so there's no separate passphrase. The cost is that a photo of the code hands the summary over, with no expiry and no revocation. Worth stating: the trimming defaults — how many conditions, how many labs each, how many points — are engineering placeholders. What's clinically right is for doctors to tell us, and when they do it's a few constants to change, not the pipeline.
MedMe 的识别和整理都在你自己的设备上完成,照片和 PDF 不会被传到我们这儿 —— 我们也没有存它们的服务器。代价是没有一个通用的「认字」引擎可以用:每个系统只肯借你它自己那套 OCR,好用程度还不一样。这张卡片记的就是我们怎么在五个平台上,拼出一条尽量能把中文病历读准的路。
两件我们不打算藏着的事。第一,我们试过让视觉大模型直接「看」中文化验单的照片,在我们试的那几张上,它编出了根本不存在的化验项目名,还编得挺像真的。这种错放在病历里风险太高,所以这条路我们先放下了,老老实实走 OCR → 文字 → 抽取。第二,同一批手机实拍的照片,Apple Vision 给出的识别置信度落在 0.36–0.41,PP-OCRv5 落在 0.69–0.95;43.60、4.35 这几个数字,PP-OCRv5 读对了、Apple Vision 读错了。得说明的是,两个引擎的置信度是各算各的,数值之间不能直接比高低,能比的是「读出来的字对不对」——我们看的是后者。可惜 PP-OCRv5 目前只在桌面上跑,手机用不了,所以手机端认得准不准,到今天仍是我们没解决好的一块,还在找路子。
Recognition and organizing both happen on your own device — your photos and PDFs don't come to us, and we don't run servers that store them. The price: there is no single OCR engine available to us. Each OS only lends you its own, and they are not equally good. This card tracks how we assembled a path across five platforms that reads Chinese medical records as accurately as we can manage.
Windows.Media.Ocr, Linux uses PP-OCRv5, Android uses ML Kit, plus HEIC decoding. We picked OS-provided engines wherever we could, because they run start to finish locally. One thing to be clear about: PP-OCRv5 isn't one of them — its model files are downloaded once, over the network, the first time they're needed. That only happens on the desktop; phones don't use it. What gets downloaded is the model, not your records, but it is a network call and we shouldn't gloss over it. #42 · #50Two things we're not hiding. First: pointed at photos of Chinese lab reports, a vision-language model invented test names that weren't there — convincingly — on the samples we tried. That kind of error is too risky to carry into medical records, so we've set the approach aside for now and stick to OCR → text → extraction. Second: on the same set of real phone photos, Apple Vision reported confidence in the 0.36–0.41 range against 0.69–0.95 for PP-OCRv5, and PP-OCRv5 read values like 43.60 and 4.35 correctly where Apple Vision didn't. Worth noting: the two engines compute confidence differently, so the numbers aren't directly comparable — what is comparable is whether the characters came out right, and that's what we went by. PP-OCRv5 is desktop-only today, so mobile accuracy remains unsolved for us, and we're still looking.
读出字只是第一步。一份出院小结里,「二甲双胍 0.5g 每日两次」是用药,「谷丙转氨酶 43.60 U/L」是化验,「2 型糖尿病」是诊断 —— 得先分得清,才谈得上按时间给你理成一条线。难点不在认出常见词,在于同一个东西有十几种写法(缩写、商品名、中英混排、单位不统一),而且宁可漏,也不能编。
最难的一块是化验项目的同义词。同一个指标,不同医院能写成完全不同的样子:全称、英文缩写、旧称、加不加括号里的方法学。药名还能靠剥壳和单位证据收敛,化验项目的写法空间要大得多 —— 所以术语字典这层要一直扩下去,单靠规则收不干净。「直接换个更大的模型就行了」也不成立:我们试过现成的医疗大模型,它在这类中文病历文本上并不比规则可靠,还会把没有的东西说得很确定。我们的方向是规则打底,再把散文段交给自己微调的小模型。我们现在的方向是:规则打底,把散文段落交给一个我们自己微调的小模型,而不是让通用大模型自由发挥。
Reading the characters is only step one. In one discharge summary, "metformin 0.5g twice daily" is a medication, "ALT 43.60 U/L" is a lab result, and "type 2 diabetes" is a diagnosis. You have to tell them apart before you can lay anything out on a timeline. The hard part isn't the common words — it's that the same thing gets written a dozen ways (abbreviations, brand names, mixed Chinese/English, inconsistent units), and that missing something is acceptable while inventing something is not.
The hardest part is synonyms among lab items. The same measurement gets written completely differently across hospitals: full name, English abbreviation, legacy term, with or without the assay method in parentheses. Drug names can be pulled together by stripping and by unit evidence; the space of lab spellings is far larger — which is why the terminology dictionary has to keep growing, and why rules alone won't close it. "Just swap in a bigger model" doesn't hold either: we tried an off-the-shelf medical LLM and it wasn't more dependable on this kind of Chinese record text, while stating things that weren't there with full confidence. Our direction is rules as the floor, with prose sections handed to a small model we fine-tune ourselves. Our direction: rules as the floor, with prose sections handed to a small model we fine-tune ourselves, rather than letting a general-purpose LLM improvise.
病历大概是普通人手里最敏感的数据。我们的选择是干脆不建存它的服务器 —— 没有账号、没有云端库,东西就在你自己的设备上。这样一来,就不存在一个装着所有人病历、可以被一次性拖走的库;代价是数据的完整、备份、损坏恢复,全得我们自己在本地做对。
需要说明白的一点:「没有服务器」不等于「绝对安全」。设备丢了、系统自带的云备份被别人拿到,这些风险依然在,所以我们才在 #39 里把云同步不是端到端加密这件事直说。分享给医生的那个文件是端到端加密的(AES-256-GCM):它是一个自带解密逻辑的独立 HTML,解密全在对方浏览器里完成,口令不会传给我们。两点如实交代:我们确实托管着一个查看器页面(GitHub Pages 上那份),只是你的病历不经过它;还有,那串「口令」就是密钥本身的编码,没有再派生一层,所以文件和口令一定要分两个渠道发。
Medical records may be the most sensitive data an ordinary person holds. Our choice was to not build a server that stores them at all — no accounts, no cloud database, everything sits on your own device. That means there is no single database holding everyone's records for someone to walk off with; the price is that integrity, backup and corruption recovery are all on us to get right locally.
One thing to be straight about: "no server" is not the same as "perfectly safe". A lost device, or someone getting at your OS cloud backup, are still real risks — which is why #39 says outright that cloud sync isn't end-to-end encrypted. The file you share with a doctor is end-to-end encrypted (AES-256-GCM): a self-contained HTML that decrypts itself in the recipient's browser, with the passphrase never sent to us. Two things stated plainly: we do host a viewer page (on GitHub Pages) — your records just never go through it; and that "passphrase" is the encoded key itself, with no further derivation, so send the file and the passphrase through two different channels.
门诊留给你的时间通常只有几分钟。你翻着手机找化验单的时候,医生只想知道三件事:什么病、吃什么药、指标在往哪个方向走。所以我们要做的不是"把所有资料都给他",而是把最要紧的挑出来排在最前面,同时让他随时能点回原件核对 —— 医生不会信一份看不到出处的摘要。
A clinic visit gives you a few minutes. While you're scrolling your phone hunting for a lab report, the doctor wants three things: what conditions, what medications, which direction the numbers are moving. So the job isn't "hand over everything" — it's putting what matters at the top while keeping the original one tap away. No doctor trusts a summary they can't check against the source.
分享这一步的约束很硬:你没法指望医生为你装个 App、注册个账号,诊室的网也未必好使。所以我们给的不是链接,是一个文件 —— 一份自包含的加密 HTML,记录用 AES-256-GCM 加密塞在里面,查看器本身也在里面。口令另外一个渠道告诉他。他双击打开,输口令,浏览器本地解密。难的地方在于,这个文件一旦发出去就不归我们管了 —— 它得自己把该做的都做完,还得让医生信它。
script-src 里的 unsafe-inline 也就能拿掉了(样式那边至今还留着)。顺手把导航、表单、被嵌进别人页面的路都锁死。#38
几处细节,顺手记下来。这份查看器的安全策略第一行就是 default-src 'none'、connect-src 'none',文件里没有外部引用 —— 它没地方可连,也不需要连。里面的 DICOM 阅片也是同一个思路:只用一个 dicom-parser 加浏览器自带的 canvas 画,不引 Cornerstone 那套渲染栈(只借了它家的解析器),不用 WASM、不开 worker,按需解码、缓存有上限 —— 因为它得能塞进一个单文件里跟着走。能直接画的是未压缩的和 JPEG baseline 这两类;别的压缩格式、以及大到超过体积上限的序列,会降级成关键切片加一句说明,同样不静默。还有一个很小但很容易踩的坑:口令按 4 个字符一组显示,分隔符只能用空格,不能用连字符 —— - 本身就是 base64url 字母表里的字符,当成分隔符删掉会把密钥改坏。
The constraints here are unforgiving: you can't count on the doctor installing an app or signing up for an account, and clinic wifi is a coin flip. So we don't hand over a link — we hand over a file. One self-contained encrypted HTML, with the records AES-256-GCM encrypted inside it and the viewer inside it too. The passphrase travels separately. Double-click, type the passphrase, and the browser decrypts locally. The hard part is that once the file is sent, it's out of our hands — it has to do everything itself, and it has to earn the doctor's trust.
Condition and OMOP condition_era rather than a shape we invented. What matters more is the "source" tag next to each lab value and medication: tap it and you jump to the document it came from, with a button to jump back. We assembled the summary; we didn't write the originals — so checking one against the other has to stay one tap away. #132unsafe-inline from script-src (it's still there for styles). Navigation, form submission and being framed by another page got locked down in the same pass. #38
A few details worth writing down. The viewer's security policy opens with default-src 'none' and connect-src 'none', and the file carries no external references — there's nowhere for it to phone, and no need. DICOM reading inside it follows the same logic: just dicom-parser plus the browser's own canvas — none of the Cornerstone rendering stack (we borrowed their parser and stopped there), no WASM, no worker, decoding on demand against a bounded cache, because it has to fit inside a single file that travels. What it draws directly is uncompressed and JPEG baseline; other compressed formats, and series past the size ceiling, degrade to a key slice plus a note — again, said out loud rather than swallowed. And one small trap that's easy to fall into: the passphrase displays in groups of four, separated by spaces, never hyphens — - is itself a character in the base64url alphabet, so stripping it as a separator would corrupt the key.
病历是在诊室、在检查科门口、在缴费窗口排队时产生的,那个场景里你手上只有手机。手机几乎人人都有 —— 我们判断它是绝大多数人的第一选择,所以把它排在最前面。桌面版不是不做,而是定位不同 —— 它是进阶的个人数据查看器:大屏翻片子、比对多年趋势、批量整理。用得上它的人更少,但用得上的时候,手机屏幕替代不了。
代价也写清楚。手机优先的直接后果就是桌面端排在后面 —— 桌面版还在往前做,Linux 的构建先停着。这是排序换来的,不是忘了。
Medical records get created in the exam room, outside the imaging department, in the queue at the payment window. In those moments the only thing in your hand is a phone. Almost everyone has one, and we're betting it's the first choice for most people, so that's where we started. Desktop isn't cancelled — it plays a different role: the power-user's data viewer. Big screen for scans, multi-year trends side by side, bulk cleanup. Fewer people need that, but when they do, a phone screen won't substitute.
The cost, stated plainly. Putting mobile first means desktop comes later — it's still moving, while the Linux build stays paused. That's what the ordering costs, and it's a choice, not an oversight.
日期按 UTC · 更新日志 · 关于 · GitHub Dates in UTC · Changelog · About · GitHub