不按流水账写。一张卡片一个难题,底下是我们一步步啃它的过程 —— 日期是代码合并进主干的那天,做砸的也写。 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.
病历大概是普通人手里最敏感的数据。我们的选择是干脆不建存它的服务器 —— 没有账号、没有云端库,东西就在你自己的设备上。这样一来,就不存在一个装着所有人病历、可以被一次性拖走的库;代价是数据的完整、备份、损坏恢复,全得我们自己在本地做对。
需要说明白的一点:「没有服务器」不等于「绝对安全」。设备丢了、系统自带的云备份被别人拿到,这些风险依然在,所以我们才在 #39 里把云同步不是端到端加密这件事直说。分享给医生的那个文件是端到端加密的(AES-256-GCM):它是一个自带解密逻辑的独立 HTML,解密全在对方浏览器里完成,口令不会传给我们。两点如实交代:我们确实托管着一个查看器页面(medmenow.com 上那份)。密文可能经过它,明文永远不会 —— 密钥只在 # 之后,按规范不随请求上行;还有,那串「口令」就是密钥本身的编码,没有再派生一层,所以文件和口令一定要分两个渠道发。
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: "local-first" 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 (at medmenow.com) — ciphertext may pass through it, plaintext never does, since the key stays after the # and is never sent upstream; and that "passphrase" is the encoded key itself, with no further derivation, so send the file and the passphrase through two different channels.
病历是在诊室、在检查科门口、在缴费窗口排队时产生的,那个场景里你手上只有手机。手机几乎人人都有 —— 我们判断它是绝大多数人的第一选择,所以把它排在最前面。桌面版不是不做,而是定位不同 —— 它是进阶的个人数据查看器:大屏翻片子、比对多年趋势、批量整理。用得上它的人更少,但用得上的时候,手机屏幕替代不了。
代价也写清楚。手机优先的直接后果就是桌面端排在后面 —— 桌面版目前暂缓、没有公开安装包,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 is paused for now with no public installer, and the Linux build stays paused too. That's what the ordering costs, and it's a choice, not an oversight.
MedMe 的识别和整理都在你自己的设备上完成,照片和 PDF 不会被传到我们这儿 —— 我们也没有存它们的服务器。代价是没有一套现成的引擎可以直接拿来用:每个系统只肯借你它自己那套 OCR,好用程度还不一样。后来我们在手机上把系统引擎换掉、自己带一套进去,起因很具体 —— 它把化验单上的数字读错了,下面那条写了是哪几个数。这张卡片记的就是我们怎么在五个平台上一步步把这条路拼出来,包括中途推翻自己的那次。
include_bytes! 编进 Rust 静态库,第一次用到时落进沙盒 —— iOS 沙盒里没有可写的模型目录,所以联网下载那条路在手机上是关掉的。505c23clibonnxruntime.a 里有 28 个自相冲突的重复对象。平时按需链接没事 —— 链接器每个符号取第一个定义。但 cargokit 默认给整个 Rust 静态库上 -force_load(为了不让 Dart 运行时靠 dlsym 找的入口被死代码消除掉),-force_load 会把每个对象都拉进来,于是 757 个重复符号;删掉任意一份,又缺符号。这个库没法整库 force_load。改法是换个思路保住入口:不再 force_load,让 onnxruntime 按需链接,再用 -Wl,-u 把桥接运行时那 14 个固定符号钉成链接根 —— 单个 dispatcher 静态引用了所有 wire 函数,保住它,整条闭包(wire → ocr → ort → onnxruntime)就会被按需拉进来。另外补 -lc++ -framework CoreML:ort 的构建脚本发出的链接指令只在 cargo 自己链接时算数,Xcode 做最终链接时传不过去。505c23cpage.width * 2。我们把 pdfx 的 width 当成了 point,据此换算出 144 DPI,觉得低于中文 OCR 通行的 300 DPI 下限,于是提到 4.17 倍。查一眼文档就知道错了:pdfx 写明 page.width 是像素,不是 point。放大倍数远超预期,render() 在真机上返回 null,而那行 if (img == null) continue; 一声不吭地跳过整页 —— 问题从「识别不准」变成「一个字都没有」。第二次改法不再赌一个常量:渲染倍数逐级降档 3× → 2× → 1.5×,第一个渲得出来的就用,每一档失败都打日志。顺手补了白底 —— render() 出来的 PNG 默认透明背景,黑字压在透明底上,被 OCR 加载时可能合成成黑底。当时日志里那两句留着:算术没错,前提是错的,而正确的算术让错误的前提显得可信;静默失败是放大器。#173 · #175Windows.Media.Ocr,Linux 用 PP-OCRv5,iOS 用 Vision,安卓用 ML Kit,再补上 HEIC 解码。挑的基本都是系统自带的能力,因为它们本地就能跑完。一处要说清楚:PP-OCRv5 不是系统自带的,它的模型文件会在第一次用到时联网下载一次。下载的是模型,不是你的病历,但这确实是一次联网,不该含糊过去。这条后来只对电脑成立:手机端从 7 月下旬起也用 PP-OCRv5,但模型是编进 app 里一起发的,联网下载那条路在手机上关着(见上面 7 月 22 日那条)。#42 · #50两件我们不打算藏着的事。第一,我们试过让视觉大模型直接「看」中文化验单的照片,在我们试的那几张上,它编出了根本不存在的化验项目名,还编得挺像真的。这种错放在病历里风险太高,所以这条路我们先放下了,老老实实走 OCR → 文字 → 抽取。第二,同一批手机实拍的照片,Apple Vision 给出的识别置信度落在 0.36–0.41,PP-OCRv5 落在 0.69–0.95;43.60、4.35 这几个数字,PP-OCRv5 读对了、Apple Vision 读错了。得说明的是,两个引擎的置信度是各算各的,数值之间不能直接比高低,能比的是「读出来的字对不对」——我们看的是后者。这段话最早写下来的时候还带着一句「可惜 PP-OCRv5 目前只在桌面上跑,手机用不了」;现在不是了 —— iOS 在 7 月 22 日、安卓在 7 月 24 日都换了过去,两端跑的是同一个引擎、同一套模型,过程在上面那两条里。一句边界说清楚:PP 依赖的 ONNX Runtime 只有 arm64 安卓的预编译库,别的 ABI 构建不出来;我们发布的安卓安装包也只出 arm64-v8a,落地页上写了「需 64 位手机」。
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 ready-made engine we can just pick up. Each OS only lends you its own OCR, and they are not equally good. We later replaced the system engine on phones and shipped our own, for a specific reason — it was getting digits on lab reports wrong, and the entry below names them. This card tracks how we assembled that path across five platforms, including the day we overturned our own earlier call.
libc++_shared.so packaged in, or PP crashes on device. b7cc3e9 · 5ca2910include_bytes! and dropped into the sandbox on first use — there's no writable model directory in an iOS sandbox, so the download path is switched off on phones. 505c23clibonnxruntime.a contains 28 duplicate objects that conflict with each other. Ordinary on-demand linking doesn't care — the linker takes the first definition of each symbol. But cargokit puts -force_load on the whole Rust static library by default, so that the entry points the Dart runtime finds via dlsym don't get stripped as dead code — and -force_load pulls in every object, which produced 757 duplicate symbols. Drop either copy and symbols go missing instead. This library can't be force-loaded whole. So we kept the entry points a different way: no force_load, let onnxruntime link on demand, and use -Wl,-u to pin the bridge runtime's 14 fixed symbols as link roots — one dispatcher statically references every wire function, so keeping it drags in the whole closure (wire → ocr → ort → onnxruntime) on demand. Plus -lc++ -framework CoreML: the link directives ort's build script emits only count when cargo does the linking, and don't survive to Xcode's final link. 505c23cpage.width * 2. We took pdfx's width to be in points, converted that to 144 DPI, decided it was under the 300 DPI floor people use for Chinese OCR, and raised it to 4.17×. One look at the docs would have shown the mistake: pdfx states page.width is in pixels, not points. The scale went far past what was intended, render() returned null on device, and the line if (img == null) continue; skipped the whole page without a word — turning "recognition is off" into "not a single character". The second fix stopped betting on one constant: try render scales 3× → 2× → 1.5× and take the first that comes back, logging every rung that fails. A white background went in at the same time — render()'s PNG is transparent by default, and black text over transparency can composite onto black when OCR loads it. Two lines from that day's log are worth keeping: the arithmetic was fine, the premise was wrong, and correct arithmetic makes a wrong premise look credible; and silent failure is an amplifier. #173 · #175Windows.Media.Ocr, Linux uses PP-OCRv5, iOS uses Vision, 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. What gets downloaded is the model, not your records, but it is a network call and we shouldn't gloss over it. That line later became desktop-only: phones run PP-OCRv5 too since late July, but the models ship inside the app and the download path is switched off there (see the July 22 entry above). #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. When this paragraph was first written it ended with "PP-OCRv5 is desktop-only today" — that's no longer the case. iOS moved on July 22, Android on July 24; both phones now run the same engine against the same models, and the two entries above tell how. One boundary worth stating: ONNX Runtime, which PP depends on, ships prebuilt libraries for arm64 Android only and other ABIs don't build — and the Android package we publish is arm64-v8a only, which the download page states as "64-bit phone required".
「它没读出来」听着像一个问题,拆开是四个,而且不在同一层:相机可能压根打不开;字可能没被找到(不是认错,是没走到认字那一步);整页可能是躺着的;甚至每个字都认对了,屏幕上还是一团乱。这四种毛病到了用户嘴里是同一句话。所以我们把顺序固定下来:先弄清坏在哪一层,再动那一层。
Table 用 FlexColumnWidth 把宽度平均分,窄屏上每列只剩下一个字左右的宽度,中文就顺着竖下去了,看起来就像行列颠倒。改成 IntrinsicColumnWidth(按内容自适应、不换行)加横向滚动,和桌面查看器一致。先看再改,这一步没跳过 —— 那版诊断提交自己什么都没修,可要是没有它,我们会一路去调 OCR,而 OCR 从头到尾是对的。23b78a1 · 8634aa9PP-LCNet_x1_0_doc_ori),6.8MB 打进手机包。桌面上转得好好的,华为那台真机就是不转。补了一版走显式模型路径的诊断,把范围缩到「Android 上的 onnxruntime 跑这个模型,输出和桌面不一致」;而且它在 oar-ocr 里是逐切片跑的,对「整页朝哪边」这个判断来说,粒度本来就不对。于是换个想法:判断整页朝向不需要黑箱。文档正着放,文字行的检测框普遍是扁的(宽 > 高);整页躺倒,每一行就变成一根竖条。竖框占比过半就当它躺倒,把图转 90° 和 270° 各重识别一遍,按识别置信度挑正的那个 —— 正着读置信度高,倒着是乱码。确定性、两端共用一套、复用已经跑通的检测模型,包里也不用多带一个模型;doc-ori 和那 6.8MB 一起删了。后面还补了一步:转正之后再按文字方向去一次斜,不然斜着拍的行仍旧是塌的。ce5f59f · ada00aa · 74c34df · 10ff5cflimit_side_len=960,不管原图多大,先把长边压到 960px 再检测。一张长边两千多的高截图被压成 0.375 倍,密集的小字就掉到检测分辨率以下 —— 没走到认字那一步;标题字大,活了下来。改法是纵向切片:按 1100px 一带、相邻两带上下各留 120px 重叠,把长图切开,每带用接近原生的分辨率单独跑,再按「这一行的中心落在哪一带的核心区」归属去重,合回整图坐标。下游拿到的仍然是整图坐标,零改动;图不够高就走单次识别,和以前逐字节一样。为了在桌面上复现,我们另拿一份真实报告堆成 960×2560、压到同样的 0.375 倍:单次整图检出 126 行,切片之后 199 行。(这是两种做法在同一张构造图上的差,不是产品的识别指标;用户那两张截图没在桌面上跑过。)与其把 960 调大 —— 那只是把悬崖挪到更高的图上 —— 不如多加一条带。04a602f四道坎里有三道的症状是一样的。相机打不开是明的,剩下三道在用户那儿都长成同一句「它没读出来」。所以现在的顺序是:先把中间产物打印出来看 —— 框在哪、多大、认出来什么字 —— 再决定动哪一层。还有一件事想说清楚:这四条里安卓和 iOS 的活儿不对称,起点就不一样。iOS 的采集走苹果自己的 VisionKit,导入的图还有原生的文档检测和拉正,那套现成好用,我们没理由再造一遍;安卓没有对应的原生件,所以采集那一条只修了安卓,四角透视也还没做,在待做里。朝向反过来 —— 它做在两端共用的 Rust 里,改一次两边都拿到。
"It didn't read" sounds like one problem. It's four, and they sit on different layers. The camera may not open at all. The characters may never be found — not misread; they simply don't reach the recognizer. The page may be lying on its side. And even with every character correct, the screen can still be a mess. All four reach us as the same sentence. So we fixed the order of operations: work out which layer broke before touching anything.
Table with FlexColumnWidth divides the width evenly — on a narrow screen each column ends up about one character wide, so Chinese stacks vertically and reads like a transposed table. Switched to IntrinsicColumnWidth (sized to content, no wrapping) plus horizontal scrolling, matching the desktop viewer. We looked before we changed anything — that diagnostic commit fixed nothing by itself, but without it we'd have gone off tuning OCR, and OCR was right the whole time. 23b78a1 · 8634aa9PP-LCNet_x1_0_doc_ori), 6.8MB compiled into the phone package. It worked on desktop; on a Huawei device it didn't rotate. A diagnostic pass with an explicit model path narrowed it to "Android's onnxruntime produces different output from desktop for this model" — and in oar-ocr the classifier runs per slice anyway, which is the wrong granularity for a decision about the whole page. So we changed the question: deciding which way up a page is doesn't need a black box. In an upright document, text-line detection boxes are wide and short; rotate the page and every line becomes a tall narrow strip. If more than half the boxes are tall, treat the page as sideways, re-run recognition on the 90° and 270° rotations, and keep the one with the higher recognition confidence — upright reads cleanly, upside-down comes back as garbage. Deterministic, one implementation for both platforms, reusing the detection model that already runs on both platforms, and no extra model in the package. The classifier and its 6.8MB came out. A follow-up step went in after: once uprighted, deskew along the text direction, or rows from a tilted shot still collapse. ce5f59f · ada00aa · 74c34df · 10ff5cflimit_side_len=960, shrinking the long edge to 960px before detection no matter how large the source is. A screenshot over 2000px tall gets scaled to 0.375×, and its dense small type falls below the detector's resolution — it never reaches the recognizer at all. Headings are large, so they survive. The fix is vertical banding: cut the tall page into 1100px bands with 120px of overlap on each inner side, run each band near native resolution, then deduplicate by which band's core a line's center falls into and merge back into full-image coordinates. Everything downstream still receives full-image coordinates and needed no changes; pages that aren't tall take the single-pass path, byte-for-byte as before. To reproduce it on desktop we stacked a different real report into a 960×2560 page and squeezed it by the same 0.375×: 126 lines detected in a single pass, 199 after banding. (That is the gap between two methods on one constructed page, not a product accuracy figure; the user's two screenshots were never run on desktop.) Raising 960 would only move the cliff to a taller image; adding a band doesn't. 04a602fThree of the four look identical from outside. A camera that won't open is obvious; the other three all arrive as the same sentence — "it didn't read". Hence the order we now follow: print the intermediate artifacts first — where the boxes are, how big, what text came out — then decide which layer to touch. One more thing worth saying: the work here is asymmetric between Android and iOS, because the starting points were. iOS captures through Apple's VisionKit and already has native document detection and rectification for imported images; that works, and rebuilding it would buy nothing. Android has no equivalent, so the capture entry above is Android-only, and four-corner perspective correction there is still on the list. Orientation runs the other way — it lives in the Rust both platforms share, so one change lands on both.
读出字只是第一步。一份出院小结里,「二甲双胍 0.5g 每日两次」是用药,「谷丙转氨酶 43.60 U/L」是化验,「2 型糖尿病」是诊断 —— 得先分得清,才谈得上按时间给你理成一条线。难点不在认出常见词,在于同一个东西有十几种写法(缩写、商品名、中英混排、单位不统一),而且宁可漏,也不能编。
最难的一块是化验项目的同义词。同一个指标,不同医院能写成完全不同的样子:全称、英文缩写、旧称、加不加括号里的方法学。药名还能靠剥壳和单位证据收敛,化验项目的写法空间要大得多 —— 所以术语字典这层要一直扩下去,单靠规则收不干净。「直接换个更大的模型就行了」也不成立,但原因和直觉不同:现成医疗模型读文字其实不差 —— 我们自标的 14 份文档上,用药 F1 0.97、疾病召回满分;真正拖后腿的恰恰是上面那些同义词。会编的是直接吃照片那条路(见前面让它自己看图那段)。所以方向是:规则打底,散文段落交给一个我们自己微调的小模型,而不是让通用大模型自由发挥。
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, though not for the obvious reason: an off-the-shelf medical model reads text reasonably well — 0.97 F1 on medications and perfect recall on conditions across the 14 documents we hand-labelled; what drags it down is exactly the synonym problem above. The confident fabrication came from feeding it photographs instead (see the "let it read the image" entry earlier). 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.
门诊留给你的时间通常只有几分钟。你翻着手机找化验单的时候,医生只想知道三件事:什么病、吃什么药、指标在往哪个方向走。所以我们要做的不是"把所有资料都给他",而是把最要紧的挑出来排在最前面,同时让他随时能点回原件核对 —— 医生不会信一份看不到出处的摘要。
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 加密塞在里面,查看器本身也在里面。口令另外一个渠道告诉他。他双击打开,输口令,浏览器本地解密。2026-07-28 更新:手机端已撤掉这条路 —— 实测 iOS 微信收到 .html 根本打不开,而二维码扩展到完整病历后也不再需要它;桌面端仍有该能力,已发出去的文件用查看器照常打开。难的地方在于,这个文件一旦发出去就不归我们管了 —— 它得自己把该做的都做完,还得让医生信它。
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. The answer at the time wasn't a link — it was 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. Update 2026-07-28: the phone no longer offers this path — iOS WeChat cannot open a received .html at all, and once the QR code grew to carry the full record it was no longer needed. Desktop keeps the capability, and files already sent still open in the viewer. 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.
我们本来有一份加密分享文件,医生下载下来能看整份病历。可真到门诊里,送达这一步常常卡住:文件得先传过去,医生还得找到它、打开它。我们试过微信 —— 文件能收到,iOS 上没有一键打开的办法。医院里那台连内网的工作站也指望不上,它和微信基本不会同时出现在一台机器上。于是换个问法:如果医生手里只有他自己那部手机,能不能让他三十秒看懂你现在什么情况?答案是一张码。而这张码有个很硬的上限。
q2.<取件号>.<密钥>,完整病历连同每一份原件加密后临时存在瞬时云上,密钥只在码里、从不上行,保留期一到自动删除。医生扫完直接看到全部,不用再问患者要原件。
# 之后 → 手机屏幕上显示成二维码。医生用自带的相机扫,查看器在他浏览器里解开。密钥也在 # 之后,按 HTTP 规范这部分不会随请求发给服务器 —— 医生那边只从静态页下载一个空壳查看器,病历数据不经过我们的服务器,只在两台手机之间。两个实现上的决定:fragment 带 q1. 前缀,查看器据此一眼分清「这是二维码载荷」还是「这是口令」;加密的绑定串用 medme-qr-v1,跟整份分享的 medme-share-v1 刻意不同,两种载荷不会被当成对方解开。解压直接用浏览器自带的 DecompressionStream,不引前端库。#166这张码换来的和放弃的,都摆在界面上。它现在带的是完整病历,含每一份原件 —— 上面 07-28 那条把「只带当下病情」这个取舍推翻了。代价是它需要联网:密文得先传上去、医生扫完再取回来,而不是像早先那样整份塞在码里。还有一句我们写进了出码页面:这张码就是钥匙。面对面出示码本身就算「在场证明」,所以不再另设口令;代价是被拍下就等于把这份摘要给了对方,没有过期,也没有撤销。顺带说清一件事:裁剪的那几个默认值(带几个病、每个病几条化验、几个点)现在是工程上的占位数,临床上该带多少,得等医生侧的反馈来定,到时候改几个常量就行,不用动管线。
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.
q2.<id>.<key>; the full record — every original included — is encrypted and parked in transient storage. The key lives only in the code and never goes upstream, and the object is deleted when its retention window ends. The doctor scans once and sees everything.
# 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 now carries the full record, every original included — the 2026-07-28 entry above overturned that trade-off. The cost is that it needs a network — 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.
日期按 UTC · 更新日志 · 关于 · GitHub Dates in UTC · Changelog · About · GitHub