Skip to main content
本页暂无完整中文版。以下先提供中文导读,随后是英文原文。
中文导读: 本页说明用药数据的存储结构、状态与查询工具。读者可先确认所需的是用药记录查询还是通用健康指标查询,再在下方英文原文中查相应字段。
Status: provisional. The vocabulary, the arithmetic and the reference storage are here and pinned by 74 golden tests. The reference application is the first consumer; a second production consumer will find things to move, and the shapes below may change in a minor release until one exists. Nothing derived is stored, so a change here is a change to code, not a migration.
A medication is not a reading, and every design decision on this page follows from that. A reading has a value, a unit and an instant. A plan has a schedule (a tuple of dosing instructions), a lifecycle (active → stopped → resumed as a NEW course) and a code system of its own. Storing “takes metformin 500 mg twice a day” as a reading forces a choice between losing the schedule and inventing a value, and makes every aggregation query step over rows that are not measurements.

The model

concept_key is what gets indexed, joined and logged

A coded concept and a text-only one never compare equal, even for the same drug. Auto-merging them is how a wrong code silently rewrites a person’s medication list; two spellings make two plans, the person sees both and deletes one, which is the safer failure. The key is safe to log. The NAME is not, and is kept out of every repr.

normalize_name is deterministic and convergent only

NFKC folding (so full-width 500mg and ㎎ become ASCII), case folding, whitespace dropped. No alias or brand→generic mapping: that is an open-ended asset behind the Terminology port, and this framework ships none.

Dose units and forms

A dose is an amount in a UCUM unit. Countable forms are UCUM annotations, and res/dose_forms.tsv is the whole table — sixteen forms, with the aliases each one accepts: Two notes on the table:
  • {pill} and {tablet} are different annotations. They are not merged, because a “pill” in a person’s words may be a capsule and the framework does not decide which. dose_unit_family puts both in count, so they compare as amounts without being claimed to be the same object.
  • {dose} and {application} carry no amount at all. They are accepted so a schedule can be recorded, and they never enter arithmetic — a “dose” is not a quantity a total can be taken of.

The instruction grammar

parse_dose_instruction(text) -> Schedule | None. Small and closed: one dose, one frequency, optional clock times. No partial parses. Half a regimen presented as a whole one is worse than no regimen: the caller keeps the original text either way, and a None is visible. FHIR’s Dosage.text goes through the same grammar when the structured fields are empty, which R4B expects and pharmacy systems commonly send.

The state tables

A plan’s stored status vs its effective status

Stored: active, stopped, entered_in_error. Everything else is derived by effective_status(plan, today) — with the subject’s local date, never date.today() on a server. entered_in_error is FHIR’s “recorded in error”: terminal and retroactive. A consumer whose “cancel” is reversible maps it to stopped with its own flag.

A dose’s state is derived from the clock

Stored: taken, skipped. That is the entire set. The deadline is the end of the slot’s local day in the slot’s own zone — the zone the person was in when the dose was due — or grace minutes after the instant. Why nothing derived is stored: a stored missed is a lie the moment the person opens the app and marks the dose taken.

Importing a FHIR MedicationStatement

The plan id is never the resource’s own id. A FHIR resource id is unique within the server that issued it; a plan id is unique across every person a consumer stores. Two people importing documents from the same clinic would collide and one medication list would overwrite another. plan_id_for(subject, record, concept_key) keeps the idempotency that made the raw id tempting and scopes it to the subject.

Dose identity is not an instant

DoseSlot.key is (plan_id, local_date, slot). utc_ms is DERIVED at projection time. A person flies from Shanghai to London. Their 08:00 dose is still their 08:00 dose; only the instant changed. Keying on the instant would make the same dose two different doses, and a re-projection after a zone change would churn every row. Daylight saving. A slot at 02:30 on a spring-forward day has no instant. gap="shift_forward" moves it to the first instant that exists; gap="skip" leaves utc_ms=None and slot_state answers unschedulable. Neither is a default — the caller says which, because a blood-pressure tablet and a contraceptive want different answers.

Adherence

over elapsed slots only, to one decimal.
  • None when nothing has elapsed — an as-needed plan, or a window that has not started. Not 0, which reads as “took nothing”.
  • extra counts taken doses with no slot; extra_skipped counts skips with no slot, which answer nothing and are reported separately.
  • unschedulable slots are excluded from every other count.
  • Roll-ups across plans add counts; they never average percentages.

The tool: query_medications

Medications have their own tool, with four parameters — view, keywords, start, end — because their grammar shares nothing with a reading’s (see answers.md).
The rows are pure functions in kernel.meds — plan_rows, log_rows, history_rows over the two store ports — so a consumer with its own storage renders the same table; agent/tools/medications_service.py is the thin binding. plan carries the note “a plan is what the person intends to take; it is not a record of doses taken”, because a model that is not told this reports a medication list as evidence of what was swallowed. And a dose missing from log is not evidence it was not taken.

Storage (the reference application)

schema/31_medications.sql: th_medication_plan, th_medication_course, th_dose_event, th_override. The schedule column carries structure only — times, counts, weekdays, dose amounts. The person’s own words live in the encrypted columns: “饭后” that no parser turns into structure is kept in instructions_text, one entry per instruction. collect/meds/store.py implements MedicationStore and DoseLogStore and asks mirobody.kernel.meds every question that has an answer there, rather than re-deriving one in SQL.

From a sentence (/api/v1/journal/sentence)

A person does not fill in a form to add a medication. They write “每天早晚吃 二甲双胍500mg” in the journal, the one box of 数据 › 记录, and:
  1. collect/sentence.py has the model split the sentence and type each part. A medication part carries the drug, the dose and the frequency as written, and whether the person says they take it, stopped it, did not take it, or only wonders about it. The model is never asked for a code or a schedule.
  2. The part becomes a MedicationMention, after the checks every part gets: it must quote the sentence, and be about the person, asserted.
  3. collect/meds/mentions.py hands the mentions to reconcile_mentions, which decides: a drug not on the list is a new plan, unconfirmed and marked source="journal", its schedule parsed from the words by parse_dose_instruction (words it cannot read are kept on the instruction); a drug already on the list is left alone; “stopped” stops the matching active plan and never creates one.
  4. The journal lists the plan on the day it was written, and removing it there marks it entered_in_error.
The web client’s 指标 › 用药 tab lists the plans; saving a correction there confirms one.

The HTTP surface (/api/v1/medications)

server/routers/medication_router.py: listing, correcting and the lifecycle. POST / remains for an API client that has a structured plan in hand. Writes need the owner, or a member the owner granted write access; a read grant lists and nothing more (403). The journal applies the same rule when a caregiver logs “Dad stopped X”, so the two ways in cannot disagree. POST / takes ?target_user_id= for that member’s record. A plan the caller may not read answers 404, exactly like one that does not exist. An illegal move (stopping a stopped plan) is the kernel’s ValueError, returned as 400. A plan has at most one open course, found by closed_by IS NULL; its id is the plan, the start and its place in the plan’s history, so stopping and resuming on one day leaves two courses, not one. A start date may change only on an active plan, and the open course moves with it; a stopped plan’s start is its last course’s and stays.

Golden tests

The local suite holds 77 cases across four groups: G the grammar, A adherence, S schedule projection and DST, K identity and FHIR round-trips, plus the Apple import path against a synthetic sample that ships with the package.