本页暂无完整中文版。以下先提供中文导读,随后是英文原文。
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
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, andres/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_familyputs both incount, 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
Nonewhen nothing has elapsed — an as-needed plan, or a window that has not started. Not0, which reads as “took nothing”.extracounts taken doses with no slot;extra_skippedcounts skips with no slot, which answer nothing and are reported separately.unschedulableslots 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).
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:
collect/sentence.pyhas 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.- The part becomes a
MedicationMention, after the checks every part gets: it must quote the sentence, and be about the person, asserted. collect/meds/mentions.pyhands the mentions toreconcile_mentions, which decides: a drug not on the list is a new plan, unconfirmed and markedsource="journal", its schedule parsed from the words byparse_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.- The journal lists the plan on the day it was written, and removing it
there marks it
entered_in_error.
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.