DEV Community

Niya Singh
Niya Singh

Posted on

Hindsight Remembers Everything. My Job Was Making Patients See It.

Our chatbot could remember three chemo cycles of a patient's life. On screen, it looked exactly like every chatbot that remembers nothing.
That was the problem I had to solve. I built the interface for OnKo, a cancer-care assistant whose best feature, long-term memory, is completely invisible unless the UI shows it. This is how I designed two Streamlit dashboards around Hindsight memory, and what I learned fighting Streamlit's styling along the way.

What OnKo is
OnKo is built by our three-person team, Chai++. A doctor enters a care plan, the patient logs their day, and a chatbot answers from everything that has been recorded. Every patient has their own Hindsight memory bank.
Samprada built the memory layer (memory.py, chat.py, the schedule and database). Shreyan built care-plan and PDF extraction, the test patient and a safety evaluation. I built everything you click: the role picker, the doctor/nurse workspace, the patient workspace and the shared design system in ui_common.py.

The stack is Streamlit, Python, Hindsight Cloud for memory, Groq for extraction, and SQLite for app state.

The design problem: memory you can't see
A normal app shows you what it stored. A memory agent doesn't. The patient types a note, it disappears into a memory bank, and weeks later it might shape an answer. Unless the UI says so, nobody knows it happened.
For a cancer patient, that matters. If you can't tell whether the app remembered your nausea, you'll tell it again, or stop trusting it. If a doctor can't see what gets saved, they won't let it near their patients.
So I set three rules for the interface:
1.        Show the moment something enters memory. Every save should feel like a deliberate act, not a side effect.
2.        Put a gate in front of clinical data. Nothing from the care team goes into memory until a human approves it.
3.        Keep the memory in view. The chat should look like it's talking about this patient.
I started from a design I mocked up in Google Stitch: a calm green palette, Manrope for text, JetBrains Mono for small "source" labels, and a two-column layout with the daily work on the left and the memory on the right. Then I had to make Streamlit look like it.
The doctor side: a gate in front of memory
The care-plan box takes plain text, the way doctors already write:
Capecitabine 1500mg twice daily after food, days 1–14. CBC on 3 Oct. Cycle 5 on 10 Oct.
"Structure Care Plan" sends it to Shreyan's extractor, but the result doesn't go anywhere yet. It lands in an editable table under a note that says "Review required · Nothing enters patient memory until you approve it." The doctor can fix a dose, change a type from a dropdown, or delete a row. Only the Approve button saves:

if st.button("◉ Approve & Add to Care Memory", type="primary", use_container_width=True):
    approved = [
        CarePlanItem(
            kind=ItemKind(r["kind"]), name=str(r["name"]), dose=str(r.get("dose") or ""),
            timings=[t.strip() for t in str(r.get("timings") or "").split(",") if t.strip() in TIME_SLOTS],
            start_date=str(r.get("start_date") or ""), end_date=str(r.get("end_date") or ""),
            notes=str(r.get("notes") or ""),
        ) for r in edited.to_dict("records") if str(r.get("name") or "").strip()
    ]
    db.add_plan_items(patient.id, approved)
    ...
    memory.save_entry(Entry(patient.id, f"Doctor-approved care plan items:\n{summary}", role, EntryType.CARE_PLAN))
Enter fullscreen mode Exit fullscreen mode

Two things happen on approval. The structured rows go to SQLite, where they become the patient's daily checklist. A readable summary goes to Hindsight as a CARE_PLAN entry, recorded by the doctor or nurse who approved it. Timings are filtered against the allowed slots, so a typo in the table can't create a checklist row at "mornng".
PDF reports work the same way: read, show the extracted values in an editable table, and save only on "Confirm & Save to Memory". The button labels say "memory" on purpose. The doctor should always know which click writes to the patient's record.
On the right sits "Since Last Visit". One button calls Samprada's summary, which reflects over the patient's bank and writes a handover for the doctor. After reading it, the doctor clicks "Mark as reviewed", and the next summary starts from that moment.

The patient side: one form, four memories
The patient's daily log is one form: today's checklist from the doctor's plan, three mood cards, symptom chips, a detail line, a diary box, and an "after your appointment" box that only shows up on days with an appointment or treatment.
It would have been easy to save the whole form as one blob. Instead, the submit handler splits it into separate entries by type:

if checklist:
    lines = "\n".join(f"- {c.label}: {c.status.value}" for c in checklist)
    memory.save_entry(Entry(patient.id, f"Daily check-in for {today}:\n{lines}", role, EntryType.CHECKLIST))
if mood or symptoms or symptom_note:
    memory.save_entry(Entry(patient.id, f"Mood: {mood or 'not given'}. Symptoms: ...", role, EntryType.SYMPTOM))
if log.diary:
    memory.save_entry(Entry(patient.id, log.diary, role, EntryType.DIARY))
if log.visit_note:
    memory.save_entry(Entry(patient.id, f"Patient's note after appointment: {log.visit_note}", role, EntryType.VISIT_NOTE))
Enter fullscreen mode Exit fullscreen mode

Empty parts aren't saved at all, so a patient who only ticks the checklist doesn't fill the bank with "Symptoms: none". Each entry has a clear type, which gives Hindsight cleaner facts to connect. That's how "nausea" in a symptom entry ends up linked to an infusion in the care plan.
After submitting, the form locks and becomes a read-only summary: "✓ Today's log is complete", the saved statuses, and the diary under an "Added to care memory" label. That label is rule one in action. The patient sees exactly what OnKo will remember.
Fighting Streamlit for the design
Streamlit is great for building fast and hard to make look like a specific design. Most of my commits were about that gap, and a few problems are worth sharing.
Radio groups that styled each other. Both the checklist statuses (Done / Missed / Late) and the mood cards are st.radio widgets. My first CSS targeted radio groups in general, so styling the mood cards also restyled the medicine statuses. The fix was Streamlit's key parameter. Every widget with a key gets a matching st-key- class, which you can use as a CSS scope:

[class*="st-key-ci_"] [role="radiogroup"] label:nth-child(1):has(input:checked){background:var(--secondary-container)!important}
[class*="st-key-ci_"] [role="radiogroup"] label:nth-child(2):has(input:checked){background:var(--error-bg)!important}
[class*="st-key-ci_"] [role="radiogroup"] label:nth-child(3):has(input:checked){background:var(--late-bg)!important}
Enter fullscreen mode Exit fullscreen mode

Checklist radios have keys ci_0, ci_1 and so on, and the mood radio has mood_selector, so each gets its own rules. It also meant the colors could carry meaning: Done is green, Missed is red, Late is amber, on every row. A patient scanning the list sees a missed dose without reading.
Unreadable symptom chips. st.pills picked up dark default styles from BaseWeb, the component library under Streamlit, and the chip text became hard to read. The fix was to target every element BaseWeb might render (buttons, options, role buttons) and their children, and force the colors, including -webkit-text-fill-color, which some browsers use in place of color. It isn't elegant, but patients on chemo often have tired eyes, so readable chips weren't optional.
The 740-pixel chat rail. The Stitch design had a tall chat panel on the right, so I gave the container height=740. That clipped the chat's contents. Switching overflow:hidden to visible with forced minimum heights fixed the clipping but left a large empty panel when the chat was short. In the end I removed the forced height and let the panel size to its content. It was three commits to learn one lesson: don't pin heights in a framework that controls its own layout.
Keeping the memory in view
The chat panel is where the memory has to be most visible, so it has signals the design system provides:
a header showing how many days of care history the bank holds ("● 28 days indexed");
a "Using your care memory" note explaining that OnKo recalls the approved plan and past check-ins;
"Try asking" buttons with memory questions ("What did I report last week?"), so a new user's first question shows off memory instead of general knowledge;
a small monospace "CARE MEMORY · patient-specific sources" chip and a line saying the care team makes medical decisions.
Shreyan later built on this panel with a "Memory behind this answer" expander. It shows the retain calls a message triggered, the memories reflect used (labelled as fact, past chat or learned pattern) and the safety rules applied. He also added a "What OnKo has learned about you" section listing patterns from Hindsight's observations. Because the layout already had a place for memory, those features fit without a redesign.
Before: a plain st.chat_input under the form. The bot might answer from three cycles of history, but nothing on screen suggests it knows who you are.
After: the same question inside the "Ask OnKo" panel, with the indexed-days badge, the memory note, a cited answer, and the memory expander open underneath showing which recorded facts the answer used.
Lessons

  1. In a memory product, the UI is where trust lives. Hindsight did the remembering. The interface decided whether anyone believed it. Label every save, and show what an answer was based on.
  2. Put a human gate between AI extraction and memory. A review table plus one clearly named "Approve & Add to Care Memory" button did more for trust than any disclaimer. Once something is in agent memory, it can come back weeks later, so the gate belongs at the door.
  3. Save by meaning, not by form. One form became up to four typed entries, and empty sections were skipped. That kept the memory bank clean and gave Hindsight better material to connect.
  4. In Streamlit, scope your CSS with key. The st-key-* classes are the most reliable handle for styling one widget without breaking another. Selectors based on internal structure are fragile.
  5. Let color carry meaning, and keep it consistent. Green, red and amber mean the same thing on every row, and the mood cards stay out of that system. Building the front end of a memory agent turned out to be less about making screens and more about showing what the system is doing. If you're building on Hindsight, think about how your users will see the memory before you polish anything else.

Top comments (0)

Some comments may only be visible to logged-in visitors. Sign in to view all comments.