Building MedSpeak: A Tiny Tool That Translates Prescription Shorthand
Ever picked up a prescription and stared at "1 tab BID pc" like it was a secret code? That's because it basically is one. Pharmacy shorthand — abbreviations for frequency, timing, route, and form — makes total sense to clinicians and pharmacists, but leaves most patients guessing.
So I built MedSpeak, a small Python + HTML tool that takes shorthand like 2 cap TID AC PO and turns it into plain English: "Take 2 capsules three times a day before meals by mouth." No frameworks, no database — just a lookup table and a tiny web server.
Here's how it works, broken into pieces you can follow (and tweak) on your own.
1. The lookup tables
The core idea is a handful of dictionaries mapping abbreviation → plain phrase. Frequency codes like bid (twice a day) or qid (four times a day), timing codes like ac (before meals), route codes like po (by mouth) or iv (by IV), and form codes like tab (tablet) or cap (capsule).
FREQUENCY = {
"qd": "once a day", "od": "once a day",
"bid": "twice a day",
"tid": "three times a day",
"qid": "four times a day",
"qod": "every other day",
"hs": "at bedtime",
"stat": "immediately",
"prn": "as needed",
"am": "in the morning", "qam": "every morning",
"pm": "in the evening", "qpm": "every evening",
}
TIMING = {
"ac": "before meals",
"pc": "after meals",
"cc": "with meals",
}
ROUTE = {
"po": "by mouth",
"sl": "under the tongue",
"iv": "by IV",
"im": "as an injection into the muscle",
"sc": "as an injection under the skin",
"sq": "as an injection under the skin",
"pr": "rectally",
"top": "applied to the skin",
}
FORM = {
"tab": "tablet", "tabs": "tablets",
"cap": "capsule", "caps": "capsules",
"ml": "ml",
"mg": "mg",
"gtt": "drop", "gtts": "drops",
}
Keeping these as flat dictionaries makes the tool easy to extend — adding a new abbreviation is a one-line change.
2. The decoding logic
The translate() function splits the input into tokens, checks each one against the tables in order, and reassembles a sentence. It also handles numeric doses and the common qXh pattern (like q6h → "every 6 hours").
def translate(text):
tokens = text.replace(",", " ").split()
result = []
for tok in tokens:
raw = tok.strip(".")
low = raw.lower()
if low.isdigit():
result.append(f"Take {low}")
elif low in FORM:
result.append(FORM[low])
elif low in FREQUENCY:
result.append(FREQUENCY[low])
elif low in TIMING:
result.append(TIMING[low])
elif low in ROUTE:
result.append(ROUTE[low])
elif low.startswith("q") and low.endswith("h") and low[1:-1].isdigit():
hrs = low[1:-1]
result.append(f"every {hrs} hours")
else:
result.append(raw)
sentence = " ".join(result)
return sentence[0].upper() + sentence[1:] if sentence else ""
Anything unrecognized just passes through untouched, so the tool degrades gracefully instead of failing on unfamiliar shorthand.
3. The HTML page
The whole UI is a single templated string: a title, an input box, a "Translate" button, and a "label card" styled to look like an actual prescription sticker. Two placeholders get swapped in on each request — the last thing the user typed, and the translated result.
HTML_PAGE = """
<!DOCTYPE html>
<html><head><meta charset="utf-8">
<title>MedSpeak</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Lora:wght@500;600&family=Inter:wght@400;500;600&family=IBM+Plex+Mono:wght@500;600&display=swap" rel="stylesheet">
<style>
:root{
--paper: #E8EDE6;
--card: #FBFAF6;
--ink: #1B2A4A;
--ink-soft: #4A5A72;
--amber: #C17817;
--amber-deep: #9C5F10;
--clay: #B3492B;
}
*{box-sizing:border-box}
body{
margin:0; padding:32px 16px 60px;
background:var(--paper);
background-image:
radial-gradient(circle at 15% 8%, rgba(193,120,23,0.06), transparent 40%),
radial-gradient(circle at 85% 90%, rgba(27,42,74,0.05), transparent 40%);
font-family:'Inter', sans-serif;
color:var(--ink);
display:flex; justify-content:center;
}
main{width:100%; max-width:560px}
.eyebrow{
font-family:'IBM Plex Mono', monospace;
font-size:12px; letter-spacing:0.12em; text-transform:uppercase;
color:var(--amber-deep); font-weight:600;
}
h1{
font-family:'Lora', serif; font-weight:600;
font-size:clamp(28px, 5vw, 36px);
margin:6px 0 4px; color:var(--ink);
}
.sub{ color:var(--ink-soft); font-size:15px; margin:0 0 28px; max-width:44ch; line-height:1.5}
.rx-mark{
display:inline-flex; align-items:center; justify-content:center;
width:40px; height:40px; border-radius:10px;
background:var(--ink); color:var(--paper);
font-family:'Lora', serif; font-weight:600; font-size:20px;
margin-bottom:14px;
}
.card{
background:var(--card); border-radius:14px; padding:22px;
box-shadow:0 1px 2px rgba(27,42,74,0.06), 0 8px 24px rgba(27,42,74,0.05);
border:1px solid rgba(27,42,74,0.08);
}
label.field-label{
display:block; font-size:13px; font-weight:600; color:var(--ink-soft);
margin-bottom:6px;
}
input[type=text]{
width:100%; padding:12px 14px; font-size:16px;
border-radius:8px; border:1.5px solid rgba(27,42,74,0.18);
font-family:'IBM Plex Mono', monospace; background:#fff; color:var(--ink);
}
input[type=text]:focus{
outline:3px solid rgba(193,120,23,0.35); outline-offset:1px;
border-color:var(--amber);
}
.row{ display:flex; gap:10px; margin-top:12px }
.row input{flex:1}
button{
padding:12px 20px; font-size:15px; font-weight:600;
border:none; border-radius:8px; cursor:pointer;
background:var(--amber); color:#fff;
font-family:'Inter', sans-serif;
transition:background .15s ease;
}
button:hover{ background:var(--amber-deep) }
button:focus-visible{ outline:3px solid var(--ink); outline-offset:2px }
.examples{ font-size:13px; color:var(--ink-soft); margin-top:10px }
.examples code{
font-family:'IBM Plex Mono', monospace; background:rgba(27,42,74,0.06);
padding:2px 6px; border-radius:4px;
}
.label-wrap{ margin-top:26px }
.rx-label{
position:relative;
background:#fff;
border:1.5px solid rgba(27,42,74,0.15);
border-radius:2px;
padding:18px 18px 16px;
font-family:'IBM Plex Mono', monospace;
font-size:15px; line-height:1.55; color:var(--ink);
transform:rotate(-0.6deg);
box-shadow:0 6px 16px rgba(27,42,74,0.10);
}
.rx-label::before{
content:"";
position:absolute; top:-1px; left:8px; right:8px; height:1px;
background-image: repeating-linear-gradient(90deg, rgba(27,42,74,0.35) 0 6px, transparent 6px 12px);
}
.rx-label .tag{
font-family:'Inter', sans-serif; font-weight:600; font-size:11px;
letter-spacing:0.08em; text-transform:uppercase; color:var(--amber-deep);
display:block; margin-bottom:6px;
}
.rx-label.empty{ color:var(--ink-soft); font-style:normal }
@media (prefers-reduced-motion: reduce){
* { transition:none !important }
}
</style>
</head>
<body>
<main>
<div class="rx-mark">Rx</div>
<div class="eyebrow">Prescription shorthand, decoded</div>
<h1>MedSpeak</h1>
<p class="sub">Type the shorthand exactly as written on the label — get back plain instructions anyone can follow.</p>
<div class="card">
<label class="field-label" for="rx">Prescription text</label>
<form action="/api/translate" method="POST">
<div class="row">
<input type="text" id="rx" name="text" placeholder="e.g. 1 tab BID pc" value="__LAST_TEXT__">
<button type="submit">Translate</button>
</div>
</form>
<p class="examples">Try <code>1 tab BID pc</code> · <code>PRN q6h</code> · <code>2 cap TID AC PO</code></p>
<div class="label-wrap">
<div class="rx-label__EMPTY_CLASS__" id="out">
<span class="tag">Instructions</span>
__RESULT_TEXT__
</div>
</div>
</div>
</main>
</body></html>
"""
def render_page(last_text="", result_text=None):
if result_text is None:
empty_class = " empty"
result_text = "Translated text will appear here."
else:
empty_class = ""
def esc(s):
return (s.replace("&", "&").replace("<", "<")
.replace(">", ">").replace('"', """))
page = HTML_PAGE
page = page.replace("__LAST_TEXT__", esc(last_text))
page = page.replace("__EMPTY_CLASS__", empty_class)
page = page.replace("__RESULT_TEXT__", esc(result_text))
return page
4. Serving it as a web page
MedSpeak runs on Python's built-in http.server — no third-party dependencies at all. A GET / returns the page; a POST /api/translate reads the submitted form body, runs it through translate(), and re-renders the page with the result filled in. Only two imports are used in the whole project: http.server and urllib.parse, both from the standard library — nothing to pip install.
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import unquote_plus
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
if self.path == "/":
self._send(200, render_page())
else:
self._send(404, "Not found")
def do_POST(self):
if self.path == "/api/translate":
length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(length).decode()
params = {}
for pair in body.split("&"):
if "=" in pair:
k, v = pair.split("=", 1)
params[unquote_plus(k)] = unquote_plus(v)
text = params.get("text", "")
result = translate(text)
self._send(200, render_page(last_text=text, result_text=result))
else:
self._send(404, "Not found")
def _send(self, code, body, ctype="text/html"):
self.send_response(code)
self.send_header("Content-Type", ctype)
self.end_headers()
self.wfile.write(body.encode())
def log_message(self, *args):
pass
if __name__ == "__main__":
print("MedSpeak running at http://localhost:8000")
ThreadingHTTPServer(("0.0.0.0", 8000), Handler).serve_forever()
5. The full script, start to end
Everything above, combined into one file. Save it as medspeak.py and run python medspeak.py, then open http://localhost:8000:
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import unquote_plus
FREQUENCY = {
"qd": "once a day", "od": "once a day",
"bid": "twice a day",
"tid": "three times a day",
"qid": "four times a day",
"qod": "every other day",
"hs": "at bedtime",
"stat": "immediately",
"prn": "as needed",
"am": "in the morning", "qam": "every morning",
"pm": "in the evening", "qpm": "every evening",
}
TIMING = {
"ac": "before meals",
"pc": "after meals",
"cc": "with meals",
}
ROUTE = {
"po": "by mouth",
"sl": "under the tongue",
"iv": "by IV",
"im": "as an injection into the muscle",
"sc": "as an injection under the skin",
"sq": "as an injection under the skin",
"pr": "rectally",
"top": "applied to the skin",
}
FORM = {
"tab": "tablet", "tabs": "tablets",
"cap": "capsule", "caps": "capsules",
"ml": "ml",
"mg": "mg",
"gtt": "drop", "gtts": "drops",
}
def translate(text):
tokens = text.replace(",", " ").split()
result = []
for tok in tokens:
raw = tok.strip(".")
low = raw.lower()
if low.isdigit():
result.append(f"Take {low}")
elif low in FORM:
result.append(FORM[low])
elif low in FREQUENCY:
result.append(FREQUENCY[low])
elif low in TIMING:
result.append(TIMING[low])
elif low in ROUTE:
result.append(ROUTE[low])
elif low.startswith("q") and low.endswith("h") and low[1:-1].isdigit():
hrs = low[1:-1]
result.append(f"every {hrs} hours")
else:
result.append(raw)
sentence = " ".join(result)
return sentence[0].upper() + sentence[1:] if sentence else ""
HTML_PAGE = """
<!DOCTYPE html>
<html><head><meta charset="utf-8">
<title>MedSpeak</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Lora:wght@500;600&family=Inter:wght@400;500;600&family=IBM+Plex+Mono:wght@500;600&display=swap" rel="stylesheet">
<style>
:root{
--paper: #E8EDE6;
--card: #FBFAF6;
--ink: #1B2A4A;
--ink-soft: #4A5A72;
--amber: #C17817;
--amber-deep: #9C5F10;
--clay: #B3492B;
}
*{box-sizing:border-box}
body{
margin:0; padding:32px 16px 60px;
background:var(--paper);
background-image:
radial-gradient(circle at 15% 8%, rgba(193,120,23,0.06), transparent 40%),
radial-gradient(circle at 85% 90%, rgba(27,42,74,0.05), transparent 40%);
font-family:'Inter', sans-serif;
color:var(--ink);
display:flex; justify-content:center;
}
main{width:100%; max-width:560px}
.eyebrow{
font-family:'IBM Plex Mono', monospace;
font-size:12px; letter-spacing:0.12em; text-transform:uppercase;
color:var(--amber-deep); font-weight:600;
}
h1{
font-family:'Lora', serif; font-weight:600;
font-size:clamp(28px, 5vw, 36px);
margin:6px 0 4px; color:var(--ink);
}
.sub{ color:var(--ink-soft); font-size:15px; margin:0 0 28px; max-width:44ch; line-height:1.5}
.rx-mark{
display:inline-flex; align-items:center; justify-content:center;
width:40px; height:40px; border-radius:10px;
background:var(--ink); color:var(--paper);
font-family:'Lora', serif; font-weight:600; font-size:20px;
margin-bottom:14px;
}
.card{
background:var(--card); border-radius:14px; padding:22px;
box-shadow:0 1px 2px rgba(27,42,74,0.06), 0 8px 24px rgba(27,42,74,0.05);
border:1px solid rgba(27,42,74,0.08);
}
label.field-label{
display:block; font-size:13px; font-weight:600; color:var(--ink-soft);
margin-bottom:6px;
}
input[type=text]{
width:100%; padding:12px 14px; font-size:16px;
border-radius:8px; border:1.5px solid rgba(27,42,74,0.18);
font-family:'IBM Plex Mono', monospace; background:#fff; color:var(--ink);
}
input[type=text]:focus{
outline:3px solid rgba(193,120,23,0.35); outline-offset:1px;
border-color:var(--amber);
}
.row{ display:flex; gap:10px; margin-top:12px }
.row input{flex:1}
button{
padding:12px 20px; font-size:15px; font-weight:600;
border:none; border-radius:8px; cursor:pointer;
background:var(--amber); color:#fff;
font-family:'Inter', sans-serif;
transition:background .15s ease;
}
button:hover{ background:var(--amber-deep) }
button:focus-visible{ outline:3px solid var(--ink); outline-offset:2px }
.examples{ font-size:13px; color:var(--ink-soft); margin-top:10px }
.examples code{
font-family:'IBM Plex Mono', monospace; background:rgba(27,42,74,0.06);
padding:2px 6px; border-radius:4px;
}
.label-wrap{ margin-top:26px }
.rx-label{
position:relative;
background:#fff;
border:1.5px solid rgba(27,42,74,0.15);
border-radius:2px;
padding:18px 18px 16px;
font-family:'IBM Plex Mono', monospace;
font-size:15px; line-height:1.55; color:var(--ink);
transform:rotate(-0.6deg);
box-shadow:0 6px 16px rgba(27,42,74,0.10);
}
.rx-label::before{
content:"";
position:absolute; top:-1px; left:8px; right:8px; height:1px;
background-image: repeating-linear-gradient(90deg, rgba(27,42,74,0.35) 0 6px, transparent 6px 12px);
}
.rx-label .tag{
font-family:'Inter', sans-serif; font-weight:600; font-size:11px;
letter-spacing:0.08em; text-transform:uppercase; color:var(--amber-deep);
display:block; margin-bottom:6px;
}
.rx-label.empty{ color:var(--ink-soft); font-style:normal }
@media (prefers-reduced-motion: reduce){
* { transition:none !important }
}
</style>
</head>
<body>
<main>
<div class="rx-mark">Rx</div>
<div class="eyebrow">Prescription shorthand, decoded</div>
<h1>MedSpeak</h1>
<p class="sub">Type the shorthand exactly as written on the label — get back plain instructions anyone can follow.</p>
<div class="card">
<label class="field-label" for="rx">Prescription text</label>
<form action="/api/translate" method="POST">
<div class="row">
<input type="text" id="rx" name="text" placeholder="e.g. 1 tab BID pc" value="__LAST_TEXT__">
<button type="submit">Translate</button>
</div>
</form>
<p class="examples">Try <code>1 tab BID pc</code> · <code>PRN q6h</code> · <code>2 cap TID AC PO</code></p>
<div class="label-wrap">
<div class="rx-label__EMPTY_CLASS__" id="out">
<span class="tag">Instructions</span>
__RESULT_TEXT__
</div>
</div>
</div>
</main>
</body></html>
"""
def render_page(last_text="", result_text=None):
if result_text is None:
empty_class = " empty"
result_text = "Translated text will appear here."
else:
empty_class = ""
def esc(s):
return (s.replace("&", "&").replace("<", "<")
.replace(">", ">").replace('"', """))
page = HTML_PAGE
page = page.replace("__LAST_TEXT__", esc(last_text))
page = page.replace("__EMPTY_CLASS__", empty_class)
page = page.replace("__RESULT_TEXT__", esc(result_text))
return page
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
if self.path == "/":
self._send(200, render_page())
else:
self._send(404, "Not found")
def do_POST(self):
if self.path == "/api/translate":
length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(length).decode()
params = {}
for pair in body.split("&"):
if "=" in pair:
k, v = pair.split("=", 1)
params[unquote_plus(k)] = unquote_plus(v)
text = params.get("text", "")
result = translate(text)
self._send(200, render_page(last_text=text, result_text=result))
else:
self._send(404, "Not found")
def _send(self, code, body, ctype="text/html"):
self.send_response(code)
self.send_header("Content-Type", ctype)
self.end_headers()
self.wfile.write(body.encode())
def log_message(self, *args):
pass
if __name__ == "__main__":
print("MedSpeak running at http://localhost:8000")
ThreadingHTTPServer(("0.0.0.0", 8000), Handler).serve_forever()
6. Trying it out
A few example inputs and what MedSpeak produces:
| Shorthand | Plain English |
|---|---|
1 tab BID pc |
Take 1 tablet twice a day after meals |
PRN q6h |
As needed every 6 hours |
2 cap TID AC PO |
Take 2 capsules three times a day before meals by mouth |
Wrap-up
MedSpeak is intentionally minimal — a couple of dictionaries and a tiny server — but it shows how a small, well-organized lookup table can make dense professional shorthand accessible to everyone. It's easy to extend with more abbreviations, and easy to fork into other domains (legal jargon, tech acronyms, whatever shorthand you're tired of decoding by hand).
Note: MedSpeak translates common abbreviation conventions — it's not medical advice and isn't a substitute for asking your pharmacist or doctor about your actual prescription.
Top comments (1)
I appreciate how you've structured the lookup tables as dictionaries, making it easy to extend the tool by simply adding new abbreviations. The use of flat dictionaries like
FREQUENCY,TIMING,ROUTE, andFORMnot only simplifies the code but also improves readability. I'm curious, have you considered implementing any validation or error handling for cases where the input shorthand is invalid or doesn't match any of the defined abbreviations, perhaps by providing a list of closest matches or suggestions?