<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: haricharanparna</title>
    <description>The latest articles on DEV Community by haricharanparna (@haricharanparna).</description>
    <link>https://dev.to/haricharanparna</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4245353%2F9109223d-ae6c-4a62-a013-5fff9fd6785c.png</url>
      <title>DEV Community: haricharanparna</title>
      <link>https://dev.to/haricharanparna</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/haricharanparna"/>
    <language>en</language>
    <item>
      <title>DoseVoice: I built an AI that reads pill bottles out loud (and knows when to say 'turn the bottle')</title>
      <dc:creator>haricharanparna</dc:creator>
      <pubDate>Sun, 15 Nov 2026 04:02:20 +0000</pubDate>
      <link>https://dev.to/haricharanparna/dosevoice-i-built-an-ai-that-reads-pill-bottles-out-loud-and-knows-when-to-say-turn-the-bottle-kp</link>
      <guid>https://dev.to/haricharanparna/dosevoice-i-built-an-ai-that-reads-pill-bottles-out-loud-and-knows-when-to-say-turn-the-bottle-kp</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/mlh-hackathon"&gt;MLH x DEV Writing Challenge&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Built in 36 hours at HackUMass XIV.&lt;/em&gt;&lt;br&gt;
&lt;strong&gt;Try it:&lt;/strong&gt; &lt;a href="https://dosevoice.vercel.app" rel="noopener noreferrer"&gt;dosevoice.vercel.app&lt;/a&gt; · &lt;strong&gt;Code:&lt;/strong&gt; &lt;a href="https://github.com/haricharanparna/dosevoice" rel="noopener noreferrer"&gt;github.com/haricharanparna/dosevoice&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;Demo Video&lt;/strong&gt; &lt;a href="https://youtu.be/KVYxPievtLg" rel="noopener noreferrer"&gt;https://youtu.be/KVYxPievtLg&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;I built DoseVoice because of my grandma. She was often squinting at her medication bottles and containers, struggling to read the labels, and she often didn't know how much of a medicine to take. That's very dangerous: it can lead to an overdose, or to taking far too little for the medicine to work.&lt;/p&gt;

&lt;p&gt;Prescription labels are some of the smallest, most important text people read. The people who take the most medications, older adults and people with low vision, also have the hardest time reading them. A misread label or a forgotten dose can mean a hospital visit.&lt;/p&gt;

&lt;p&gt;So we built &lt;strong&gt;DoseVoice&lt;/strong&gt;: point your camera at a pill bottle, hear exactly what it says, and get spoken reminders so you never miss a dose.&lt;/p&gt;
&lt;h2&gt;
  
  
  What it does
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Scan:&lt;/strong&gt; a live camera opens right in the browser. Gemini reads the label, and DoseVoice speaks it in plain language ("500 milligrams", not "500 mg").&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Turn the bottle":&lt;/strong&gt; if the photo only shows the name, DoseVoice says so out loud and asks for the directions side, then reads both photos together.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reminders that talk:&lt;/strong&gt; reminder times come straight from the label ("twice daily" becomes 9 AM and 9 PM). At dose time it speaks the reminder and sends a notification with &lt;strong&gt;I took it&lt;/strong&gt; and &lt;strong&gt;Snooze&lt;/strong&gt; buttons.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Safety check:&lt;/strong&gt; Gemini looks across all your medications for documented interactions. In our demo it flagged &lt;strong&gt;warfarin + ibuprofen&lt;/strong&gt; as a major bleeding risk and turned it into a question for your pharmacist.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Built for low vision:&lt;/strong&gt; three text sizes, high contrast, dark mode, adjustable speaking speed, and spoken feedback for every action.&lt;/li&gt;
&lt;/ul&gt;


&lt;h2&gt;
  
  
  The stack
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Piece&lt;/th&gt;
&lt;th&gt;What we used&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Frontend&lt;/td&gt;
&lt;td&gt;Next.js 15 (App Router), React 19, TypeScript, a custom CSS design system&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AI&lt;/td&gt;
&lt;td&gt;Google Gemini API (multimodal vision + structured output)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Database&lt;/td&gt;
&lt;td&gt;MongoDB Atlas (free M0 cluster)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Browser APIs&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;getUserMedia&lt;/code&gt; (camera), Web Speech API (voice), service worker (notifications)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hosting&lt;/td&gt;
&lt;td&gt;Vercel (free Hobby plan)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AI pair programmer&lt;/td&gt;
&lt;td&gt;Claude (Anthropic) helped debug and refine the code; I directed the features, made the code, and tested/made the product decisions&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Total cost: &lt;strong&gt;$0&lt;/strong&gt;. Everything runs on free tiers.&lt;/p&gt;
&lt;h2&gt;
  
  
  The interesting part: making an LLM trustworthy enough for medication
&lt;/h2&gt;

&lt;p&gt;Free-text answers from an LLM are a non-starter when the subject is dosing. So every Gemini call uses a &lt;strong&gt;strict JSON response schema&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="no"&gt;SCHEMA&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"OBJECT"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;properties&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;isMedicationLabel&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"BOOLEAN"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;drugName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"STRING"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;strength&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"STRING"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;instructions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"STRING"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;suggestedTimes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"ARRAY"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"STRING"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;warnings&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"ARRAY"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"STRING"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;plainSummary&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"STRING"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;missing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"ARRAY"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"STRING"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kd"&gt;enum&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"strength"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"directions"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;retakeHint&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"STRING"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;confidence&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"STRING"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kd"&gt;enum&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"high"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"medium"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"low"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;required&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="cm"&gt;/* all of the above */&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two fields do the heavy lifting:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;confidence&lt;/code&gt;&lt;/strong&gt;: when it's &lt;code&gt;low&lt;/code&gt;, DoseVoice says so out loud and tells you to confirm with your pharmacist instead of pretending.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;missing&lt;/code&gt;&lt;/strong&gt;: this is what powers the "turn the bottle" feature. Pill bottles are round, so one photo usually shows half the label. Rather than guessing directions, Gemini reports what it &lt;em&gt;couldn't&lt;/em&gt; see, and the app asks for another photo.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I also don't fully trust the model to flag its own gaps, so the server double-checks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// If Gemini left directions blank but didn't flag it, flag it ourselves.&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;scan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;isMedicationLabel&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;scan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;instructions&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;scan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;missing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"directions"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;scan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;missing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"directions"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Reading two sides of a bottle at once
&lt;/h2&gt;

&lt;p&gt;Gemini accepts multiple images in one request, so "turn the bottle" is just one more photo appended to the same call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;parts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;images&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;img&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mimeType&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;img&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;/^&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;image&lt;/span&gt;\&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;\&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="n"&gt;base64&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;$&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;inline_data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;mime_type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;mimeType&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;geminiJSON&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Scan&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="no"&gt;PROMPT&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;SCHEMA&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The prompt tells Gemini the photos may be &lt;em&gt;different sides of the same bottle&lt;/em&gt; and to combine them, so the second scan returns one complete result (name from photo 1, directions from photo 2).&lt;/p&gt;

&lt;h2&gt;
  
  
  Things that broke (and how I fixed them)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. The model I started with disappeared.&lt;/strong&gt; Mid-hackathon, &lt;code&gt;gemini-2.5-flash&lt;/code&gt; started returning 404 for our new API key ("no longer available to new users"). I switched to &lt;code&gt;gemini-3.5-flash&lt;/code&gt; and added an ordered fallback list, so a retired model, a timeout or a 5xx moves on to the next model automatically.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Free-tier rate limits.&lt;/strong&gt; Scanning two photos and then running the safety check in quick succession hit the per-minute limit. Since each model has its &lt;em&gt;own&lt;/em&gt; free-tier quota, a 429 now falls through to the backup model instead of showing an error:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;429&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;lastError&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;GeminiError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Gemini's free-tier rate limit was hit. Wait a minute and try again."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;429&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// try the next model; each has its own quota&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;3. "Loading…" forever on Vercel.&lt;/strong&gt; Locally everything worked; deployed, the app hung. The cause was MongoDB Atlas's IP Access List, which only allowed my laptop. Two lessons: allow &lt;code&gt;0.0.0.0/0&lt;/code&gt; for serverless hosts, and &lt;strong&gt;don't cache a failed connection promise&lt;/strong&gt;. Ours was memoized, so even after fixing Atlas the warm function stayed broken:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="n"&gt;clientPromise&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;MongoClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;uri&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;serverSelectionTimeoutMS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;8000&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;clientPromise&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// retry next request&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;4. "Take a photo" opened the file picker.&lt;/strong&gt; On desktop, &lt;code&gt;&amp;lt;input capture&amp;gt;&lt;/code&gt; is ignored. I replaced it with a real in-page camera using &lt;code&gt;getUserMedia&lt;/code&gt;, a live &lt;code&gt;&amp;lt;video&amp;gt;&lt;/code&gt; preview and a canvas capture, falling back to the file picker only when no camera is available.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Notifications silently failed on Android.&lt;/strong&gt; &lt;code&gt;new Notification()&lt;/code&gt; throws on Android Chrome. Routing reminders through a service worker's &lt;code&gt;showNotification()&lt;/code&gt; fixed it, and added action buttons, so "I took it" works straight from the notification.&lt;/p&gt;

&lt;h2&gt;
  
  
  Designing for people who can't see the screen
&lt;/h2&gt;

&lt;p&gt;Accessibility was a constraint from the start, not a feature added later:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Every action (scan, save, take, skip, snooze) has &lt;strong&gt;spoken&lt;/strong&gt; feedback, not just a visual toast.&lt;/li&gt;
&lt;li&gt;The tab list is keyboard-navigable, results are announced through &lt;code&gt;aria-live&lt;/code&gt; regions, and the reminder is a proper &lt;code&gt;alertdialog&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Text size, contrast and theme are user settings applied with &lt;code&gt;data-*&lt;/code&gt; attributes on &lt;code&gt;&amp;lt;html&amp;gt;&lt;/code&gt;, so the whole design system scales together.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Building for low vision made the app better for everyone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Safety boundaries
&lt;/h2&gt;

&lt;p&gt;DoseVoice helps people &lt;strong&gt;read and remember&lt;/strong&gt;. It is deliberately not a doctor:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It never invents directions. If they're missing, it asks for another photo.&lt;/li&gt;
&lt;li&gt;The interaction check only reports documented interactions, and frames each one as a &lt;strong&gt;question for your pharmacist&lt;/strong&gt;, never "stop taking X".&lt;/li&gt;
&lt;li&gt;Every screen says it's not medical advice.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Pill identification by shape and color, to catch mix-ups&lt;/li&gt;
&lt;li&gt;Caregiver alerts when a dose is missed&lt;/li&gt;
&lt;li&gt;Refill reminders from the "refills remaining" field&lt;/li&gt;
&lt;li&gt;Multilingual speech&lt;/li&gt;
&lt;li&gt;A full PWA with push reminders even when the browser is closed&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I learned
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Structured output is what makes an LLM usable&lt;/strong&gt; in high-stakes apps. The schema matters more than the prompt.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Plan for the model to change under you.&lt;/strong&gt; Fallbacks and good errors paid off within hours.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Browser APIs are underrated.&lt;/strong&gt; Camera, speech, and service workers gave us an app-like experience with no app store.&lt;/li&gt;
&lt;li&gt;Learned how to deploy an app for free on Vercel and used it to make the app accessible to everyone.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;Thanks to the HackUMass XIV organizers and MLH. If you or someone you know struggles with medication labels, give &lt;a href="https://dosevoice.vercel.app" rel="noopener noreferrer"&gt;DoseVoice&lt;/a&gt; a try. Feedback welcome!&lt;/p&gt;

</description>
      <category>mlhacks</category>
      <category>devchallenge</category>
      <category>hackathon</category>
      <category>ai</category>
    </item>
  </channel>
</rss>
