<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0" xmlns:cc="http://cyber.law.harvard.edu/rss/creativeCommonsRssModule.html">
    <channel>
        <title><![CDATA[Stories by Levi Stringer on Medium]]></title>
        <description><![CDATA[Stories by Levi Stringer on Medium]]></description>
        <link>https://medium.com/@levi_stringer?source=rss-c6cd7b83742b------2</link>
        <image>
            <url>https://cdn-images-1.medium.com/fit/c/150/150/0*R5Cc6wsg3lSg4V2A</url>
            <title>Stories by Levi Stringer on Medium</title>
            <link>https://medium.com/@levi_stringer?source=rss-c6cd7b83742b------2</link>
        </image>
        <generator>Medium</generator>
        <lastBuildDate>Tue, 26 May 2026 22:36:54 GMT</lastBuildDate>
        <atom:link href="https://medium.com/@levi_stringer/feed" rel="self" type="application/rss+xml"/>
        <webMaster><![CDATA[yourfriends@medium.com]]></webMaster>
        <atom:link href="http://medium.superfeedr.com" rel="hub"/>
        <item>
            <title><![CDATA[The Invisible Denominator: On Measuring What Language Models Actually Cost]]></title>
            <link>https://medium.com/@levi_stringer/the-invisible-denominator-on-measuring-what-language-models-actually-cost-28c88918baea?source=rss-c6cd7b83742b------2</link>
            <guid isPermaLink="false">https://medium.com/p/28c88918baea</guid>
            <category><![CDATA[token]]></category>
            <category><![CDATA[artificial-intelligence]]></category>
            <category><![CDATA[llm]]></category>
            <dc:creator><![CDATA[Levi Stringer]]></dc:creator>
            <pubDate>Mon, 15 Dec 2025 15:31:08 GMT</pubDate>
            <atom:updated>2025-12-15T15:31:08.427Z</atom:updated>
            <content:encoded><![CDATA[<p>The engineer showed me her terminal. Fourteen microservices, each making between two and two hundred LLM calls per user session. “We have no idea what anything costs,” she said. “We just get a bill at the end of the month.”</p><p>This is the state of the art.</p><p>In 1854, John Snow mapped cholera deaths in London. He did not theorize about miasma or debate the merits of various humoral imbalances. He counted. He located. He drew. The resulting map, which I first saw in Edward Tufte’s <a href="https://www.amazon.ca/Visual-Display-Quantitative-Information/dp/1930824130">The Visual Display of Quantitative Information</a> shows dots clustered around the Broad Street pump, demonstrated what argument could not. The pump handle was removed. The epidemic ended.</p><p>The lesson is not about cholera. The lesson is about the relationship between measurement and action. You cannot optimize what you cannot see. You cannot debug what you cannot trace. You cannot manage what you do not count.</p><p>Modern software teams have, for the most part, internalized this. We instrument our services. We trace our requests. We know, to the millisecond, how long each database query takes. We can tell you which endpoint is slow, which user is hammering the API, which deploy introduced the regression.</p><p>Then we call an LLM, and all of that rigor evaporates.</p><p>Consider the anatomy of a language model API call. You send tokens. You receive tokens. You are charged. The provider tells you how many tokens you consumed. But this information arrives detached from context, a usage object appended to a response, logged nowhere, correlated with nothing.</p><p>The aggregate bill arrives weeks later. By then, the code that generated the costs has been modified seventeen times. The feature that caused the spike shipped three sprints ago. The engineer who wrote the recursive summarization loop has moved to a different team.</p><p>This is not a monitoring problem. It is an <em>attribution</em> problem. The data exists; it simply isn’t connected to anything useful.</p><p>Let us be concrete. Here is a table of prices, current as of December 2025:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*OzeFo8TKRsYOgaq37hnEhg.png" /></figure><p>The market has shifted dramatically in 2025. GPT-5 now costs less than GPT-4o did at launch. Claude Opus 4.5 is cheaper than Claude 3 Opus despite being far more capable, a 66% price reduction at the flagship tier. Google’s Gemini 2.0 Flash delivers strong performance at a tenth of a dollar per million input tokens.</p><p>Yet the ratio between cheapest and most expensive still spans two orders of magnitude. A task that costs a fraction of a cent on Gemini Flash might cost twenty-five cents on Claude Opus 4.5. Same input, same output structure, same business logic, different model selection.</p><p>Most teams default to a single model for everything. They choose based on capability benchmarks or, more often, based on what the first engineer to touch the codebase happened to use. Then they discover, potentially months later, in a costs meeting, that 80% of their calls were classification tasks that any model could handle.</p><p>The information needed to make better decisions existed in every API response. It was simply never recorded.</p><p>There is a pattern here that extends beyond language models. Organizations consistently fail to measure costs at the point of decision. They measure in aggregate, at the end of the accounting period, when the money has already been spent and the code has already been written.</p><p>This is backwards. Cost information is most valuable <em>before</em> the decision, not after. The engineer choosing between GPT-4o and GPT-4o-mini needs to know, at that moment, what each option costs for this particular task. Of course if the model is capable of performing the task as well. The product manager prioritizing features needs to understand the marginal infrastructure cost of each option. The architect designing a new pipeline needs visibility into which stages dominate the budget.</p><p>None of this requires sophisticated tooling. It requires only that someone write down the numbers.</p><p>I built a small library to do exactly this. It is perhaps 500 lines of Python. It stores data in SQLite , a single file, no infrastructure, no external dependencies. Each API call is logged with its token counts, its calculated cost, and whatever attribution metadata you care to attach: feature name, user ID, session, experiment cohort.</p><p>The implementation is trivial:</p><pre>from llm_costs import track</pre><pre>track(&quot;gpt-4o&quot;, input_tokens=500, output_tokens=100, feature=&quot;chat&quot;)</pre><p>One line. The token counts come from the API response you already receive. The feature name comes from your own code. The cost calculation is arithmetic. The storage is append-only writes to a local database.</p><p>The value is not in the code. The value is in the data it produces.</p><p>The output token asymmetry deserves particular attention. Across all major providers, generating tokens costs substantially more than consuming them. This reflects the underlying economics: input processing is parallelizable, output generation is sequential.</p><p>The implication is that verbose responses are expensive responses. A model that returns a 500-word explanation costs five times more than one that returns a 100-word summary. Yet most prompts do not specify length. They ask for “analysis” or “explanation” without constraint, then accept whatever verbosity the model provides.</p><p>Structured outputs — JSON rather than prose — typically reduce output tokens by 60–80%. The information content is identical. The cost is not.</p><pre>Verbose: &quot;Based on my analysis of the customer feedback provided, <br>I believe the overall sentiment expressed is negative, primarily <br>due to concerns about shipping delays and customer service <br>responsiveness...&quot;  (47 tokens, continuing for many more)</pre><pre>Structured: {&quot;sentiment&quot;: &quot;negative&quot;, &quot;issues&quot;: [&quot;shipping&quot;, &quot;support&quot;]}<br>(15 tokens, complete)</pre><p>This is not optimization. This is basic hygiene. But it requires visibility into the data to recognize the opportunity.</p><p>The deeper problem is organizational. Engineering teams are evaluated on velocity and reliability. Cost efficiency is someone else’s concern, reviewed quarterly, in a different meeting, by different people. The feedback loop between “code that spends money” and “money that was spent” is measured in months.</p><p>Contrast this with performance optimization. When a page loads slowly, the engineer sees it immediately. The feedback is visceral, instantaneous. Slow code <em>feels</em> slow.</p><p>Expensive code feels like nothing at all. It executes in milliseconds. The response is correct. The user is satisfied. The expense is invisible until it becomes someone else’s problem.</p><p>Closing this feedback loop requires making cost visible at the point of creation. Not in a dashboard three clicks away. Not in a monthly report. In the logs, in the terminal, in the development environment where decisions are actually made.</p><p>The December 2025 pricing landscape offers more levers than ever. All major providers now offer batch processing at 50% discounts for non-urgent workloads. Prompt caching delivers 50–90% savings on repeated context — a system prompt sent with every request can be cached once and reused thousands of times. The cheapest capable models have dropped below $0.10 per million input tokens: Gemini 2.0 Flash, Cohere Command R7B, Groq’s hosted Llama variants.</p><p>But these savings require knowing where your tokens go. A team using prompt caching on 30% of their calls saves nothing if the other 70% — the expensive 70% — remain uninstrumented and unexamined.</p><p>The tooling implications are straightforward. Every LLM call should be logged with:</p><ol><li>The model used</li><li>Input and output token counts</li><li>Calculated cost</li><li>Attribution metadata (feature, user, session)</li><li>Timestamp</li></ol><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*C2tKqecIeUsrqGzo6DXu4g.png" /></figure><p>This is perhaps fifty lines of wrapper code around any SDK. The data should be queryable by dimension: cost by model, by feature, by user, by day. The queries are simple aggregations. The storage can be SQLite for small deployments, anything with SQL semantics for larger ones.</p><p>The organizational implications are harder. Someone must look at the data. Someone must have authority to act on what they find. Someone must care.</p><p>In my experience, the most effective intervention is not a dashboard or an alert. It is a weekly email to the engineering team: “Here is what we spent on LLMs this week. Here is the breakdown by feature. Here is the breakdown by model.” No commentary. No judgment. Just numbers.</p><p>Engineers, given data, will optimize. They do not need to be told. They need to be informed.</p><p>There is a final point worth making about the nature of these costs. Unlike traditional infrastructure — servers, databases, bandwidth — LLM costs scale with <em>usage</em>, not capacity. You do not pay for what you provision. You pay for what you use.</p><p>This is both blessing and curse. The blessing: no idle capacity, no overprovisioning, perfect elasticity. The curse: no ceiling, no budget you can set and forget, no way to guarantee costs will not exceed some threshold.</p><p>The only defense is measurement. Continuous, granular, attributed measurement. Not because measurement alone solves the problem, but because measurement is the prerequisite to solving anything.</p><p>John Snow did not cure cholera. He identified the pump. The identification was the contribution. Everything else followed from seeing clearly what had previously been obscured.</p><p>The pump handle, in this case, is the API call. Remove the handle — which is to say, instrument the call — and the path forward becomes visible.</p><p><em>The code discussed in this post is available at </em><a href="https://github.com/levi-katarok/llm-costs"><em>https://github.com/levi-katarok/llm-costs</em></a><em>. It is MIT licensed, dependency-free, and perhaps useful</em></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=28c88918baea" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Simplifying RAG Context Windows with Conversation Buffers — How to Stop Your Agent Forgetting…]]></title>
            <link>https://medium.com/@levi_stringer/simplifying-rag-context-windows-with-conversation-buffers-how-to-stop-your-agent-forgetting-df2149ad7403?source=rss-c6cd7b83742b------2</link>
            <guid isPermaLink="false">https://medium.com/p/df2149ad7403</guid>
            <dc:creator><![CDATA[Levi Stringer]]></dc:creator>
            <pubDate>Mon, 08 Dec 2025 00:07:00 GMT</pubDate>
            <atom:updated>2025-12-11T15:31:14.613Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Jeb3vf117WbqOyOkzpsv9g.png" /></figure><h3>Simplifying RAG Context Windows — How to Stop Your Agent Forgetting</h3><p>Building a RAG chatbot that works in demo is straightforward. Building one that still works after twenty messages is a whole different story. <a href="https://github.com/levi-katarok/rag-context-window">Github Link </a>:</p><p>This post covers four independent solutions:</p><ul><li><strong>conversation buffers</strong></li><li><strong>document reordering</strong></li><li><strong>semantic caching</strong></li><li><strong>multi-agent orchestration.</strong></li></ul><p>These solutions address what I’ve heard been called the <strong>RAG memory wall</strong>, and the math is unforgiving.</p><ul><li>Ten message exchanges at 300 tokens each gives you 3,000 tokens of history.</li><li>Add five retrieved chunks of documents at 500 tokens each for another 2,500.</li><li>Include your system prompt for 1,000 more.</li></ul><p>You’re at 6,500 tokens before the model generates a single word. You’re hemorrhaging money before the conversation even starts!</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/962/1*p0FnE0ie7Z7JnD1E3mP-Ig.png" /><figcaption>Prompt Context Overload</figcaption></figure><p>You may be thinking who cares? We’ve got models with context windows so large that we could put all of Homer’s The Odyssey and still have hundreds of thousand of tokens to spare. But not without its own problems, LLMs exhibit what researchers call U-shaped attention. Model performance is often highest when relevant information occurs at the beginning or end of the input context, and significantly degrades when models must access information in the middle, even for models explicitly designed for long contexts. More context means more middle, more places for your carefully retrieved documents to get ignored.</p><p>Companies running RAG at scale have figured this out.</p><ul><li><a href="https://careersatdoordash.com/blog/large-language-modules-based-dasher-support-automation/">DoorDash combined smart context management with guardrails and saw 90% fewer hallucinations</a>.</li><li>LinkedIn added knowledge graphs to their support ticket system and cut resolution times by 28%.</li></ul><p>That’s not incremental improvement, that’s a different product!</p><p>The patterns they use aren’t complicated — they just require thinking about context as a resource to manage rather than a bucket to fill.</p><p>Throughout this post we’ll walk through <strong>four independent solutions</strong> for managing RAG context windows. Each one addresses a different aspect of the problem, and you can implement any of them on their own or combine them based on your needs.</p><p><strong>Solution 1: C</strong>ompresses conversation history while preserving critical facts.</p><p><strong>Solution 2</strong>: Reorders documents to combat attention degradation.</p><p><strong>Solution 3:</strong> Adds semantic caching to avoid redundant work.</p><p><strong>Solution 4: </strong>Distributes complex queries across multiple focused agents. Start with whichever one addresses your most pressing problem.</p><h3><strong>Setup and Requirements</strong></h3><p><strong>Node.js Environment</strong>: We’ll be using TypeScript throughout. Make sure you have Node.js 18+ installed. I recommend using a package manager like pnpm, but npm works fine.</p><p><strong>API Keys</strong>: You’ll need an OpenAI API key (for embeddings and model). Store these in environment variables — never hardcode them.</p><pre><br><br>export OPENAI_API_KEY=&#39;your_key_here&#39;<br><br>npm install openai</pre><h3><strong>Solution 1: Conversation Summary Buffer</strong></h3><blockquote><strong>Problem</strong>: Your context window fills up as conversations get longer, forcing you to either <strong>truncate history</strong> (losing important early context) or <strong>pay for ever-larger </strong>context windows.</blockquote><p>The core problem with long conversations is simple: you can’t keep everything. Sliding window approaches that keep the last N messages lose important early context. A user mentions they’re in Canada in message two, asks about shipping in message twelve — but your window discarded the location. Your chatbot just developed amnesia mid-conversation.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*YTcWcM5uZdl7g5gY_EnKLQ.png" /><figcaption>Conversation Summary Buffer</figcaption></figure><p>The solution is to maintain recent messages verbatim while summarizing older content, with explicit entity tracking for facts that must never be lost. This approach draws from <a href="https://arxiv.org/abs/2308.15022">Recursively Summarizing Enables Long-Term Dialogue Memory in Large Language Models</a> which demonstrated that recursively generating summaries to compress older dialogue while preserving key information significantly improves response consistency in long conversations.</p><p>Think of it like how you’d summarize a long meeting: you’d compress the general discussion into key points, but you’d make sure specific action items and names don’t get lost in the summary.</p><pre>interface Message { role: &quot;user&quot; | &quot;assistant&quot;; content: string }<br><br>class ConversationSummaryBuffer {<br>  private client = new OpenAI();<br>  private messages: Message[] = [];<br>  private summary = &quot;&quot;;<br>  private entities = {<br>    ids: new Set&lt;string&gt;(),<br>    locations: new Set&lt;string&gt;(),<br>    products: new Set&lt;string&gt;()<br>  };<br><br>  constructor(private maxRecent = 6) {}<br><br>  async addMessage(role: &quot;user&quot; | &quot;assistant&quot;, content: string) {<br>    this.messages.push({ role, content });<br><br>    // Extract entities before any compression<br>    content.match(/(?:account|order|ticket)[\s#:]*(\d{6,})/gi)?.forEach(id =&gt; {<br>      const num = id.match(/\d{6,}/)?.[0];<br>      if (num) this.entities.ids.add(num);<br>    });<br>    content.match(/(?:in|from|shipping to)\s+([A-Z][a-z]+(?:\s+[A-Z][a-z]+)?)/g)?.forEach(loc =&gt; {<br>      this.entities.locations.add(loc.replace(/^(in|from|shipping to)\s+/i, &quot;&quot;));<br>    });<br>    content.match(/\b(Pro|Enterprise|Basic|Premium)\b/gi)?.forEach(p =&gt;<br>      this.entities.products.add(p.toLowerCase())<br>    );<br><br>    if (this.messages.length &gt; this.maxRecent) await this.compress();<br>  }<br><br>  private async compress() {<br>    const old = this.messages.slice(0, -this.maxRecent);<br>    this.messages = this.messages.slice(-this.maxRecent);<br>    const text = old.map(m =&gt; `${m.role}: ${m.content}`).join(&quot;\n&quot;);<br><br>    const res = await this.client.chat.completions.create({<br>      model: &quot;gpt-4o-mini&quot;,<br>      max_tokens: 500,<br>      messages: [{<br>        role: &quot;user&quot;,<br>        content: `Summarize concisely:\n${text}\nPrevious: ${this.summary || &quot;None&quot;}`<br>      }],<br>    });<br>    this.summary = res.choices[0].message.content || &quot;&quot;;<br>  }<br><br>  buildContext(docs: string[]): Message[] {<br>    const parts = [&quot;You are a helpful assistant.&quot;];<br>    if (this.summary) parts.push(`\n\nHistory:\n${this.summary}`);<br><br>    const active = Object.entries(this.entities).filter(([, s]) =&gt; s.size &gt; 0);<br>    if (active.length) {<br>      parts.push(`\n\nKey info:\n${active.map(([k, s]) =&gt;<br>        `- ${k}: ${[...s].join(&quot;, &quot;)}`<br>      ).join(&quot;\n&quot;)}`);<br>    }<br><br>    if (docs.length) parts.push(`\n\nDocs:\n${docs.join(&quot;\n\n---\n\n&quot;)}`);<br><br>    return [      { role: &quot;user&quot;, content: parts.join(&quot;&quot;) },      { role: &quot;assistant&quot;, content: &quot;I understand.&quot; },      ...this.messages    ];<br>  }<br>}</pre><p>The key insight here is separating what can be compressed (general conversation flow) from what cannot (specific identifiers, locations, product references). The entity extraction is deliberately simple, using a small model to extract entities.</p><p><strong>When to use this</strong>: Implement this solution when you’re seeing quality degradation in longer conversations, when users report the chatbot “forgetting” things they mentioned earlier, or when your token costs scale linearly with conversation length.</p><p><strong>Supported Research</strong>:</p><ul><li><a href="https://arxiv.org/abs/2308.15022">Recursively Summarizing Enables Long-Term Dialogue Memory in Large Language Models</a></li><li><a href="https://arxiv.org/abs/2402.17753">Evaluating Very Long-Term Conversational Memory of LLM Agents</a></li></ul><h3><strong>Solution 2: Strategic Document Ordering</strong></h3><blockquote><strong>Problem</strong>: Your retrieved documents are being <strong>ignored</strong> even when they contain the right information, because they’re landing in the middle of your context where the model pays less attention.</blockquote><p>Even with perfect context management, you can still lose information to the U-shaped attention problem. The foundational research here is <a href="https://arxiv.org/abs/2307.03172">Lost in the Middle: How Language Models Use Long Context</a>s.</p><p>They demonstrated that LLM performance is often highest when relevant information occurs at the beginning or end of the input context, and significantly degrades when models must access information in the middle, even for models explicitly designed for long contexts.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*HDy6hglQZIkePiL6ZqGEmQ.png" /><figcaption>Reordering Documents</figcaption></figure><p>Follow-up research in <a href="https://arxiv.org/abs/2406.16008">Found in the Middle: Calibrating Positional Attention Bias Improves Long Context Utilization</a> confirmed this is due to an intrinsic attention bias: LLMs exhibit a U-shaped attention pattern where tokens at the beginning and end of input receive higher attention regardless of their relevance to the query.</p><p>To me, the practical implication is clear: if your most relevant retrieved document happens to land in the middle of your context, the model might not give it the weight it deserves. My fix is straightforward: put your best documents at the beginning, second-best at the end, and weakest in the middle. This is shown to maximize the chance that your most relevant retrievals actually influence the response.</p><pre>interface ScoredDoc { content: string; score: number }<br><br>function reorderForAttention(docs: ScoredDoc[]): string[] {<br>  const sorted = [...docs].sort((a, b) =&gt; b.score - a.score);<br>  if (sorted.length &lt;= 2) return sorted.map(d =&gt; d.content);<br><br>  const result: ScoredDoc[] = [sorted[0]];<br>  for (let i = 1; i &lt; sorted.length; i++) {<br>    if (i % 2 === 1) {<br>      result.push(sorted[i]);<br>    } else {<br>      result.splice(Math.floor(result.length / 2), 0, sorted[i]);<br>    }<br>  }<br>  return result.map(d =&gt; d.content);<br>}</pre><p>For cases where you have clearly primary vs. supplementary documents, explicit sections work even better. The headers aren’t just for human readability — they help the model understand the relative importance of different sections.</p><pre>function buildStructuredContext(<br>  primary: string[],<br>  secondary: string[]<br>): string {<br>  let context = &quot;## Primary Sources\n\n&quot; + primary.join(&quot;\n\n---\n\n&quot;);<br><br>  if (secondary.length) {<br>    context += &quot;\n\n## Additional Context\n\n&quot; + secondary.join(&quot;\n\n---\n\n&quot;);<br>  }<br>  return context;<br>}</pre><p><strong>When to use this</strong>: This solution is worth implementing when you’re retrieving multiple documents per query and notice that highly relevant information sometimes gets overlooked. It’s a low-effort change — just a reordering step — that can noticeably improve response quality without any additional API calls or infrastructure.</p><p><strong>In short put your worst docs where the model won’t notice them anyway!</strong></p><h3><strong>Solution 3: Semantic Caching</strong></h3><blockquote><strong>Problem</strong>: You’re paying for retrieval and generation repeatedly for queries that are semantically identical, just phrased differently.</blockquote><p>Many RAG queries mean the same thing even when they use different words. “What’s your refund policy?” and “How do I get my money back?” Same question, same answer, but you just paid twice!</p><p>The paper <a href="https://arxiv.org/abs/2411.05276">GPT Semantic Cache: Reducing LLM Costs and Latency via Semantic Embedding Caching </a>formalized this approach: by storing embeddings of user queries, systems can efficiently identify semantically similar questions and retrieve pre-generated responses without redundant API calls. The technique achieves notable reductions in operational costs while significantly enhancing response times.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*3z05bKKoA5eB5cAeFdLvSw.png" /><figcaption>Semantic Caching</figcaption></figure><p>More recent work in <a href="https://arxiv.org/abs/2504.02268">Advancing Semantic Caching for LLMs with Domain-Specific Embeddings and Synthetic Data</a> explored how specialized, fine-tuned embedding models can further improve cache effectiveness. They note that semantic caching relies on embedding similarity rather than exact key matching, presenting unique challenges in balancing precision, query latency, and computational efficiency, but I think it’s worth the tradeoffs.</p><p>The concept is similar to traditional caching, but instead of checking if two strings are identical, we check if their vector embeddings are similar enough to be considered the same question.</p><pre>import { createHash } from &quot;crypto&quot;;<br><br>class SemanticCache {<br>  private openai = new OpenAI();<br>  private cache = new Map&lt;string, { response: string; embedding: number[] }&gt;();<br>  private stats = { hits: 0, misses: 0 };<br><br>  constructor(private threshold = 0.92) {}<br><br>  private async embed(text: string) {<br>    const res = await this.openai.embeddings.create({<br>      model: &quot;text-embedding-3-small&quot;,<br>      input: text<br>    });<br>    return res.data[0].embedding;<br>  }<br><br>  private cosine(a: number[], b: number[]) {<br>    const dot = a.reduce((s, v, i) =&gt; s + v * b[i], 0);<br>    const magA = Math.sqrt(a.reduce((s, v) =&gt; s + v * v, 0));<br>    const magB = Math.sqrt(b.reduce((s, v) =&gt; s + v * v, 0));<br>    return dot / (magA * magB);<br>  }<br><br>  async get(query: string): Promise&lt;string | null&gt; {<br>    const emb = await this.embed(query);<br>    let best = { score: 0, response: &quot;&quot; };<br><br>    for (const c of this.cache.values()) {<br>      const sim = this.cosine(emb, c.embedding);<br>      if (sim &gt; best.score &amp;&amp; sim &gt;= this.threshold) {<br>        best = { score: sim, response: c.response };<br>      }<br>    }<br><br>    if (best.response) { this.stats.hits++; return best.response; }<br>    this.stats.misses++;<br>    return null;<br>  }<br><br>  async set(query: string, response: string) {<br>    const hash = createHash(&quot;sha256&quot;).update(query).digest(&quot;hex&quot;);<br>    this.cache.set(hash, { response, embedding: await this.embed(query) });<br>  }<br>}</pre><p>A few implementation notes worth highlighting. The threshold of 0.92 , conservative, yes. But nobody ever got fired for a cache that was too careful…</p><p><strong>When to use this</strong>: Implement semantic caching when you have repetitive query patterns (common in customer support), when latency is a concern, or when you’re looking to reduce API costs. It pairs well with Solution 1 — the summary buffer manages conversation state while the cache handles repeated questions.</p><h3><strong>Solution 4: Multi-Agent Architecture</strong></h3><blockquote><strong>The problem this solves</strong>: Complex queries that span multiple domains produce shallow answers because cramming all the relevant context into a single call overwhelms the model’s ability to reason effectively.</blockquote><p>Some queries need information from multiple sources. “Compare our enterprise pricing to competitors and summarize recent customer feedback” requires pulling from pricing docs, competitor analysis, and support tickets. If you retrieve all of that and stuff it into one context window, you get shallow answers that don’t do justice to any of the sources.</p><p>The alternative is to spawn specialized sub-agents, each with their own focused context window, then synthesize their findings. Think of it like delegating research tasks to a team: each person goes deep on their area of expertise, then you bring everyone together to synthesize insights.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*OiEkCgYZ3U3S_bm4gfN_Aw.png" /><figcaption>Simple Multi-Agent Design</figcaption></figure><p>This approach is part of a broader trend toward “Agentic RAG.” Singh et al.’s 2025 survey <a href="https://arxiv.org/abs/2501.09136">Agentic Retrieval-Augmented Generation: A Survey on Agentic RAG</a> provided a comprehensive exploration of how autonomous AI agents can be embedded into RAG pipelines, leveraging agentic design patterns like reflection, planning, tool use, and multi-agent collaboration to dynamically manage retrieval strategies and adapt workflows to meet complex task requirements. Pretty cool!</p><p>You wouldn’t ask one person to be an expert in pricing, competitive intel, and customer sentiment. Why ask that of one context window?</p><p>More specifically, in A<a href="https://arxiv.org/abs/2412.05838"> Collaborative Multi-Agent Approach to Retrieval-Augmented Generation Across Diverse Data</a> proposed a multi-agent RAG system where specialized agents, each optimized for a specific data source, handle query generation for relational, NoSQL, and document-based systems. They demonstrated that this approach becomes more efficient than single-agent architectures when dealing with diverse data sources.</p><p>Recent work in <a href="https://arxiv.org/abs/2505.20096">MA-RAG: Multi-Agent Retrieval-Augmented Generation via Collaborative Chain-of-Thought Reasoning</a> showed that orchestrating a collaborative set of specialized agents — Planner, Step Definer, Extractor, and QA Agents — each responsible for a distinct stage of the RAG pipeline, significantly outperforms standalone LLMs and existing RAG methods on multi-hop and ambiguous question-answering benchmarks.</p><pre>type Retriever = (query: string) =&gt; Promise&lt;string[]&gt;;<br><br>class MultiAgentRAG {<br>  private client = new OpenAI();<br><br>  async orchestrate(query: string, retrievers: Record&lt;string, Retriever&gt;) {<br>    // Step 1: Plan - decompose query into subtasks<br>    const planRes = await this.client.chat.completions.create({<br>      model: &quot;gpt-4o&quot;,<br>      max_tokens: 500,<br>      messages: [{<br>        role: &quot;user&quot;,<br>        content: `Break down for retrieval.<br>Query: ${query}<br>Sources: ${Object.keys(retrievers).join(&quot;, &quot;)}<br>Respond JSON: {&quot;subtasks&quot;: [{&quot;source&quot;: &quot;...&quot;, &quot;subquery&quot;: &quot;...&quot;}]}`<br>      }],<br>    });<br><br>    const planText = planRes.choices[0].message.content || &quot;{}&quot;;<br>    const plan = JSON.parse(planText.match(/\{[\s\S]*\}/)?.[0] || &#39;{&quot;subtasks&quot;:[]}&#39;);<br><br>    // Step 2: Execute subtasks in parallel<br>    const results = await Promise.all(<br>      plan.subtasks.map(async (t: { source: string; subquery: string }) =&gt; {<br>        const docs = await retrievers[t.source](t.subquery);<br><br>        const res = await this.client.chat.completions.create({<br>          model: &quot;gpt-4o-mini&quot;,<br>          max_tokens: 300,<br>          messages: [{<br>            role: &quot;user&quot;,<br>            content: `Answer: ${t.subquery}\n\nDocs:\n${docs.join(&quot;\n\n&quot;)}`<br>          }],<br>        });<br><br>        return {<br>          source: t.source,<br>          findings: res.choices[0].message.content || &quot;&quot;<br>        };<br>      })<br>    );<br><br>    // Step 3: Synthesize findings into final answer<br>    const synthRes = await this.client.chat.completions.create({<br>      model: &quot;gpt-4o&quot;,<br>      max_tokens: 1000,<br>      messages: [{<br>        role: &quot;user&quot;,<br>        content: `Question: ${query}<br><br>Findings:<br>${results.map(r =&gt; `**${r.source}**:\n${r.findings}`).join(&quot;\n\n&quot;)}<br><br>Synthesize a comprehensive answer.`<br>      }],<br>    });<br><br>    return {<br>      answer: synthRes.choices[0].message.content || &quot;&quot;,<br>      plan,<br>      results<br>    };<br>  }<br>}</pre><h4>Usage Example</h4><pre>const retrievers = {<br>  pricing: async (q: string) =&gt; [&quot;Enterprise: $99/mo&quot;, &quot;Pro: $49/mo&quot;],<br>  competitors: async (q: string) =&gt; [&quot;Competitor A: $120/mo&quot;],<br>  feedback: async (q: string) =&gt; [&quot;92% satisfaction&quot;, &quot;NPS +15&quot;],<br>};<br><br>const rag = new MultiAgentRAG();<br>const { answer } = await rag.orchestrate(<br>  &quot;Compare enterprise pricing to competitors and summarize feedback&quot;,<br>  retrievers<br>);</pre><p>This architecture uses significantly more tokens than a single-agent approach, roughly 15x more for a complex query with multiple subtasks. That’s a real tradeoff: you’re paying more per query to get substantially better answers on questions that would otherwise produce shallow or incomplete responses.</p><p><strong>When to use this</strong>: Reserve multi-agent orchestration for queries that genuinely span multiple domains. Simple factual questions don’t need it and would just waste tokens. The pattern shines when users ask analytical questions that require synthesizing information from different sources — comparing products across multiple dimensions, investigating issues that span documentation and support history, or generating reports that need multiple data sources.</p><p><strong>Further reading</strong>:</p><ul><li><a href="https://arxiv.org/abs/2501.09136">Agentic Retrieval-Augmented Generation: A Survey on Agentic RAG</a></li><li><a href="https://arxiv.org/abs/2505.20096">MA-RAG: Multi-Agent Retrieval-Augmented Generation via Collaborative Chain-of-Thought Reasoning</a></li><li><a href="https://arxiv.org/abs/2412.05838">A Collaborative Multi-Agent Approach to Retrieval-Augmented Generation Across Diverse Data</a></li></ul><h3>Wrapping Up</h3><p>Context management separates RAG demos from production systems. The solutions here — conversation buffers, strategic document ordering, semantic caching, multi-agent architectures — each address a different aspect of the problem. You don’t need all of them, and you don’t need to implement them in any particular order.</p><p>If your main issue is conversations getting worse over time, start with Solution 1. If you’re retrieving good documents but the model seems to ignore them, try Solution 2. If you’re seeing repetitive queries driving up costs, Solution 3 will help. If complex questions consistently get shallow answers, consider Solution 4.</p><p>Your users don’t get less demanding on message twenty. Your RAG shouldn’t get less capable! Start with measuring your baseline, implement the solution that addresses your most pressing problem, and add others as needed. Each piece works independently, and you can stop wherever your requirements are met.</p><h3><strong>Troubleshooting Common Issues</strong></h3><p>Before diving into the solutions, here are issues you’ll likely encounter.</p><p><strong>Token Counting Confusion</strong>: Different models tokenize differently. Claude and GPT don’t produce identical token counts for the same text. When setting buffer limits, leave headroom — if your limit is 8,000 tokens, aim to stay under 7,000.</p><p><strong>Entity Extraction False Positives</strong>: The regex patterns in Solution 1 are intentionally simple. You’ll extract some noise. For production, consider using a small model to extract entities more accurately, or tune the patterns to your specific domain.</p><p><strong>Cache Threshold Tuning</strong>: The semantic cache in Solution 3 uses a 0.92 similarity threshold. Too high and you’ll miss valid cache hits. Too low and you’ll serve wrong answers. One banking chatbot reported reducing false positives from 99% to 3.8% by focusing on cache design over threshold tuning — start conservative and adjust based on your data.</p><p><strong>Summarization Hallucinations</strong>: When compressing conversation history, the summarization model can occasionally invent details. The entity extraction in Solution 1 exists partly to catch this — critical facts get preserved separately from the summary.</p><p><strong>References</strong></p><ol><li>Liu, N.F., Lin, K., Hewitt, J., Paranjape, A., Bevilacqua, M., Petroni, F., &amp; Liang, P. (2024). Lost in the Middle: How Language Models Use Long Contexts. <em>Transactions of the Association for Computational Linguistics</em>, 12, 157–173. <a href="https://arxiv.org/abs/2307.03172">https://arxiv.org/abs/2307.03172</a></li><li>Hsieh, C.Y., et al. (2024). Found in the Middle: Calibrating Positional Attention Bias Improves Long Context Utilization. <a href="https://arxiv.org/abs/2406.16008">https://arxiv.org/abs/2406.16008</a></li><li>Wang, Q., et al. (2023). Recursively Summarizing Enables Long-Term Dialogue Memory in Large Language Models. <a href="https://arxiv.org/abs/2308.15022">https://arxiv.org/abs/2308.15022</a></li><li>Maharana, A., Lee, D.H., Tulyakov, S., &amp; Bansal, M. (2024). Evaluating Very Long-Term Conversational Memory of LLM Agents. <a href="https://arxiv.org/abs/2402.17753">https://arxiv.org/abs/2402.17753</a></li><li>Regmi, S., et al. (2024). GPT Semantic Cache: Reducing LLM Costs and Latency via Semantic Embedding Caching. <a href="https://arxiv.org/abs/2411.05276">https://arxiv.org/abs/2411.05276</a></li><li>Gill, W., et al. (2025). Advancing Semantic Caching for LLMs with Domain-Specific Embeddings and Synthetic Data. <a href="https://arxiv.org/abs/2504.02268">https://arxiv.org/abs/2504.02268</a></li><li>Singh, A., et al. (2025). Agentic Retrieval-Augmented Generation: A Survey on Agentic RAG. <a href="https://arxiv.org/abs/2501.09136">https://arxiv.org/abs/2501.09136</a></li><li>Nguyen, T., et al. (2025). MA-RAG: Multi-Agent Retrieval-Augmented Generation via Collaborative Chain-of-Thought Reasoning. <a href="https://arxiv.org/abs/2505.20096">https://arxiv.org/abs/2505.20096</a></li><li>Salve, A., et al. (2024). A Collaborative Multi-Agent Approach to Retrieval-Augmented Generation Across Diverse Data. <a href="https://arxiv.org/abs/2412.05838">https://arxiv.org/abs/2412.05838</a></li></ol><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=df2149ad7403" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[How I Built a Local AI-Powered Mint Alternative]]></title>
            <link>https://medium.com/@levi_stringer/how-i-built-a-local-ai-powered-mint-alternative-f9cc38b7db32?source=rss-c6cd7b83742b------2</link>
            <guid isPermaLink="false">https://medium.com/p/f9cc38b7db32</guid>
            <category><![CDATA[software-development]]></category>
            <category><![CDATA[canada]]></category>
            <category><![CDATA[artificial-intelligence]]></category>
            <category><![CDATA[personal-finance]]></category>
            <category><![CDATA[fintech]]></category>
            <dc:creator><![CDATA[Levi Stringer]]></dc:creator>
            <pubDate>Wed, 26 Nov 2025 03:55:15 GMT</pubDate>
            <atom:updated>2025-11-26T03:55:15.495Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*j9W2LGz0pabINRbvL4BoDA.png" /></figure><p>When Canada’s best free budgeting app shut down, I spent a weekend building my own. Here’s what happened.</p><p>Mint shut down in Canada about a year and a half ago.</p><p>The email was brief: “We’re discontinuing Mint. Try Credit Karma instead.”</p><p>I tried Credit Karma. It was mostly a credit card sales pitch with some budgeting features tacked on. Pass.</p><p>So I looked at the alternatives. YNAB was $100/year USD. Monarch was beautiful but expensive. PocketSmith had a limited free tier. For Americans, there are dozens of options. For Canadians? Our fintech has always been an afterthought. Plaid barely works with our banks. Most apps don’t support Interac. We’re not a priority market.</p><p>I’m an AI engineer, and I’d been curious about these new local language models — small enough to run on a laptop, supposedly capable enough for real tasks. Transaction categorization seemed like a good test case. I think small models like this are the future.</p><p>Could I build something that worked?</p><h3>What I Actually Needed</h3><p>Here’s what I used Mint for: automatic transaction categorization.</p><p>That’s it. Not budgeting advice. Not “ways to save” recommendations. Just: tell me where my money went this month so I can see the pattern.</p><p>What I wanted:</p><ul><li>Works with bank exports (PDF/CSV from TD, RBC, Scotiabank — whatever my bank spits out)</li><li>Automatically categorizes transactions</li><li>Shows spending trends over time</li><li>Spots anomalies (duplicate charges, subscription increases)</li><li>Runs locally on my computer</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*oYMk63zlwD-0-xVAHwUwHQ.png" /><figcaption>Dashboard</figcaption></figure><h3>The Local AI Part</h3><p>I started with SmolLM3–3B a 3GB language model from HuggingFaceTB its free and can run on my MacBook Pro.</p><p>First test: 50 transactions from my credit card statement.</p><p>Result: 48 correct, 2 edge cases (a coffee shop inside a bookstore got tagged as “shopping” instead of “food”). Still needed a bit of prompt tuning.</p><p>The model understood things I never taught it:</p><blockquote><strong>FIZZ MOBILE</strong> → Utilities</blockquote><blockquote><strong>UBER TRIP vs UBER EATS </strong>→ Transportation vs Food</blockquote><blockquote><strong>TTC PRESTO </strong>→ Transit</blockquote><blockquote><strong>LCBO #482</strong> → Alcohol</blockquote><blockquote><strong>INTERAC E-TRANSFER </strong>→ Transfer (not an expense)</blockquote><p>Processing speed: 50–100ms per transaction. Fast enough.</p><p>I’ve since added support for other models too, OpenAI, Anthropic is as well. If you want cloud, or various Ollama models locally. The system is model-agnostic.</p><h3>The Build</h3><p>Simple tech stack:</p><ul><li>Bun + Elysia backend (TypeScript)</li><li>PostgreSQL with Drizzle ORM</li><li>Ollama to run the AI model locally</li><li>React/Next.js frontend with Recharts</li></ul><p>Through two weeks of evenings most of the time was spent tweaking prompts to improve categorization accuracy and handling different bank statement formats.</p><h3>Minimal by Design</h3><p>Most finance apps try to do everything. Budgets, goals, savings challenges, investment tracking, credit score monitoring, “insights” that are really just ads.</p><p>I wanted the opposite: show me my spending, let me ask questions, get out of my way.</p><p>The dashboard is one screen. Category breakdown on the left, trend chart on the right. No gamification. No badges. No Ads. No credit card promos. No “you saved $12 this month!” notifications. You get the idea.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Kpv0glzkk6AH2bBvcctWCw.png" /><figcaption>Document Processing</figcaption></figure><h3>Ask Questions in Plain English</h3><p>I can ask: “How much did I spend on coffee last summer?”</p><p>The system parses the intent, queries my transaction database, and returns an answer with a suggested chart. It understands relative time (“last month”, “this year”, “last 6 months”) and fuzzy category matching.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*QkWKv98FfbOrHgLgILQ0CA.png" /><figcaption>Talk with your finances. Locally</figcaption></figure><p>Other questions that work:</p><ul><li>“What did I spend the most on last month?”</li><li>“Show me my recurring subscriptions”</li><li>“Why am I spending more this year?”</li></ul><h3>Financial Time Machine</h3><p>Upload two years of statements and it finds patterns I never noticed:</p><ul><li>Seasonal spending (I spend 40% more in December — not just gifts, but “treating myself” purchases too)</li><li>Subscription creep over time</li><li>Lifestyle changes (“You started ordering delivery regularly in March 2023”)</li></ul><p>It can compare arbitrary time periods: “How does Q1 2024 compare to Q1 2023?” with category-level breakdowns showing what changed.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*yRyPVP6OIglJQI7JUtJ-7A.png" /><figcaption>Finance Time Machine</figcaption></figure><h3>Smart Transfer Detection</h3><p>Mint always counted my checking-to-savings transfers as spending. The AI understands “TRANSFER TO SAVINGS” and “INTERAC E-TRANSFER to Mom” aren’t expenses. A bit of manual rules and tweaks needed, but the power was in my hands.</p><h3>What Five Years of Data Revealed</h3><p>Once it worked, I uploaded every bank statement I could find. Five years, roughly 5,000 transactions.</p><p>Processing time took all of 8 minutes!</p><p>What I learned:</p><p><strong>My grocery spending varies wildly</strong> I thought: “I spend about $400/month on groceries” Reality: Anywhere from $280 to $650, no consistent pattern</p><p><strong>Subscription creep</strong> 2020: 4 subscriptions, $38/month 2024: 11 subscriptions, $127/month Added one at a time, never noticed the total. Life style creep is real!</p><p><strong>Small recurring charges</strong> $4.85 coffee × 3 days/week = $756.6/year</p><p><strong>The Friday pattern</strong> Spending consistently spiked $40–60 every Friday. A couple too many of “screw it, let’s order dinner”</p><h3>Local AI Lessons</h3><p>What I learned about running AI models locally:</p><p><strong>They’re capable enough.</strong> A 3GB model gets 95%+ accuracy on transaction categorization. You don’t need large models for this.</p><p><strong>They’re fast.</strong> Faster than cloud APIs when processing batches. No network latency.</p><p><strong>They understand context.</strong> Better than keyword matching. Knows “AMZN KINDLE” is different from “AMZN FRESH.”</p><p><strong>They work offline.</strong> Download the model once, process statements anywhere.</p><h3>The Manual Upload Flow…</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*qR1nr1RhypT6Mlb8UekHHw.png" /><figcaption>See all your financial documents in one place</figcaption></figure><p>No automatic bank syncing means I do have to download statements manually each month. Takes 30 seconds per account.</p><p>I’m thinking that this turned out to be a feature, not a bug.</p><p>When Mint auto-synced, I’d check once a month, glance at charts, close the app. Now I have to download the files, which means I actually see the transactions. Notice things. Ask questions. In the future an automated email reminder will also be nice.</p><p>Why $240 at Home Depot? (That shelf project I never finished.) Why is my phone bill $65 now? (Price increase I missed.)</p><p>The friction makes me pay attention.</p><p>Plus, in Canada, automatic bank syncing is unreliable anyway. Plaid support is spotty. Banks break connections constantly. The PDF workflow is simple but it just… works.</p><h3>What Actually Matters</h3><p>Building this taught me something: the AI isn’t the valuable part.</p><p>The valuable part is looking at your spending. Really looking at it.</p><p>The local AI makes the tedious parts faster — categorizing hundreds of transactions, spotting patterns across years, flagging anomalies. But I still have to upload statements, review the results, think about what they mean.</p><p>That’s what matters.</p><p>Mint’s shutdown forced me to pay attention again. Building this tool forced me to pay even more attention — to both my spending and what local AI can actually do.</p><p>Worth a couple late nights.</p><p>Right now this runs on my laptop. If there’s enough interest from other Canadians stuck in post-Mint limbo, I might turn it into something others can use — either self-hosted or as a proper app.</p><p>If you’d want early access, shoot me a message!</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=f9cc38b7db32" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[What It Takes to Start an AI Consulting Corporation in Canada]]></title>
            <link>https://medium.com/@levi_stringer/how-to-start-a-ai-consulting-corporation-in-canada-in-about-a-week-e0a8626f787a?source=rss-c6cd7b83742b------2</link>
            <guid isPermaLink="false">https://medium.com/p/e0a8626f787a</guid>
            <category><![CDATA[canada]]></category>
            <category><![CDATA[taxes]]></category>
            <category><![CDATA[company]]></category>
            <category><![CDATA[consulting]]></category>
            <category><![CDATA[small-business]]></category>
            <dc:creator><![CDATA[Levi Stringer]]></dc:creator>
            <pubDate>Sun, 23 Nov 2025 03:08:39 GMT</pubDate>
            <atom:updated>2025-11-26T03:57:12.975Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/774/1*KMEV4HVIScjhAAvLT1F7eg.png" /></figure><p>In May 2023, after spending six months traveling and thinking about what I wanted next, I incorporated my AI engineering consulting practice in Canada. I wanted to challenge myself and see if there was more out there for me.</p><p>Here’s what I learned spending about a week and about $500 setting everything up, plus the decisions I’d make differently knowing what I know now.</p><blockquote><strong>Important Disclaimer:</strong> I’m an AI consultant, not a lawyer, accountant, or financial advisor. This post describes my personal experience incorporating in Canada in 2023. Laws, regulations, and tax rules change frequently and vary by province. Don’t use this as legal or financial advice — consult with actual professionals (lawyer, accountant, or CPA) before making decisions about your business.</blockquote><h3>Mininum Viable List</h3><p>Before we dive into the details, here is a shortlist of everything you need to incorporate and get up and running:</p><p><strong>Legal &amp; Incorporation</strong></p><ul><li>[ ] NUANS name search (or accept a numbered company like I did)</li><li>[ ] Federal or provincial incorporation filing</li><li>[ ] Corporate minute book kit</li><li>[ ] Business insurance (I use Zensurance)</li><li>[ ] Contract templates (I use LawDepot)</li></ul><p><strong>Banking &amp; Financial</strong></p><ul><li>[ ] Business bank account</li><li>[ ] International payment solution (if needed)</li><li>[ ] Accounting software</li><li>[ ] Bookkeeper (I found mine on Upwork)</li><li>[ ] Accountant for year-end (also found on Upwork)</li></ul><p><strong>Operations</strong></p><ul><li>[ ] Time tracking software (I use Harvest)</li><li>[ ] Invoicing system</li><li>[ ] Client contracts and SOW templates</li></ul><p>Total timeline: ~1 week from filing to reaching out to clients</p><p><strong>Total first-year cost: $500–1000</strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/308/1*voHAEYkNOaPq1IipLiLPPQ.png" /><figcaption>How to start a business in 7 days.</figcaption></figure><h3>Federal vs Provincial: I Went Federal (Here’s Why)</h3><p>Everyone debates this. Provincial is cheaper upfront ($200 vs $300 federal), keeps your ownership private, and works fine if you’re only in Ontario.</p><p>I went federal anyway.</p><p><strong>The reason</strong>: I’m an AI consultant working with tech companies. Many are in BC, some in Alberta, and mostly in the US. Having a federal incorporation just felt more professional when talking to clients across the country.</p><p>There are some practical benefits: automatic name protection Canada-wide (no one else can register my corporate name, not that it mattered in my case anyways…), and if I ever want to work physically in another province, I don’t need extra paperwork. Federal costs $200 to file online, processes in one business day, and the annual return is only $12.</p><p>The downsides can be real though. Federal corporations now file public shareholder registers, technically anyone can look up who owns your company. Not a big deal or something I was concerned about. The name approval process also would mean waiting for a government examiner to review a proposed name. This would take three business days instead of same-day like Ontario. Again, probably not a big deal.</p><h3>The Numbered Company Name Mistake (Learn From Me)</h3><p>Here are you, ready to register your company. You’ve probably already got a name! Very exciting! But here’s something nobody tells you until it’s too late: if you just click through all the registration forms and you get to the end you may end up getting assigned a numbered company name.</p><p>That’s how I ended up as <strong><em>1506596–8 Canada Inc.</em>…</strong></p><p>I didn’t realize I needed to have my business name sorted out before filing. I must have missed it during the incorporation process when it asked for my corporate name. Now if you’re not ready with one that’s been approved through a NUANS search, they just give you a number.</p><p>It’s not the end of the world, you can operate under a trade name or “doing business as” (DBA) name. But it looks less professional on invoices, contracts, and bank accounts. When clients see a numbered company, it sometimes raises questions.</p><p>If I could do it over, I’d spend the extra day doing the NUANS name search ($13.80) and have a proper business name from day one. Or I’d just embrace the numbered company from the start and operate under a clean trade name.</p><p><strong>Lesson: Don’t rush the incorporation. </strong>Get your business name figured out first. It kind of matters!</p><h3>The Incorporation Process Takes One Business Day (Federal Edition)</h3><p>I incorporated online through the Corporations Canada website <a href="https://www.canada.ca/en/services/business/start/register-with-gov/register-corp/register-corp-fed.html">here</a>. The actual filing took about an hour. You need:</p><ul><li>Your proposed corporate name (or accept a numbered company like I did)</li><li>Articles of Incorporation specifying your share structure. You can also just do this within the process.</li><li>Your registered office address (can be your home, but must include the province)</li><li>Director information (just me, and since I’m a Canadian resident, met the 25% Canadian director requirement)</li></ul><p>For the share structure, I kept it simple: unlimited common shares. You don’t need complicated Class A and Class B share structures unless you have specific tax planning or ownership arrangements. Most solo consultants can just go with a simple unlimited common share structure.</p><p>The system is pretty straightforward once you figure out the interface. I submitted everything in the morning, and got my Certificate of Incorporation by email the next business day.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*LTVhaWArX-P8paQ3VUqobg.png" /><figcaption>Very official looking</figcaption></figure><p><strong>What I didn’t realize until much later: </strong>you immediately need a corporate minute book. This is legally required under the Canada Business Corporations Act and contains your incorporation documents, by-laws, shareholder registers, and meeting minutes. I bought a <a href="https://corporateminutebooks.ca/">pre-made kit </a>for $250 rather than having a lawyer prepare everything for $1,500+.</p><p>The organizational resolutions were straightforward: approve the by-laws, appoint myself as president, authorize banking, issue my shares to myself, set my fiscal year-end. Took maybe an hour with the kit templates.</p><p><strong>Lawyer Consultation (Optional but Worth Considering):</strong></p><p>I didn’t do a lawyer consultation during incorporation, and the process was fine without one. I do have friends who have started corporations with friends and they have strong recommend starting with a laywer and doing it right. Having a corporate lawyer review your setup can be valuable, especially for:</p><ul><li>Understanding salary vs dividend strategies for your specific situation</li><li>Fiscal year-end optimization</li><li>What triggers CRA audits</li><li>Complex ownership structures if you have co-founders or investors</li></ul><p>If you’re doing a straightforward solo incorporation, you can probably skip it. If you want the advice, budget $300–600 for 1–2 hours of consultation, ideally focused on year-end tax planning rather than the incorporation mechanics themselves. You can also do tax planning with a CPA.</p><p>Total incorporation cost: $200 filing fee + $250 minute book = $450 (or $464 if you do the NUANS name search).</p><h3>Banking: Why I Went Old School (RBC)</h3><p>Everyone talks about <strong>Float</strong>, <strong>Venn</strong>, and other <strong>fintech solutions </strong>with high interest rates and no fees. I looked at all of them.</p><p>But in the end I walked into RBC and opened a plain old business account.</p><p><strong>My thought process: </strong>I was starting my first corporation. I had questions. Lots of them. What documents do I actually need? How do I collect US dollars from clients? How can I get a company credit card? Can I integrate with accounting software?</p><p>The banker sat with me for 45 minutes and walked through everything. Showed me how their online banking worked for businesses. Explained the different account types. Answered my dumb questions about corporate vs personal accounts. Ultimately everything is basically the same as your chequing account.</p><p>One thing is the interest rate is <em>basically zero</em>, which sucks. Especially if you pay yourself in dividends and have to set aside substantial amounts of cash for corporate tax time. Even with these accounts the fees exist, which is annoying. But the peace of mind early on was valuable. Now that I understand how everything works, would I switch to Float or another high-interest account? Maybe. But I don’t regret starting with a real bank.</p><p>For international clients (which I have several), and accepting USD, I opened a Wise Business account. No monthly fees, and foreign exchange rates are about 90% cheaper than RBC. When a US client pays me, I’m not losing 2.5% on the conversion. Thousands saved each year.</p><p>My banking setup: RBC for Canadian operations ($12/month), Wise for international payments (free monthly, pay per transaction). Total monthly cost: $12.</p><p>Having both means I can accept payments however clients want to send them, convert currencies cheaply, and have someone to call when I need help. Good enough.</p><h3>The Tools That Helped Me Out</h3><h4><strong>QuickBooks</strong> — Financials</h4><p>Handles everything I need:</p><ul><li>Invoicing clients</li><li>Tracking expenses</li><li>Recording transactions</li><li>Calculating HST/GST,</li><li>Generating year-end tax forms.</li></ul><p>But truthfully the main reason I went with QuickBooks? It’s incredibly easy to find talented bookkeepers who already know the software. When I hired a bookkeeper on Upwork, every single qualified candidate knew QuickBooks. That means less training time and they can hit the ground running.</p><h4>Bookkeeping</h4><p>I once spent 8 hours pulling my hair out trying to reconcile my books, and categorize rough transactions. I eventually realized this is not what I was put on this earth for and promptly hired a bookkeeping service. My bookkeeper charges around $100–200/month depending on transaction volume, which includes categorizing expenses, reconciling accounts, and preparing everything for my accountant at year-end.</p><h4><strong>Harvest</strong> ($12/month) — Time Tracking</h4><p>For time tracking Harvest is purpose-built for consultants who bill hourly or need detailed time records. It makes hourly billing straightforward, you can track time by project and client, then create invoices directly from your time entries. Way cleaner than trying to do time tracking inside accounting software.</p><h4><strong>LawDepot</strong> — Contracts and Legal Documents</h4><p>For me LawDepot was great as it provides Canadian-specific templates for service agreements, NDAs, independent contractor agreements, and more. Much cheaper than paying a lawyer $500–1000 per contract, and the templates are solid for standard consulting work.</p><p>Total monthly cost with software and bookkeeping: around $220–280. Total time saved: probably 15 hours per month not doing manual bookkeeping, paperwork, and payroll calculations.</p><h3>Business Insurance (Don’t Skip This)</h3><p>One thing I was glad I did early: getting business insurance through <strong>Zensurance</strong>.</p><p>As a consultant, you’re exposed to professional liability risk. What if your code recommendation causes a client to lose money? What if there’s a security issue with something you built? What if a client claims you missed a deadline that cost them a deal?</p><p>Professional liability insurance (also called Errors &amp; Omissions or E&amp;O insurance) protects you against these claims. General liability covers things like if you spill coffee on a client’s laptop during a meeting.</p><p>I pay about $600–700 annually for coverage. I did several quote comparison and for me Zensurance made it easy — filled out a quick online form about my business, got quotes from multiple insurers, and was covered within a day.</p><p>Many larger clients (especially US companies) won’t sign contracts without proof of insurance. Having it ready means you don’t lose opportunities because you can’t provide a certificate of insurance quickly.</p><p>There are alternative places you can go ultra-budget: time tracking before I had FreshBooks. I used Clockify (free) for the first few months. Unlimited time tracking, unlimited projects, zero cost. Worked perfectly fine.</p><h3>The Salary vs Dividend Decision Isn’t What You Think</h3><p>Every online guide says the same thing: dividends are more tax-efficient. Pay yourself dividends, not salary.</p><blockquote>Disclaimer: This is what worked for my situation based on conversations with my accountant. Your optimal salary/dividend split depends on your income level, retirement goals, province, and personal financial situation. Talk to a CPA or tax accountant about your specific circumstances — don’t just copy my numbers.</blockquote><p>Here’s what they don’t tell you: most accountants actually recommend salary for Canadian consultants. When I talked to several accountants, they pretty much all said the same thing, salary is often better than the internet makes it sound.</p><p>Here’s why: salary builds CPP retirement benefits. Salary creates RRSP contribution room. Salary helps you qualify for mortgages. Dividends do none of these things.</p><p>After running the actual numbers with my accountant, I pay myself $75,000 in salary annually, paid quarterly. This puts about $6,000 into CPP (which I’ll get back in retirement), creates $13,500 in RRSP room, and keeps my quarterly payroll remittances manageable rather than dealing with monthly paperwork.</p><p>Everything above that $75,000 I take as dividends for flexibility. Need extra cash for a big expense? Declare a dividend. Slow client month? Skip it. No payroll calculations required.</p><p>The administrative difference is <strong>huge though</strong>. Salary means:</p><ul><li>Calculating CPP contributions</li><li>Income tax withholding,</li><li>Monthly remittances to CRA.</li></ul><p>I started to do this when I first was starting and had minimal clients. But now my bookkeeper handles this, and it takes about an hour per quarter. Dividends require a T5 slip at year-end if I take more than $50 annually.</p><p><strong>NO EI Premiums:</strong> as the owner of more than 40% of your corporation, you’re exempt from EI premiums. Don’t withhold or remit EI. This saves about $2,500 annually in combined employee and employer premiums that would be wasted money.</p><h3>The Tax Obligations</h3><blockquote>Tax Disclaimer: Tax rates and rules change. The rates mentioned here were accurate for Ontario in 2023 when I incorporated. Your province may have different rates, and federal/provincial tax rules change regularly. Verify current rates with an accountant.</blockquote><p>Your corporation pays 12.2% corporate tax on the first $500,000 of active business income in Ontario (9% federal + 3.2% Ontario). This is the Small Business Deduction rate. Way better than personal tax rates.</p><p>But there are four different tax filing obligations to track:</p><ol><li><strong>T2 Corporate Tax Return</strong> — Due 6 months after your fiscal year-end, though you pay the actual tax 3 months after year-end</li><li><strong>GST/HST Returns</strong> — Required once you hit $30,000 in revenue (I registered voluntarily at $15,000 to claim Input Tax Credits on all my software and equipment purchases)</li><li><strong>T4 Slips</strong> — If you pay yourself salary, due February 28 annually</li><li><strong>T5 Slips</strong> — If you pay yourself dividends over $50, due February 28 annually</li></ol><p><strong>Important GST/HST Note for Consultants with US Clients:</strong></p><p>If you’re selling services to US clients (like I do with software consulting), your services are <strong>zero-rated exports</strong>. This means:</p><ul><li>You charge 0% GST/HST to US clients (not 13%)</li><li>You still must file GST/HST returns if registered</li><li>You can still claim Input Tax Credits (ITCs) on your Canadian business expenses</li><li>You need to keep proper documentation proving your client is outside Canada</li></ul><p>This is actually beneficial. You recover the 13% HST on all your Canadian expenses (software, equipment, office supplies) but don’t need to collect any HST from clients. I immediately claimed back the 13% HST on my $3,000 MacBook Pro, $1,200 in software subscriptions, and $800 in office furniture. That’s $650 cash back from CRA.</p><p>Mandatory electronic filing started January 1, 2024 — you must file your GST/HST returns electronically through CRA’s My Business Account.</p><h3>What It Actually Costs ( Full Breakdown)</h3><p>Here’s what I spent in year one:</p><ul><li>Federal incorporation filing: $200</li><li>Minute book kit: $250</li><li>Business banking (RBC): $72/year</li><li>QuickBooks: $240/year</li><li>Harvest time tracking: $144/year</li><li>LawDepot contracts: $80/year</li><li>Zensurance business insurance: $600/year</li><li>Bookkeeper (Upwork): ~$800/year</li><li>Accountant for year-end (Upwork): $300</li><li>Federal annual return: $12</li></ul><p><strong>Total first-year cost: $2,500–3,000 (</strong>can be done for $800–1,200 if you go ultra-lean<strong>)</strong></p><p>There are several ways you could go ultra lean here. Cheapest accounting software, do the bookkeeping yourself, leaner insurance, etc..</p><h3>The Timeline From Zero to Operational</h3><p><strong>Day 1:</strong> Incorporated online through Corporations Canada website in the morning. Ordered minute book kit from Amazon.</p><p><strong>Day 2:</strong> Received Certificate of Incorporation by email (one business day processing). Booked appointment at RBC for the following week (they needed a few days for business banking appointments). Applied for Business Number through CRA’s Business Registration Online. Got my number instantly. Registered for GST/HST and payroll accounts at the same time.</p><p><strong>Day 3:</strong> Minute book kit arrived. Spent 2 hours completing organizational resolutions and setting up corporate records.</p><p><strong>Day 4:</strong> RBC appointment. Opened business account. Got temporary debit card same day, permanent card arrived a week later. Opened Wise Business account online (approved same day).</p><p><strong>Day 5:</strong> Set up Quickbooks. Connected to RBC account. Created invoice templates with my new GST/HST number.</p><p><strong>Day 6:</strong> Fully operational.</p><p>The whole process took about a week from deciding to incorporate to starting to find clients!</p><h3>What I’d Do Differently</h3><p>Four things:</p><p><strong>First</strong>, I’d take the extra day to get a proper business name instead of ending up with a numbered company. The NUANS search is only $13.80 and would have saved me the awkwardness of invoicing clients as “1506596–8 Canada Inc.”</p><p><strong>Second</strong>, I’d have found my bookkeeper and accountant on Upwork from day one instead doing <strong>anything myself. </strong>I am <strong>not</strong> an accountant nor want to be! Leave it to the professionals! Platforms like upwork can make this very accessible and allow you to hire just for what you need.</p><p><strong>Third</strong>, I’d start with Wave Accounting (free) for the first 3–6 months instead of immediately paying for QuickBooks. Wave handles Canadian taxes correctly and costs nothing. Upgrade once you’re consistently hitting $10,000+ monthly revenue.</p><blockquote>Remember: This is my experience from 2023–2025. Your situation, province, business model, and goals are different. Use this as a starting point for your research, not as instructions! Hire professionals for the important stuff.</blockquote><h3>Common Mistakes</h3><ul><li>Overthinking the federal vs provincial decision (just pick one)</li><li>Paying themselves only dividends and missing CPP/RRSP benefits</li><li>Not getting insurance until a client requires it (get it day one)</li><li>Doing their own bookkeeping for too long (hire help at $5k+ revenue/month)</li><li>Not registering for GST/HST early when they have big expenses</li></ul><h3>The Bottom Line</h3><p>Incorporating takes about two weeks and $2500 in first-year costs to do properly. Provincial incorporation makes more sense than federal for most consultants, unless you’re working with clients nationally like I do. The hybrid salary + dividend approach (favoring salary despite what the internet says) optimizes both taxes and retirement planning. Free or low-cost Canadian tools (Wave, Wise, Harvest) combined with Upwork professionals can keep costs manageable.</p><p><strong>The biggest surprise</strong>: corporate administration takes way less time than expected when you have a good bookkeeper. About 2–3 hours per month on my end for reviewing numbers and payroll sign-offs. The liability protection, professional credibility, and 12.2% corporate tax rate make it worthwhile once you’re consistently billing $75,000+ annually.</p><p>2.5 years in, I’m glad I incorporated. The structure forces better financial discipline, tax planning saves $15,000–20,000 annually compared to sole proprietorship, and clients take you more seriously when you have “Inc.” after your company name (even if it’s a numbered company).</p><p>Just don’t overcomplicate it!</p><p>This covers the mechanics of setting up a consulting corporation in Canada. But the setup is just the beginning. The real journey, figuring out what to charge, finding clients who value your work, building systems that scale, learning when to say no, and navigating the psychological shift from employee to business owner , that’s a story for next time!</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=e0a8626f787a" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[I Built a Route Planning App for Cyclists Who Hate Stopping]]></title>
            <link>https://medium.com/@levi_stringer/i-built-a-route-planning-app-for-cyclists-who-hate-stopping-6e31d192de69?source=rss-c6cd7b83742b------2</link>
            <guid isPermaLink="false">https://medium.com/p/6e31d192de69</guid>
            <category><![CDATA[ai]]></category>
            <category><![CDATA[machine-learning]]></category>
            <category><![CDATA[cycling]]></category>
            <category><![CDATA[app-development]]></category>
            <category><![CDATA[montreal]]></category>
            <dc:creator><![CDATA[Levi Stringer]]></dc:creator>
            <pubDate>Mon, 17 Nov 2025 04:48:38 GMT</pubDate>
            <atom:updated>2025-11-17T04:52:52.013Z</atom:updated>
            <content:encoded><![CDATA[<h3>I love cycling but I also love solving problems. And I had a problem.</h3><p>I don’t have a flexible way to search, <strong>decide on a route, hop on my bike and go. So I decided to build something to fix it.</strong></p><p>I’m relatively new to Montreal. Back home, I could leave my house and be on open road in 10 minutes. Want a nice 20k loop? I knew exactly where to go. Here? I’m starting from scratch.</p><p>A couple months ago, I’m cycling to Laval for some longer stretches. Google Maps: “10 km to bike-friendly route.” Easy warmup, right?</p><p>Ten minutes later, I’m stopped at my sixth red light, unclipping <strong>again, </strong>and breathing exhaust.</p><p>The problem isn’t the apps. It’s that none of them let you describe the type of ride you want. They give you “fastest route” or “bike-friendly.” But what does bike-friendly even mean? Shared lane with cars? Protected path? Side streets?</p><p>I don’t want the fastest route. I want a route with flow. Minimal stops. Actual bike lanes. Away from traffic.</p><p>So I built something that lets me describe exactly that.</p><h3>The Core Idea: Just Tell It What Kind of Ride You Want</h3><p>Sometimes I want:</p><ul><li>A quick 20-minute flow session with minimal stops</li><li>A chill route away from traffic</li><li>Something flat because my legs are toast</li><li>The quickest way to just put kilometers on my bike</li></ul><p>Existing apps make you pick from their categories or search segments. What I wanted was simple: question → solution. Something you just talk to:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/590/1*jgQ0a7uNV9BZ6FjpPkKt8Q.png" /></figure><p>“Find me a chill route from Plateau to Mile End” “Quick ride to Old Port and back, avoid traffic” “Flat route, no hills, I’m tired”</p><p>No clicking through menus. No toggling 15 different options. Just get a route.</p><p>How It Works: Streaming AI + Google Maps APIs</p><p>The AI Layer I integrated an LLM API that streams responses. When you type a query, you get real-time, conversational responses:</p><p>You: “I want a short ride to Mile End, maybe 20 minutes”</p><p>App: (streaming) “I’ll find you a relaxed route to Mile End, aiming for around 20 minutes. Looking for options with fewer stops…”</p><p>It should feel natural. It’s flexible. Works in English or French.</p><p>The Google Maps Integration Once the agent understands what you want, it taps into Google’s APIs to build the route. A few things I like:</p><ol><li><strong>Alternative Routes</strong> — Google gives you multiple alternatives. I use provideRouteAlternatives: true and let the agent pick the best match based on the query.</li><li><strong>Bike Layer</strong> — Google knows where actual bike infrastructure is. Protected lanes, shared lanes, bike paths. You can see which routes are actually bike-friendly vs “you’re sharing with cars and praying.”</li><li><strong>Traffic Layer</strong> — Real-time traffic data matters WAY more for bikes than people think. Heavy car traffic = you’re stopping at every light. Light traffic = better flow.</li></ol><figure><img alt="" src="https://cdn-images-1.medium.com/max/598/1*U1YsP5H9ekZq8YZVdubnQQ.png" /><figcaption>Map View With Traffic Overlap</figcaption></figure><p>The Route Feed Instead of a list, routes appear in a feed. Like Instagram, but for bike routes. Each route shows:</p><ul><li>Map preview (full-screen, interactive)</li><li>Distance, time, elevation</li><li>Traffic level indicator (green/yellow/red dot)</li><li>Safety score (0–100)</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/590/1*NyMR72XxfC5tQ0OYYgOalw.png" /></figure><p>You can iterate until you find a route you like.</p><h3>The Safety Score</h3><p>Each route gets scored 0–100 based on real-time data from Google Maps. Here’s what affects it:</p><p><strong>Road types:</strong> Residential streets and bike lanes increase the score. Highways and major boulevards decrease it.</p><p><strong>Time of day:</strong> Routes score highest mid-day. Rush hour (6–9am, 4–7pm) and nighttime rides get penalized for traffic and visibility.</p><p><strong>Traffic patterns:</strong> The algorithm analyzes turn frequency as a proxy for traffic density. More turns usually means quieter residential areas, which boosts the score.</p><p><strong>Route warnings:</strong> Google flags things like missing sidewalks or “use caution” conditions. Each warning drops the score.</p><p>So when browsing your feed:</p><ul><li>Route A: Fast (12 min) but safety score: 45/100 (busy boulevard, rush hour timing)</li><li>Route B: Slower (15 min) but safety score: 85/100 (residential streets, protected bike lanes)</li></ul><p>The score updates in real-time based on current conditions. Same route at 2pm might score 80, but at 5pm during rush hour? Maybe 55.</p><h3>What This Actually Looks Like</h3><p>Real example: I want to go from my place in the Plateau to grab coffee in Mile End.</p><p>What I type: “Chill ride to Mile End, avoid traffic”</p><p>What the AI responds: (streaming) “I’ll find you a relaxed route to Mile End with less traffic. One moment…”</p><p>Then it shows the route:</p><ul><li>Distance: 2.8 km</li><li>Time: 16 minutes</li><li>Elevation: 35m gain</li><li>Traffic: Light (green indicator)</li><li>Safety score: 78/100</li></ul><p>I tap it, see the map. Route goes through side streets (Clark/Waverly), not the main drags. Perfect.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/612/1*VRfLvI_wX1XNcTK38msucA.png" /></figure><p>I start riding. Minimal stops. Quiet streets. Just flow.</p><h3>Why This Works for Route Planning</h3><p>The key difference: you describe what you want, not what other people have done.</p><p>Google Maps optimizes for speed. Great for getting somewhere fast, but “bike-friendly” could mean anything.</p><p>Strava is built for tracking and performance. Route planning is paywalled, the free tier is amazing for analyzing rides you’ve already done, not for “I have 30 minutes and want something chill.”</p><p>What I built:</p><ul><li>Normal queries that match how you think about rides</li><li>Multiple route options in a feed so you can quickly compare</li><li>Safety scores and traffic indicators visible before you start</li><li>Real-time data that updates as conditions change</li></ul><p>It does one thing well: help you find the type of ride you actually want, right now.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/612/1*iTjQVrvNFFxxBF_IGdgUaQ.png" /></figure><h3>The Air Quality Bonus</h3><p>Montreal summer air can be rough. I added air quality data (1–10 scale) to each route. Now I can check before I leave: Is it a good day to ride? Or should I stick to the metro?</p><p>The API was already there (WeatherAPI.com), took maybe 30 minutes to wire up.</p><h3>Was This Worth It?</h3><p>Honestly? Yeah.</p><p>I use it before almost every ride now. Sometimes just to check traffic. Sometimes to find a new route. Sometimes because I want a specific vibe and I know it’ll find something that matches.</p><p>But more importantly: I’m learning Montreal. I’m building that cyclist’s intuition I had back home.</p><p>Is this a Strava killer? No probably not. Strava is for tracking, competing, analyzing performance, flexing on your friends.</p><p>This is for planning. For knowing. For getting the type of ride you actually want.</p><p>And as someone who just is still getting used to a new city and wants to ride without stopping every 200 meters? That’s everything.</p><h4>What’s Next?</h4><p>This does what I need, but three features would make it even better:</p><p>1. <strong>Route saving</strong> — Star favorites so I stop re-discovering the same routes<br>2. <strong>Traffic light counter</strong> — Show exactly how many stops to expect (the dream)<br><strong>3. Community routes</strong>— Let other riders share their no-stop gems. For free!</p><p>Whether I build these depends on whether anyone else actually wants this. If you’d use features like these, let me know in the comments.</p><p>For now, it solves my problem. That’s enough.</p><p>And if you’re in Montreal and have route suggestions — especially ones with minimal stops and good flow — drop a comment. I would be stoked to find some new ones.</p><h4>The Tech Stack</h4><p><strong>Frontend:</strong></p><ul><li>TypeScript + React 19</li><li>Vite (blazing fast builds)</li><li>Tailwind CSS (utility-first styling)</li><li>Mobile-first responsive design</li></ul><p><strong>APIs:</strong></p><ul><li>Google Maps APIs (Directions, Elevation, Maps JavaScript)</li><li>Weather API (air quality data)</li><li>Custom LLM API (streaming chat responses)</li></ul><p><strong>Architecture:</strong></p><ul><li>Route Feed (Instagram-style UI)</li><li>Chat Interface (streaming AI responses)</li><li>Natural Language Parser (extracts locations and preferences)</li><li>Route Orchestrator (coordinates all services)</li></ul><p>The whole thing is on <a href="https://github.com/levi-katarok/routevelo">GitHub</a></p><p>Built this because I needed it. If you’re also trying to figure out cycling in a new city, hope this helps.</p><p><em>If this resonated with you, give it a clap. If you have questions about the APIs or cycling, drop a comment. And if you’re also new to a city and missing your old routes, I feel you.</em></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=6e31d192de69" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Claude Code The Experiment: Atomic Task Breakdown Test]]></title>
            <link>https://medium.com/@levi_stringer/claude-code-one-shot-or-slow-down-bcb6283990d0?source=rss-c6cd7b83742b------2</link>
            <guid isPermaLink="false">https://medium.com/p/bcb6283990d0</guid>
            <dc:creator><![CDATA[Levi Stringer]]></dc:creator>
            <pubDate>Thu, 06 Nov 2025 02:05:13 GMT</pubDate>
            <atom:updated>2025-11-23T02:45:51.627Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/872/1*WvBNrQ5HzM0JCca8EFj0lA.png" /></figure><h3><strong>TLDR</strong>: I gave Claude Code identical bug fixes twice.</h3><ul><li>First run: $1.82, complete with custom hashing algorithms.</li><li>Second run: $0.77, five lines of validation logic. Both solved the problem.</li></ul><p>Task decomposition sounds boring until you’re staring at a production bug at 2 AM.</p><p>You’re wondering whether to throw the entire problem at Claude or methodically carve it into digestible chunks. Most developers default to the first option because it’s faster. But as we all know… faster isn’t always smarter.</p><p>I was trying to figure out what to test on, a true production app. And then it hit me, why not use the library so many thousands of developers rely on everyday, Vercel’s AI SDK. So I tested both approaches using a genuine bug from the backlog(issue #8132). Bundle everything into one mega-prompt versus atomize it into focused sessions. The results caught me off guard.</p><h4>The Bug That Kicked This Off</h4><p>OpenAI’s Responses API enforces a strict 40-character limit on tool call IDs. Sure why not.</p><p>The Vercel AI SDK’s `web_search_preview` tool? Blissfully unaware. It generated 51-character IDs like it was no one’s business.</p><p>Straightforward problem. Generate shorter IDs while maintaining uniqueness. How complex could this possibly get?</p><h3>Approach A: The “Just Fix It” Bundle</h3><p>My first prompt kept it simple.</p><blockquote>Fix GitHub issue #8132 in the vercel/ai repository.</blockquote><blockquote>The problem: The web_search_preview tool generates tool_call IDs that exceed OpenAI’s 40-character limit, causing this error: ‘Invalid messages[36].tool_calls[0].id: string too long. Expected maximum length 40, got length 51 instead.’</blockquote><p>No constraints. No guidance. Just point Claude at the problem. Keep in mind that I’m using almost exclusively Sonnet 4.5.</p><p><strong>Cost: $1.82</strong></p><p><strong>Time</strong>: 15 minutes</p><p><strong>Files changed:</strong> 6</p><p><strong>Lines added:</strong> +340</p><h4>Claude Got Creative</h4><p>It built an entire utility module from scratch. Hash-based truncation system included.</p><pre>// packages/provider-utils/src/truncate-id.ts (43 lines)<br>export function truncateId(id: string, maxLength: number = 40): string {<br>  if (id.length &lt;= maxLength) {<br>    return id;<br>  }<br>  const separatorIndex = id.indexOf(&#39;_&#39;);<br>  const prefix = separatorIndex &gt; 0 ? id.substring(0, separatorIndex) : &#39;&#39;;<br>  // Custom hash function<br>  const simpleHash = (str: string): number =&gt; {<br>    let hash = 0;<br>    for (let i = 0; i &lt; str.length; i++) {<br>      const char = str.charCodeAt(i);<br>      hash = ((hash &lt;&lt; 5) - hash) + char;<br>      hash = hash &amp; hash; // Convert to 32-bit integer<br>    }<br>    return Math.abs(hash);<br>  };<br>  const hash = simpleHash(id);<br>  const hashSuffix = hash.toString(36); // Base36 encoding<br>  const availableSpace = maxLength - (prefix.length + 1);<br>  const truncatedHash = hashSuffix.substring(0, availableSpace);<br>  return prefix ? `${prefix}_${truncatedHash}` : truncatedHash;<br>}</pre><p>Then it applied this truncation machinery across six different files. Added 125 lines of unit tests. Updated integration test snapshots. Built a full abstraction layer for what should’ve been a simple fix.</p><h4>Takeaways</h4><ul><li>I had to prevent it from reading a file it wasn’t supposed to read.</li><li>Tests failed on the first pass.</li><li>Required multiple iterations before everything clicked.</li><li>Ended up with custom bitwise operations and base36 encoding when the problem boiled down to “make shorter IDs.”</li></ul><p>It worked. But I’d just commissioned a Swiss Army knife when I needed scissors.</p><h3>Approach B: Atomic Sessions</h3><p>This time I carved it into three surgical strikes.</p><h4>Session 1:</h4><blockquote>&gt; Write a test that reproduces issue #8132. The test should generate a tool call ID using the current code and assert that it exceeds 40 characters. Only create the test — don’t fix anything yet.</blockquote><p><strong>Session 2:</strong></p><blockquote>&gt; Fix the ID generation to enforce 40-character maximum. Modify the existing createIdGenerator function to auto-adjust the size parameter if it would exceed the limit. Only modify the generation logic.</blockquote><p><strong>Session 3:</strong></p><blockquote>&gt; Add validation to prevent regression. Create a validateIdLength function that throws if an ID exceeds 40 chars. Add it where IDs are generated.</blockquote><p><strong>Cost:</strong> $0.77</p><p><strong>Time:</strong>16 minutes</p><p><strong>Files changed:</strong> 2</p><p><strong>Lines added:</strong> ~90</p><h4>Claude Kept It Real</h4><p>This time? It addressed the actual problem.</p><pre>// packages/provider-utils/src/generate-id.ts<br>export const validateIdLength = (id: string): void =&gt; {<br>  const MAX_ID_LENGTH = 40;<br>  if (id.length &gt; MAX_ID_LENGTH) {<br>    throw new InvalidArgumentError({<br>      argument: &#39;id&#39;,<br>      message: `Generated ID length (${id.length}) exceeds maximum allowed length (${MAX_ID_LENGTH})`,<br>    });<br>  }<br> };<br>export const createIdGenerator = ({<br>  prefix,<br>  size = 16,<br>  alphabet = &#39;0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz&#39;,<br>  separator = &#39;-&#39;,<br>  }: {prefix?: string; separator?: size?: number; alphabet?: string; } = {}): IdGenerator =&gt; {<br>      const MAX_ID_LENGTH = 40;<br>      // Auto-adjust size if it would exceed maximum<br>      let actualSize = size;<br>      if (prefix != null) {<br>        const prefixLength = prefix.length + separator.length;<br>          if (prefixLength + size &gt; MAX_ID_LENGTH) {<br>            actualSize = MAX_ID_LENGTH - prefixLength; // ← The fix<br>          }<br>      } else if (size &gt; MAX_ID_LENGTH) {<br>        actualSize = MAX_ID_LENGTH;<br>      }<br>      const generator = () =&gt; {<br>      const alphabetLength = alphabet.length;<br>      const chars = new Array(actualSize);<br>      for (let i = 0; i &lt; actualSize; i++) {<br>        chars[i] = alphabet[(Math.random() * alphabetLength) | 0];<br>      }<br>      const id = chars.join(&#39;&#39;);<br>      validateIdLength(id); // ← Defensive check<br>      return id;<br>     };<br>      // … rest of function<br>   };</pre><p>Zero hash functions. Zero base36 encoding shenanigans. Just prevent the problem where it starts.</p><h4>Takeaways</h4><p>From my notes I had:</p><ul><li>Cleaner code quality</li><li>I felt like I had better code understanding</li><li>Built checkpoints to check and course correct.</li></ul><p>Each session delivered one clear outcome:</p><ul><li>✅ Session 1: Test passes showing IDs exceed limits</li><li>✅ Session 2: Generator auto-adjusts size dynamically</li><li>✅ Session 3: Defensive validation guards against regression</li></ul><p>At every checkpoint, I could verify we weren’t building a Rube Goldberg machine. That’s powerful and I think that’s important to keep as an engineer. That understand and active part of the process.</p><h3>Why Atomization Crushed Bundling</h3><h4>Bundles Breed Complexity</h4><p>Give Claude an unconstrained problem and watch it flex. Custom hash function with bitwise operations? Uh huh. Base36 encoding for compactness? Absolutely. Separate truncation utility applied after-the-fact across multiple files? Sure why not.</p><p>Every line was technically defensible. That’s exactly the problem I think, when everything is justifiable in isolation, you lose sight of whether you’re solving the right problem. I don’t care about fancy engineering, it basically solved a phantom problem. We didn’t need to truncate existing IDs. We needed to stop generating oversized ones in the first place.</p><p>Zero checkpoints meant zero chances to ask the crucial question: “Wait, is this the simplest solution?”. I had a great mentor one time who told me. <em>KISS.</em></p><blockquote>“Keep It Simple Stupid”</blockquote><h4>Sessions Enforced Simplicity</h4><p>Breaking tasks into sessions created natural inflection points.</p><p><strong>After Session 1:</strong> “Alright, we can reproduce the bug. IDs exceed 40 characters.”</p><p><strong>After Session 2:</strong> “Hold on — why not just prevent generating long IDs?”</p><p><strong>After Session 3:</strong> “Add a defensive guard. Ship it.”</p><p>Each checkpoint gave me a chance to course-correct before complexity metastasized into the codebase. That’s the difference between code that works and code that endures. It also reinforces my ability to speak to the code and defend it when it comes to PR-time.</p><h3>Where Complexity Shows Its True Cost</h3><p>Code that works isn’t always code that ships.</p><h4>Code Review Tells the Story</h4><p>Reviewing the bundled approach triggers questions:</p><ul><li>Why implement a custom hash function?</li><li>Could truncating IDs risk collision scenarios?</li><li>Now ID logic lives in two places — generation and truncation</li><li>Should we use an established hashing library instead?</li></ul><p>Reviewing the session-based approach:</p><ul><li>Prevent size from exceeding 40 characters. Sensible.</li><li>Validation provides a safety net.</li><li>ID logic stays centralized in the generator.”</li></ul><p>One PR sparks five debates. The other sparks zero.</p><h3>Deciding When to Atomize</h3><p>Running this experiment crystallized my framework.</p><h4>Split Tasks When:</h4><p><strong>You’d review intermediate state anyway →</strong> Transform those review points into distinct sessions</p><p><strong>The solution approach remains unclear → </strong>Run a research session before implementation. Plan, plan, plan.</p><p><strong>Incremental validation matters → </strong>Chain them: Test session (Replicate bug) → Fix session → Validation session</p><h4>Stay Bundled When:</h4><p><strong>Single file with obvious changes → </strong>“Add null check to this function” doesn’t need decomposition</p><p><strong>You want to iterate quickly</strong>→ “Change this header, this table, and the colour to blue”. Quick visual mockups aren’t worth the trouble of breaking down.</p><p><strong>Well-defined API contracts exist</strong> → “Implement this interface” provides clear boundaries. Certainly leverage existing context.</p><h4>The One-Sentence Litmus Test</h4><p>Can’t articulate the task in ONE sentence with ONE measurable outcome? Split it.</p><ul><li>❌ “Fix the ID length issue” → Vague endpoint</li><li>✅ “Write failing test when IDs exceed 40 characters” → Crystal clear</li><li>✅ “Modify createId to auto-adjust size based on prefix length” → Surgical precision</li></ul><h3>Try it yourself:</h3><p>Next time you’re tempted to throw Claude a complex task:</p><p>1. Draft your prompt</p><p>2. Ask yourself: “Can I verify success in one step?”</p><p>3. No? Engage in a planning session and work to split it.</p><p>4. Identify natural review checkpoints</p><p>5. Convert each checkpoint into its own session</p><h4>What This Really Means</h4><p>The session-based approach cost half as much. Generated cleaner code. But here’s what actually matters — I felt confident at every checkpoint.</p><p>Bundled finished in 15 minutes. Session-based took 16. One minute difference.</p><p>Yet one approach generated technical debt I’d need to defend during code review. The other produced production-ready code that shipped without debate.</p><p>Speed doesn’t equal value.</p><p>Breaking tasks into atomic sessions forced both Claude and me to think clearly about each step. That clarity manifested directly in the codebase. No over-engineering. No unnecessary abstractions. Just clean, maintainable solutions.</p><p>Try atomizing your next complex feature. You’ll invest maybe one extra minute planning sessions. You’ll recover hours in code review and maintenance cycles.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=bcb6283990d0" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Running DeepSeek Locally: A Practical Example]]></title>
            <link>https://medium.com/@levi_stringer/simplifying-local-deepseek-a-practical-example-78b61720925e?source=rss-c6cd7b83742b------2</link>
            <guid isPermaLink="false">https://medium.com/p/78b61720925e</guid>
            <category><![CDATA[deepseek]]></category>
            <category><![CDATA[ollama]]></category>
            <category><![CDATA[llm]]></category>
            <category><![CDATA[openai]]></category>
            <category><![CDATA[machine-learning]]></category>
            <dc:creator><![CDATA[Levi Stringer]]></dc:creator>
            <pubDate>Thu, 30 Jan 2025 16:09:23 GMT</pubDate>
            <atom:updated>2025-01-30T20:19:27.965Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/349/0*tM70Lx8d4_sAl5MU.png" /></figure><blockquote>TLDR: Want to chat with DeepSeek quickly?</blockquote><pre>curl -fsSL https://ollama.com/install.sh | sh<br>ollama -v #check Ollama version<br>ollama run deepseek-r1:1.5b</pre><p>That’s all it takes! Read on for more details.</p><h3>Let’s Dive in</h3><p>Have you heard the buzz about DeepSeek, or have you been blissfully unaware? Well, if you’re like me, keeping up with emerging language models is part of your job. Otherwise, you may be wondering what in the world is going on.</p><p>DeepSeek-R1, released last week by DeepSeek, has been gaining traction for its cost-effectiveness (~free) and strong performance.</p><h3>What Is DeepSeek?</h3><p>It is a marked improvement in the cost of high performing models. This release was a huge step forward in the commodification of LLM models. Think of it as a “generic-brand” version of OpenAI’s GPT models- bringing capable models locally, but without reliance on cloud-based APIs.</p><p><strong>Key benefits:</strong></p><ul><li>💰 <strong>Cost Effective: </strong>High performance without hefty monthly invoices</li><li>🖥️ <strong>Local Execution</strong>: Run inference directly on your hardware.</li><li>🔧 <strong>Flexible Choices</strong>: Available in different sizes (1.5B, 7B, 700B) to fit your hardware capabilities.</li></ul><p>Now, you might be wondering: <strong>“What’s in it for me?”</strong><br>Simple — commoditization makes technology more <strong>accessible and cheaper</strong>, driving down costs for everyone. To highlight this effect, I’ll show you how to run DeepSeek locally on a <strong>MacBook Pro M3</strong>.</p><h3>Step 1: Install Ollama</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/674/1*ugFyZkNMzIoBw14ag82DMg.png" /><figcaption>Ollama’s Llama</figcaption></figure><ul><li>Download <a href="https://ollama.com/">Ollama</a>. This is probably the easiest way to get started with open source — models. Ollama is an open source project that allows you to quickly and easily run various LLMs on your local machine — <strong>without relying on an cloud based platforms.</strong></li><li>Installation on macOS is straightforward:</li></ul><pre>brew install ollama</pre><ul><li>After installing, verify it works by running ollama --help.</li></ul><h3>Step 2: Choose the Right Model for Your Hardware</h3><p>Next, figure out what size of DeepSeek model is right for your hardware. If you have limited RAM or an older GPU, you might start with a smaller model like deepseek-r1:1.5b or deepseek-r1:7b.</p><ul><li>You can see the models on <a href="https://ollama.com/library/deepseek-r1">Ollama’s page</a>.</li></ul><blockquote><strong>Pro Tip</strong>: Check out the <a href="https://apxml.com/posts/gpu-requirements-deepseek-r1">GPU requirement guide</a> to see if your hardware can handle larger models.</blockquote><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*qp-qayRN3tEmFOf_MiFXAg.png" /><figcaption>Model Versions</figcaption></figure><ul><li>For this illustration, I’m using a <strong>MacBook Pro M3 with 18 GB of RAM </strong>and I was <em>just </em>able to get away running</li></ul><pre>ollama run deepseek-r1:7b</pre><h4>A Note on Quantization</h4><p>By default, ollama run deepseek-r1:7b uses <strong>4-bit quantization</strong>, meaning it requires far less memory than full <strong>16-bit or 32-bit</strong> versions.</p><p>Want higher precision? Experiment with <strong>8-bit or 16-bit quantization</strong> if you have the memory/GPU capacity.</p><p>⚠️ <strong>Fun fact:</strong> The largest DeepSeek model currently has <strong>716 billion parameters</strong> with FP16 precision — requiring <strong>1.3TB of storage</strong>. Safe to say, your MacBook chip ain’t handling that.</p><h3>Step 3: Install the Model &amp; Run It</h3><p>Once you’ve picked a model, running it for the first time will trigger an automatic download:</p><pre>ollama run deepseek-r1:7b</pre><blockquote>⏳ <strong>Note:</strong> The first run may take several minutes as it downloads and caches the model. After that, you should see a prompt where you can type a question or statement and get a response.</blockquote><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*ZO_xrTyQravA9EyNwfuAEg.png" /></figure><h4>Common Issues and Solutions</h4><p><strong>1. Ollama Connection Fails</strong></p><ul><li>Make sure Ollama is running via ollama serve.</li><li>Verify you’re connecting to the right URL (http://localhost:11434/v1).</li><li>Check if the model is loaded: ollama list.</li></ul><p><strong>2. Insufficient RAM or GPU</strong></p><ul><li>Try a smaller model variant, or use 4-bit quantization.</li><li>Close other memory-intensive apps.</li></ul><p><strong>3. Model Download Is Slow</strong></p><ul><li>The larger the model, the longer it will take. Consider using smaller model sizes first</li></ul><h3>Integration Example: 🚀 Better Commit Messages with DeepSeek</h3><p>Now that you have the model running, how do you <strong>really</strong> put it to use? Most developers hate writing commit messages. It’s easy to just type:</p><pre>git commit -m &quot;changes&quot;</pre><p>I’ve never much enjoyed is writing <strong>or </strong>reading<strong> </strong>commit messages. I know what you’re thinking: ‘Really? It’s just one sentence.’ And you’re not wrong. But I’ve seen developers put effort into elegant features and bug fixes — only to end up with commit messages like git commit -m &#39;changes&#39; , “changes”? Six months later, that message is useless. So for this example I took this problem of mine and did what engineers do best — <em>fix it.</em></p><p><strong>Example Solution: commitry-cli</strong></p><p>I built <strong>commitry-cli</strong>, a simple tool that generates meaningful commit messages using DeepSeek. Here’s how to use it:</p><ol><li><strong>Clone the repo &amp; Install</strong></li></ol><pre>git clone git@github.com:levi-katarok/commitry-cli.git<br>cd commitry-cli<br>python -m venv venv &amp;&amp; source venv/bin/activate<br>pip install -e .</pre><p>2. <strong>Ensure Ollama is running</strong> <strong>on the default port:</strong></p><pre>ollama serve<br>lsof -i :11434</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*5URujHhfUfLHkApgvy2wZg.png" /><figcaption>Sample Command Line Ouput Upon Success</figcaption></figure><p>3. <strong>Test it:</strong></p><p>You can now try and make a change in your repo and see what you get!</p><ul><li>Here I make a change in my repo</li></ul><pre>def calculate_radius_of_planet(planet):<br>    if planet == &quot;Earth&quot;:<br>        return 6371<br>    elif planet == &quot;Mars&quot;:<br>        return 3389<br>    else:<br>        return 0</pre><ul><li>Then I staged the function</li></ul><pre>git add . </pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Pi6d5sW5FCjYc6X8vgY-7Q.png" /><figcaption>Sample of Staged Changed</figcaption></figure><ul><li>Pretty simple change right? Now we can see how Deepseek will do by running my handy cli tool commitry-cli. You can see the truncated output below.</li><li>Run commitry-cli:</li></ul><pre>commitry-cli                          <br><br>&lt;think&gt;<br>Alright, so I&#39;m trying to understand this code that was generated by a user. Let me read through it again.<br><br>The function is called calculate_radius_of_planet and it takes &#39;planet&#39; as an argument. The first condition checks if planet == &quot;Earth&quot;. If that&#39;s true, it returns 6371—which must be in kilometers because I recall Earth&#39;s radius is about 6,371 km.<br><br>Then there&#39;s an elif clause checking if planet == &quot;Mars&quot; and returning 3389. Yeah, Mars&#39; radius is roughly around there—maybe like 3,389 km? That seems right.<br><br>If none of those conditions are met, it goes to else and returns 0. Hmm, so for any other planets not specified here, the function just gives a zero radius. I guess that&#39;s by design because they only have data for Earth and Mars.<br><br>Wait a second, in the code, after the else clause, there was another &#39;else&#39; statement: return 0 again? No, no— actually, let me check the indentation. Oh, right, it seems like the first function is from line 3, so maybe that&#39;s the correct structure. But wait, looking back, I think there might have been a mistake in how these code blocks were presented.<br><br>Wait, the original code given starts with a function and then has those conditionals inside. So after handling Earth and Mars, if none are matched, it returns 0. There isn&#39;t an extra &#39;else&#39; for another planet because any other input would just hit the last else clause.<br><br>I think this is correct as per the user&#39;s data. The code correctly maps Earth to ~6371 km and Mars to ~3389 km. Any other planet not specified returns 0, which could be a placeholder or default value if they don&#39;t have radius information for others.<br><br>So the function works as intended based on what was provided.<br>&lt;/think&gt;<br><br>The code defines a function `calculate_radius_of_planet` that returns the radius of a given planet in kilometers, returning 6371 km for Earth and 3389 km for Mars, with all other unsupported planets returning 0.</pre><ul><li>You’ll get a suggested commit message generated by DeepSeek. Below is an example of the <strong>truncated</strong> output:</li></ul><pre>Suggested commit message:<br>The code defines a function `calculate_radius_of_planet` that returns the radius of a given planet in kilometers, returning 6371 km for Earth and 3389 km for Mars, with all other unsupported planets returning 0.</pre><ul><li>Not bad! It’s certainly more descriptive than the typical “updates” or “changes.” For comparison, if you have an OpenAI API key, you can configure the CLI to call an OpenAI model as well. In one test, when I called GPT-4o-mini:</li></ul><pre>commitry-cli --model=&#39;gpt-4o-mini&#39;</pre><ul><li>It returned back to me:</li></ul><pre>Suggested commit message:<br>Add a function to calculate the radius of a planet based on its name.</pre><ul><li>Also not bad. Honestly I think OpenAI might have won in this case. Both are decent commit messages. One of the big perks here is that <strong>DeepSeek</strong> can run entirely offline — no need to rely on a third-party API or pay for tokens.</li></ul><p><strong>Complete Flow:</strong></p><pre>git clone git@github.com:levi-katarok/commitry-cli.git<br>cd commitry-cli<br>python -m venv venv &amp;&amp; source venv/bin/activate<br>pip install -e .<br>ollama serve<br>commitry-cli</pre><h4>Conclusion</h4><p>The rise of DeepSeek represents another milestone in the commodification of large language models — if you are curious about why DeepSeek went with this strategy, I recommended reading <a href="https://gwern.net/complement">The Law of Commoditize Your Tech</a>. As models become cheaper and more accessible, we gain more flexibility to run high-quality AI <strong><em>locally, at the edge.</em></strong></p><p>This is just one potential use case. But what other scenarios could benefit from high-quality, locally-run models?</p><p>As always, please share your thoughts, questions, or any corrections in the comments below. I hope this was helpful.</p><h4>Resources:</h4><ul><li>📜 <a href="https://arxiv.org/pdf/2501.12948">DeepSeek Official Paper</a></li><li>💾 <a href="https://ollama.com/library/deepseek-r1:8b">Ollama DeepSeek R1</a></li><li>🖥️ <a href="https://apxml.com/posts/gpu-requirements-deepseek-r1">DeepSeek Models GPU Guide</a></li><li>🔧 <a href="https://github.com/levi-katarok/commitry-cli">commitry-cli Repository</a></li></ul><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=78b61720925e" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Simplifying LLaMA on AWS Bedrock]]></title>
            <link>https://medium.com/@levi_stringer/simplifying-llama-model-usage-on-aws-bedrock-in-5-steps-b6e5a6287319?source=rss-c6cd7b83742b------2</link>
            <guid isPermaLink="false">https://medium.com/p/b6e5a6287319</guid>
            <category><![CDATA[machine-learning]]></category>
            <category><![CDATA[llm]]></category>
            <category><![CDATA[bedrock]]></category>
            <category><![CDATA[llama-3]]></category>
            <category><![CDATA[aws]]></category>
            <dc:creator><![CDATA[Levi Stringer]]></dc:creator>
            <pubDate>Tue, 24 Sep 2024 15:28:54 GMT</pubDate>
            <atom:updated>2024-09-27T14:31:18.255Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/912/1*vPz8mzXdx2P0gXunilfw8Q.png" /><figcaption>Created by Midjourney 2024</figcaption></figure><p>In this tutorial, I’ll guide you through setting up and using Meta’s LLaMA model on AWS Bedrock, showcasing a semi-practical use case… generating recipes based on available ingredients. This guide is for developers and data scientists who are either familiar with AWS or looking for a hosted service to deploy alternate models such as the LLaMA series. <a href="https://github.com/levi-katarok/simple-aws-bedrock-llm">If you prefer to see the code directly you can checkout the github here.</a></p><blockquote>Note: I used LLaMA here simply because I found it the hardest to find an example elsewhere, but this process will work for any model hosted on AWS Bedrock, you can see a complete list on AWS <a href="https://aws.amazon.com/bedrock/?gclid=Cj0KCQjwo8S3BhDeARIsAFRmkOO4CXTBhEgIYy3ECDnwCDGapJVlntfp1VmuDLSlc0pqqRD_ZArxb1kaAsvgEALw_wcB&amp;trk=8228be07-d2ee-417f-b236-33eb068829a6&amp;sc_channel=ps&amp;ef_id=Cj0KCQjwo8S3BhDeARIsAFRmkOO4CXTBhEgIYy3ECDnwCDGapJVlntfp1VmuDLSlc0pqqRD_ZArxb1kaAsvgEALw_wcB:G:s&amp;s_kwcid=AL!4422!3!692006005915!e!!g!!aws%20bedrock!21054971261!157173597057">here</a>.</blockquote><h3>Prerequisites</h3><ul><li>An AWS account with appropriate permissions and billing set up</li><li>Python 3.7 or later</li></ul><h3>Step 1: Set Up Your AWS Environment</h3><p>Though AWS configuration can sometimes be challenging, this process for Bedrock was surprisingly straightforward.</p><ol><li><strong>Log in to your AWS Console.</strong></li><li><strong>Navigate to the IAM (Identity and Access Management) dashboard:</strong></li></ol><ul><li>IAM allows you to control who can access your AWS resources. You’ll create a user with programmatic access and apply the AmazonBedrockFullAccess policy to enable usage of Bedrock services.</li></ul><p><strong>3. Create or update an IAM user:</strong></p><ul><li>Create a new IAM user or use an existing one with programmatic access.</li><li>Attach the <em>AmazonBedrockFullAccess</em> policy to this user.</li><li>Note down the Access Key ID and Secret Access Key.</li></ul><p>4. <strong>Enable model access:</strong></p><ul><li>Visit the <a href="https://us-west-2.console.aws.amazon.com/bedrock/home?region=us-west-2#/modelaccess">Bedrock model access page</a> and enable the Meta models (or others as needed).</li></ul><p><strong>5. Verify region availability:</strong></p><ul><li>Ensure you are using the correct AWS region (e.g., us-west-2), as model availability may vary by region.</li></ul><h3>Step 2: Configure Your Local Environment</h3><ol><li>Use the requirements.txt from <a href="https://github.com/levi-katarok/simple-aws-bedrock-llm">GitHub</a> or copy the following installation command:</li></ol><pre>pip install boto3 python-dotenv pydantic</pre><h3>Step 3: Create Python Script</h3><ol><li>Create a .env file with your AWS credentials:</li></ol><pre>AWS_SECRET_ACCESS_KEY=********<br>AWS_ACCESS_KEY_ID=****<br>AWS_REGION=us-west-2</pre><p>2. Create a Python file named recipe_generator.py with the following code. In the script, we first load the environment variables, then set up the AWS Bedrock Runtime client for making API calls. The generate_recipe function prompts the LLaMA model using an ingredient list and expects the output in JSON format conforming to the Recipe model schema.</p><pre>import boto3<br>import json<br>import os<br>from typing import List<br>from pydantic import BaseModel<br>from dotenv import load_dotenv<br><br># Load environment variables<br>load_dotenv()<br><br># Set up AWS credentials<br>aws_access_key = os.environ.get(&quot;AWS_ACCESS_KEY_ID&quot;)<br>aws_secret_key = os.environ.get(&quot;AWS_SECRET_ACCESS_KEY&quot;)<br>aws_region = os.environ.get(&quot;AWS_REGION&quot;)<br><br># Create a Bedrock Runtime client<br>client = boto3.client(<br>    &quot;bedrock-runtime&quot;,<br>    region_name=aws_region,<br>    aws_access_key_id=aws_access_key,<br>    aws_secret_access_key=aws_secret_key<br>)<br><br># Define Recipe model<br>class Recipe(BaseModel):<br>    name: str<br>    ingredients: List[str]<br>    instructions: List[str]<br><br># Create JSON schema for Recipe<br>schema = Recipe.model_json_schema()<br>schema_json = json.dumps(schema, indent=4)<br><br># Set the model ID for Llama 3<br>MODEL_ID = &quot;meta.llama3-1-8b-instruct-v1:0&quot;<br><br>def generate_recipe(ingredients):<br>    prompt = f&quot;&quot;&quot;&lt;|begin_of_text|&gt;&lt;|start_header_id|&gt;system&lt;|end_header_id|&gt;<br>    You are a helpful assistant. Generate a recipe using the following ingredients. The output should be formatted as a JSON instance.&lt;|eot_id|&gt;&lt;|start_header_id|&gt;user&lt;|end_header_id|&gt;<br>    Ingredients: {&#39;, &#39;.join(ingredients)}. <br>    Output in JSON format only. The output should be formatted as a JSON instance that conforms to the JSON schema below. {schema_json}&lt;|eot_id|&gt;&lt;|start_header_id|&gt;assistant&lt;|end_header_id|&gt;&quot;&quot;&quot;<br> <br>    try:<br>        response = client.invoke_model(<br>            modelId=MODEL_ID,<br>            body=json.dumps({<br>                &quot;prompt&quot;: prompt,<br>                &quot;max_gen_len&quot;: 512,<br>                &quot;temperature&quot;: 0.7,<br>                &quot;top_p&quot;: 0.9,<br>            })  <br>        )<br>        <br>        response_body = json.loads(response[&#39;body&#39;].read().decode(&#39;utf-8&#39;))<br>        recipe_json = json.loads(response_body[&#39;generation&#39;])<br>        return Recipe(**recipe_json)<br>    except Exception as e:<br>        print(f&quot;Error generating recipe: {str(e)}&quot;)<br>        return None<br><br># Example usage<br>if __name__ == &quot;__main__&quot;:<br>    ingredients = [&quot;chicken&quot;, &quot;rice&quot;, &quot;bell peppers&quot;, &quot;onions&quot;, &quot;potatoes&quot;]<br>    recipe = generate_recipe(ingredients)<br>    if recipe:<br>        print(recipe)<br>    else:<br>        print(&quot;Failed to generate recipe.&quot;)</pre><blockquote>Tip: <a href="https://www.llama.com/docs/model-cards-and-prompt-formats/llama3_1/">Using the recommended prompt structure from Meta </a>can significantly improve the quality of the output, especially when generating structured JSOB</blockquote><h3>Step 4: Run the Script</h3><p>Execute the script by running:</p><pre>python recipe_generator.py</pre><p>Upon execution, the script will send the ingredient list to the LLaMA model via Bedrock and return a JSON-formatted recipe.</p><h3>Step 5: Analyze the Results</h3><p>The script will output a JSON object containing the generated recipe. You can further customize the output by modifying the prompt or tweaking the generate_recipe function to fit your application&#39;s needs. Here’s an example of what the pydantic output might look like.</p><pre>Recipe(<br>    name=&#39;Chicken and Vegetable Stir Fry&#39;,<br>    ingredients=[&#39;chicken&#39;, &#39;rice&#39;, &#39;bell peppers&#39;, &#39;onions&#39;, &#39;potatoes&#39;],<br>    instructions=[<br>        &#39;Heat oil in a pan and sauté the onions and bell peppers until tender.&#39;,<br>        &#39;Add the chicken and cook until browned.&#39;,<br>        &#39;Add the potatoes and cook until they start to soften.&#39;,<br>        &#39;Add the rice and stir-fry for 2-3 minutes.&#39;,<br>        &#39;Season with salt and pepper to taste.&#39;,<br>        &#39;Serve hot and enjoy!&#39;<br>    ]<br>)</pre><h3>Conclusion</h3><p>You’ve now successfully set up and used Meta’s LLaMA model on AWS to generate recipes based on given ingredients! This example alternatives to other APIs, and how easy it is to connect to LLaMA on AWS Bedrock and utilize it for various use cases.</p><p>While this is a simple example, I always strive for flexibility in my LLM applications and have access to hosted LLaMA is a great tool in the tool belt.</p><blockquote>Note: Remember to always review and test the generated recipes, as AI models may sometimes produce inedible, or impractical meals... Happy cooking and coding!</blockquote><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=b6e5a6287319" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Building Stateful Conversations with Postgres and LLMs]]></title>
            <link>https://medium.com/@levi_stringer/building-stateful-conversations-with-postgres-and-llms-e6bb2a5ff73e?source=rss-c6cd7b83742b------2</link>
            <guid isPermaLink="false">https://medium.com/p/e6bb2a5ff73e</guid>
            <category><![CDATA[rest-api]]></category>
            <category><![CDATA[llm]]></category>
            <category><![CDATA[machine-learning]]></category>
            <category><![CDATA[postgres]]></category>
            <category><![CDATA[llmops]]></category>
            <dc:creator><![CDATA[Levi Stringer]]></dc:creator>
            <pubDate>Tue, 12 Mar 2024 21:21:07 GMT</pubDate>
            <atom:updated>2024-03-12T21:21:07.473Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/400/1*d-zMjPeppDbvBtak-Wh2Tg.png" /><figcaption>Image generated by DALLE March 2024</figcaption></figure><p>In an era dominated by AI-driven interactions, creating LLM applications or chatbots that can handle multiple users and remember past conversations is crucial. This guide dives into the challenge of ensuring continuity and context in Large Language Model (LLM) interactions, revealing how PostgreSQL can be the key to managing chat histories effectively. By integrating a database, you not only enhance user experience but also equip your chatbot with the ability to engage in more meaningful, context-aware dialogs. Let’s explore how to elevate your chatbot from a simple question-answer machine to an intelligent conversational agent that remembers and learns from each interaction.</p><blockquote>Complete solution at the end of the article.</blockquote><h4><strong>Benefits of Having </strong>Why Chat History Enhances User Experience</h4><p>George Santayana wisely pointed out “<em>Those who cannot remember the past are condemned to repeat it”. </em>A concept as true in conversations as it is in history.</p><p>Imagine the frustration you’d have if chatting with a support system and every time you asked a question, they repeatedly asked the same questions. Maybe you don’t have to imagine, as I imagine many of us have been there before. By avoiding repetition, incorporating history in any chatbot offers significant benefits that elevate any application.</p><p>What are some technical benefits of having a saved session history?</p><ol><li><strong>Contextual Understanding</strong>: Large language models thrive on context. Including chat history in prompts, allows LLMs to grasp the nuances of ongoing dialogue, ensuring answers are not only accurate, but also highly relevant. This keeps a fluid coherent conversation</li><li><strong>Improved Continuity: </strong>In the real world life is full of interruptions. We step away from conversation, only to return later with new questions or to continue where we left off. With chat history, a bot can pick up precisely where the conversation left off, regardless of breaks, ensuring a coherent and continuous user experience without missing a beat.</li><li><strong>RESTful Chatbots: </strong>In the stateless realm of RESTful applications, where each request stands alone, chatbots face the challenge of remembering past interactions. This is where integrating chat history becomes critical. It allows RESTful chatbots to overcome their inherent lack of memory, enabling them to provide responses that are coherent and contextually relevant, even after interruptions. Chat history ensures that despite the stateless nature of RESTful services, chatbots can maintain a seamless conversation flow, significantly enhancing the user experience by allowing for natural, uninterrupted dialogue. This approach transforms isolated interactions into a connected, ongoing conversation, crucial for user engagement and satisfaction in RESTful environments. While still allowing your application to attend to other incoming requests.</li></ol><h3>Designing Your Chat History Feature</h3><p>Now, of course you can go with any database and any format you’d like. I’m going to be providing an example with Postgres. It is reliable and free (check out <a href="https://supabase.com/">https://supabase.com/</a> if you want a great cloud hosted solution). You can always set up and install a local version.</p><p>PostgreSQL is renowned for its robustness and strong compliance with SQL standards. It offers features such as transactional integrity, complex queries, and support for JSON data types, making it an excellent choice for applications requiring reliable data storage and retrieval mechanisms, such as AI-powered chat applications.</p><p>For a robust chat history management system, we’ll focus on three essential components:</p><ul><li><strong>Users Table</strong>: Stores user-specific information.</li><li><strong>Sessions Table</strong>: Keeps track of each chat session, noting start and end times, and linking back to the respective user.</li><li><strong>Messages Table:</strong> Records the details of every message within a session, including sender, text, and timestamps.</li></ul><pre>CREATE TABLE users (<br>    user_id SERIAL PRIMARY KEY,<br>    username VARCHAR(50) UNIQUE NOT NULL,<br>    created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP<br>);<br><br>CREATE TABLE sessions (<br>    session_id SERIAL PRIMARY KEY,<br>    user_id INTEGER REFERENCES users(user_id),<br>    start_time TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,<br>    end_time TIMESTAMP WITH TIME ZONE<br>);<br><br>CREATE TABLE messages (<br>    message_id SERIAL PRIMARY KEY,<br>    session_id INTEGER REFERENCES sessions(session_id),<br>    sender VARCHAR(50) NOT NULL,<br>    message_text TEXT NOT NULL,<br>    created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP<br>);</pre><p>With these three tables — users, sessions, and messages — we lay a solid foundation for managing chat histories. This structure not only helps in identifying unique users but also enables efficient retrieval of session and message histories. Simple yet powerful, this setup is all you need to start implementing robust chat history management in your chatbot applications. We are able to identify unique users, and if they provide us with a session_id we can retrieve it.</p><h3>Integrating LLMs and Chat History with FastAPI</h3><p><strong>Choosing Your Tech Stack:</strong> While FastAPI is my framework of choice for its speed and ease of use, the concepts outlined here are adaptable across various technologies. You can you follow similar steps in Node, Ruby, Rust, PHP… the list goes on.</p><p><strong>Setting Up Your Database: </strong>With PostgreSQL, we structure our chat history using three key tables: Users, Sessions, and Messages. These tables are crucial for tracking user interactions, session details, and the messages exchanged.</p><p><strong>FastAPI Implementation Overview:</strong></p><ul><li>Database Connection: Utilize databases for asynchronous database interactions, ensuring smooth operation in concurrent environments.</li><li>Model Definitions: Define Pydantic models for structured data exchange. MessageRequest captures incoming messages, while AIResponse handles the LLM&#39;s output.</li><li>API Endpoints: The /send_message endpoint is central to our implementation. It saves incoming messages to the database, interacts with the LLM (e.g., OpenAI) for response generation, and logs the response back in the database.</li></ul><p>So now that we’ve got our table schemas define from before, here is an example of how you might adjust the implementation to send a message and receive a response from an LLM, while managing conversation history with PostgreSQL.</p><blockquote>Note we’re just going to start with saving messages and sessions with raw SQL statements. In practice for a production application you’d likley use an ORM layer like <a href="https://docs.sqlalchemy.org/en/20/intro.html">SQLAlchemy</a>. SQLAlchemy is a layer that simplifies database interactions.</blockquote><pre>from fastapi import FastAPI, HTTPException<br>from pydantic import BaseModel<br>import databases<br>import openai<br>from typing import Optional<br><br>DATABASE_URL = &quot;postgresql://user:password@localhost/dbname&quot;<br>database = databases.Database(DATABASE_URL)<br><br>app = FastAPI()<br><br># Define Pydantic models for request and response data<br>class MessageRequest(BaseModel):<br>    user_id: int<br>    session_id: Optional[int] = None<br>    message: str<br><br>class AIResponse(BaseModel):<br>    ai_response: str<br>    session_id: Optional[int]<br># Initialize OpenAI<br>openai.api_key = &#39;your_openai_api_key&#39;<br><br>@app.on_event(&quot;startup&quot;)<br>async def startup():<br>    await database.connect()<br><br>@app.on_event(&quot;shutdown&quot;)<br>async def shutdown():<br>    await database.disconnect()<br><br>@app.post(&quot;/send_message&quot;, response_model=AIResponse)<br>async def send_message(request: MessageRequest):<br>    query = &quot;INSERT INTO messages (session_id, sender, message_text) VALUES (:session_id, &#39;User&#39;, :message)&quot;<br>    await database.execute(query=query, values={&quot;session_id&quot;: request.session_id, &quot;message&quot;: request.message})<br><br>    ai_response = get_openai_response(request.message)<br><br>    # Record AI&#39;s response<br>    await database.execute(query=query, values={&quot;session_id&quot;: request.session_id, &quot;message&quot;: ai_response})<br><br>    return AIResponse(ai_response=ai_response)<br><br>def get_openai_response(message: str) -&gt; str:<br>    response = openai.Completion.create(<br>        engine=&quot;text-davinci-003&quot;,<br>        prompt=message,<br>        temperature=0.7,<br>        max_tokens=150,<br>        top_p=1,<br>        frequency_penalty=0,<br>        presence_penalty=0<br>    )<br>    return response.choices[0].text.strip()p</pre><p>Okay great! So no we’re just saving messages. But that doesn’t help us keep track of sessions or chat history. Let’s update our send_message endpoint to check if a session_idis provided.</p><pre>@app.post(&quot;/send_message&quot;, response_model=AIResponse)<br>async def send_message(request: MessageRequest):<br>    if request.session_id is None:<br>        # No session_id provided, create a new session<br>        create_session_query = &quot;INSERT INTO sessions (user_id, start_time) VALUES (:user_id, :start_time) RETURNING session_id&quot;<br>        session_id = await database.execute(query=create_session_query, values={&quot;user_id&quot;: request.user_id, &quot;start_time&quot;: datetime.now()})<br>    else:<br>        # Use the provided session_id<br>        session_id = request.session_id<br><br>    # Record user message in the database<br>    insert_message_query = &quot;INSERT INTO messages (session_id, sender, message_text) VALUES (:session_id, &#39;User&#39;, :message)&quot;<br>    await database.execute(query=insert_message_query, values={&quot;session_id&quot;: session_id, &quot;message&quot;: request.message})<br><br>    # Get AI response<br>    ai_response = get_openai_response(request.message)<br><br>    # Record AI&#39;s response in the database<br>    await database.execute(query=insert_message_query, values={&quot;session_id&quot;: session_id, &quot;message&quot;: ai_response})<br><br>    return AIResponse(ai_response=ai_response)</pre><p>Progress is clear: we’re now adeptly saving messages and tracking conversations. You might wonder, ‘How do we ensure conversations continue smoothly, especially when revisiting past interactions?’ A valid concern, indeed! To address this, let’s enhance our setup by incorporating a Pydantic model specifically designed for managing message history.</p><pre>class MessageHistory(BaseModel):<br>    sender: str<br>    message_text: str<br>    created_at: datetime</pre><p>Now once again update our send_message endpoint</p><pre>@app.post(&quot;/send_message&quot;, response_model=AIResponse)<br>async def send_message(request: MessageRequest):<br>    try:<br>        # Manage session existence<br>        if request.session_id is None:<br>            query = &quot;&quot;&quot;<br>            INSERT INTO sessions (user_id, start_time) VALUES (:user_id, CURRENT_TIMESTAMP)<br>            RETURNING session_id<br>            &quot;&quot;&quot;<br>            session_id = await database.execute(query=query, values={&quot;user_id&quot;: request.user_id})<br>        else:<br>            session_id = request.session_id<br><br>        # Fetch the last 5 messages for context - helps maintain continuity<br>        recent_messages_query = &quot;&quot;&quot;<br>        SELECT sender, message_text FROM messages<br>        WHERE session_id = :session_id<br>        ORDER BY created_at DESC<br>        LIMIT 5<br>        &quot;&quot;&quot;<br>        recent_messages = await database.fetch_all(query=recent_messages_query, values={&quot;session_id&quot;: session_id})<br>        <br>        # Building context from recent messages<br>        context = &quot; &quot;.join([f&quot;{message[&#39;sender&#39;]}: {message[&#39;message_text&#39;]}&quot; for message in recent_messages])<br><br>        # Forming a context-rich prompt for the LLM<br>        full_prompt = f&quot;{context} User: {request.message}&quot;<br>        ai_response = get_openai_response(full_prompt)<br><br>        # Logging the conversation<br>        messages_query = &quot;&quot;&quot;<br>        INSERT INTO messages (session_id, sender, message_text)<br>        VALUES (:session_id, :sender, :message_text)<br>        &quot;&quot;&quot;<br>        await database.execute(query=messages_query, values={&quot;session_id&quot;: session_id, &quot;sender&quot;: &quot;User&quot;, &quot;message_text&quot;: request.message})<br>        await database.execute(query=messages_query, values={&quot;session_id&quot;: session_id, &quot;sender&quot;: &quot;AI&quot;, &quot;message_text&quot;: ai_response})<br><br>        return AIResponse(ai_response=ai_response, session_id=session_id)<br>    except Exception as e:<br>        # Consider more specific exception handling and logging<br>        raise HTTPException(status_code=500, detail=str(e))</pre><p>By including the sender’s role in the context string, you give the LLM additional information about the dialogue structure, which can help it generate responses that are more in line with the conversation’s flow. This approach mimics a more natural conversation pattern, where each participant’s contributions are clearly identified.</p><h3><strong>Complete Solution</strong></h3><p>And there you have it! If you’ve skimmed down to this point, no worries. We’ve outlined a straightforward chatbot utilizing minimal external tools, ensuring clarity without resorting to complex abstractions (🦜). This foundational setup equips you with everything necessary to develop a session-based chatbot designed to enhance user experience without causing frustration.</p><p>Consider this a starting point. As you become more comfortable, you can expand and tailor the chatbot to meet specific needs or incorporate advanced features. Remember, the core principles covered here are adaptable and scalable.</p><p>I hope this guide proves useful in your development journey. Should you encounter any challenges or wish to share feedback, I’m happy to help and connect!</p><pre>from fastapi import FastAPI, HTTPException<br>from pydantic import BaseModel<br>from typing import Optional<br>import databases<br>import openai<br>from datetime import datetime<br><br>DATABASE_URL = &quot;postgresql://user:password@localhost/dbname&quot;<br>database = databases.Database(DATABASE_URL)<br><br>app = FastAPI()<br><br># Define Pydantic models for structured data exchange<br>class MessageRequest(BaseModel):<br>    user_id: int<br>    session_id: Optional[int] = None<br>    message: str<br><br>class AIResponse(BaseModel):<br>    ai_response: str<br>    # Ensure the session_id is included in the response for continuity<br>    session_id: Optional[int]<br><br># Model for tracking message history not immediately used but defined for future expansion<br>class MessageHistory(BaseModel):<br>    sender: str<br>    message_text: str<br>    created_at: datetime<br><br># Initialize OpenAI with your API key<br>openai.api_key = &#39;your_openai_api_key&#39;<br><br>@app.on_event(&quot;startup&quot;)<br>async def startup():<br>    await database.connect()<br><br>@app.on_event(&quot;shutdown&quot;)<br>async def shutdown():<br>    await database.disconnect()<br><br>@app.post(&quot;/send_message&quot;, response_model=AIResponse)<br>async def send_message(request: MessageRequest):<br>    session_id = request.session_id<br>    if session_id is None:<br>        # Create a new session if one is not provided<br>        session_query = &quot;INSERT INTO sessions (user_id, start_time) VALUES (:user_id, CURRENT_TIMESTAMP) RETURNING session_id&quot;<br>        session_id = await database.execute(query=session_query, values={&quot;user_id&quot;: request.user_id})<br><br>    # Fetch recent messages for context<br>    recent_messages_query = &quot;&quot;&quot;<br>    SELECT sender, message_text FROM messages<br>    WHERE session_id = :session_id<br>    ORDER BY created_at DESC<br>    LIMIT 5<br>    &quot;&quot;&quot;<br>    recent_messages = await database.fetch_all(query=recent_messages_query, values={&quot;session_id&quot;: session_id})<br><br>    # Build context from recent messages<br>    context = &quot; &quot;.join([f&quot;{message[&#39;sender&#39;]}: {message[&#39;message_text&#39;]}&quot; for message in recent_messages])<br><br>    # Create a context-rich prompt for the LLM<br>    full_prompt = f&quot;{context} User: {request.message}&quot;<br>    ai_response = get_openai_response(full_prompt)<br><br>    # Log the conversation in the database<br>    message_query = &quot;INSERT INTO messages (session_id, sender, message_text) VALUES (:session_id, :sender, :message_text)&quot;<br>    await database.execute(query=message_query, values={&quot;session_id&quot;: session_id, &quot;sender&quot;: &quot;User&quot;, &quot;message_text&quot;: request.message})<br>    await database.execute(query=message_query, values={&quot;session_id&quot;: session_id, &quot;sender&quot;: &quot;AI&quot;, &quot;message_text&quot;: ai_response})<br><br>    # Return the AI&#39;s response along with the current session_id for continuity<br>    return AIResponse(ai_response=ai_response, session_id=session_id)<br><br>def get_openai_response(message: str) -&gt; str:<br>    response = openai.Completion.create(<br>        engine=&quot;text-davinci-003&quot;,<br>        prompt=message,<br>        temperature=0.7,<br>        max_tokens=150,<br>        top_p=1,<br>        frequency_penalty=0,<br>        presence_penalty=0<br>    )<br>    return response.choices[0].text.strip()</pre><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=e6bb2a5ff73e" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Simplifying RAG with PostgreSQL and PGVector]]></title>
            <link>https://medium.com/@levi_stringer/rag-with-pg-vector-with-sql-alchemy-d08d96bfa293?source=rss-c6cd7b83742b------2</link>
            <guid isPermaLink="false">https://medium.com/p/d08d96bfa293</guid>
            <category><![CDATA[postgres]]></category>
            <category><![CDATA[sqlalchemy]]></category>
            <category><![CDATA[machine-learning]]></category>
            <category><![CDATA[large-language-models]]></category>
            <category><![CDATA[retrieval-augmented]]></category>
            <dc:creator><![CDATA[Levi Stringer]]></dc:creator>
            <pubDate>Tue, 23 Jan 2024 05:16:15 GMT</pubDate>
            <atom:updated>2024-07-31T03:15:59.465Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/400/1*PqbS5XW0GcHXSeJqwa0s_A.png" /><figcaption>Image generated by DALLE Jan 2024</figcaption></figure><p>Authors: <a href="https://www.linkedin.com/in/levistringer/">Levi Stringer</a></p><p>Source code: <a href="https://github.com/levi-katarok/simplified-rag">https://github.com/levi-katarok/simplified-rag</a></p><p>Building an application powered by Retrieval Augmented Generation (RAG) can be difficult, time-consuming, and expensive. Spending a lot of time in the LLM space, you begin to crave simplicity and avoid bloated libraries. When building a RAG application, I wanted only the necessities to get started. To achieve this I’ve began using PGVector within SQL, PostgresSQL is a tried and true database and if you’re like me and don’t want manage several database providers and wanted to get started quickly. These are the preliminary steps in getting the right context to give to an LLM. Here is a fantastic article if you’re unfamilar with the concepts of <a href="https://medium.com/enterprise-rag/a-first-intro-to-complex-rag-retrieval-augmented-generation-a8624d70090f">RAG</a>.</p><h4>Goal</h4><p>Develop a basic application that:</p><ol><li>Extracts text from documents (e.g., PDFs).</li><li>Converts text into embeddings using a pre-trained model.</li><li>Stores embeddings in a PostgreSQL database using PG Vector.</li><li>Queries the database to find documents whose embeddings are most similar to the embedding of a query text.</li></ol><p>This post outlines of transforming textual content from PDF documents into vectorized forms and querying them for similarity, using <strong>PostgreSQL</strong> with <strong>PG Vector</strong> and <strong>SQLAlchemy.</strong></p><p>This will be useful for those who prefer sticking to a single database like PostgreSQL and avoiding the overhead of juggling multiple database systems. We’ll be exploring a straightforward example that demonstrates how to create embeddings from text data and utilize PostgreSQL for efficient querying. A simplified overview can be seen below.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/780/1*GxxOqQ8njKAmVGUxC294aQ.png" /><figcaption>Interaction between components (Application, PostgreSQL, OpenAI)</figcaption></figure><h3>Setup and Requirements</h3><ul><li><strong>PostgreSQL Database:</strong> Make sure you have PostgreSQL installed and running. This method assumes you’re working with a local or remote PostgreSQL instance where you have the privileges to install extensions and create tables. Ensure you have <strong>PG Vector installed</strong> in your PostgreSQL database. This usually <strong>requires superuser access</strong>. If you’re not sure whether PG Vector is installed, you can check by connecting to your PostgreSQL database and running:</li></ul><pre>CREATE EXTENSION IF NOT EXISTS vector;</pre><ul><li><strong>Python Environment: </strong>A Python environment (preferably Python 3.6 or newer) is essential for running the scripts. I highly recommend using a virtual environment for your project to manage dependencies efficiently and avoid conflicts with system-wide packages. If you’re unfamiliar with Python virtual environments, they allow you to create isolated spaces on your machine, tailor-made for your project’s requirements. To set up a virtual environment, run:</li></ul><pre>python3 -m venv myenv<br>source myenv/bin/activate  # On Windows, use `myenv\Scripts\activate`</pre><ul><li><strong>Libraries and Modules:</strong></li><li>sqlalchemy for interacting with the PostgreSQL database.</li><li>pgvector.sqlalchemy for integrating PG Vector with SQLAlchemy.</li><li>openai for accessing OpenAI&#39;s GPT models for vectorization. (Read embedding model description below)</li><li>pypdf for reading PDF documents.</li><li>pandas for handling data manipulations.</li><li>numpy for numerical operations.</li></ul><p><strong><em>Note</em></strong><em>: You can do this with simple Python lists instead of pandas and numpy. In general NumPy arrays use less memory than Python lists for numerical calculations. We aren’t doing any calculations on our embeddings here, I’ve just gotten in the habit of using them.</em></p><ul><li><strong>Embedding Model: </strong>I’ve used OpenAI’s<a href="https://platform.openai.com/docs/guides/embeddings/what-are-embeddings"> text-embedding-ada-002 </a>model. You’ll need an API key from OpenAI to use their models for generating embeddings for this example. You can can sign up and get an OpenAI API key <a href="https://openai.com/blog/openai-api">here</a>. Note this process will work for <strong>any embedding model you like</strong>. The choice of embedding model, such as OpenAI’s text-embedding-ada-002, affects the dimensionality and quality of your data embeddings. Select a model that aligns with your application&#39;s requirements for the best results. Some other paid ones include <a href="https://cohere.com/embeddings">Cohere</a>, <a href="https://huggingface.co/models?other=embeddings">Hugging Face</a>, <a href="https://cloud.google.com/vertex-ai/docs/generative-ai/embeddings/get-text-embeddings">Google’s Vertex AI</a>. With a list of open source ones <a href="https://www.graft.com/blog/open-source-text-embedding-models">here</a>.</li></ul><p>To install these libraries :</p><pre>pip install numpy pandas sqlalchemy pgvector.sqlalchemy openai pypdf</pre><ul><li>When using embedding models, it’s vital to adjust the N_DIM parameter to match the dimensionality of the embeddings produced by your chosen model. For OpenAI&#39;s text-embedding-ada-002 model, the dimension is 1536. This information is crucial when defining your database schema to store embeddings correctly. If you opt for a different model, consult the model&#39;s documentation to find the correct dimensionality and adjust the N_DIM in your code accordingly. This ensures that your database is correctly set up to store the vector data without loss or truncation.</li></ul><h3>Troubleshooting Common Issues</h3><p>As you embark on setting up your RAG application, you might encounter a few common hurdles:</p><ol><li><strong>PG Vector Installation Issues:</strong> If you run into problems while installing PG Vector, ensure you have the necessary permissions on your PostgreSQL instance. Sometimes, cloud-hosted databases require specific steps to enable extensions. Check your hosting provider’s documentation for details.</li><li><strong>Python Dependency Conflicts:</strong> In case of conflicts between installed libraries, ensure that you’re using a virtual environment as recommended. If issues persist, try updating your packages to their latest versions, or consult the package documentation for compatibility information.</li><li><strong>API Key Security:</strong> Keep your OpenAI API key secure. Avoid hard-coding it into your scripts. Instead, use environment variables or secure vaults to store sensitive information. <strong>Don’t push your API key to Github!</strong></li><li><strong>Database Connection Errors:</strong> Ensure your database connection strings are correct. Connection issues often arise from incorrect credentials, hostnames, or firewall settings blocking access to your database.</li><li><strong>Model Dimensionality Mismatch:</strong> If you encounter errors related to the size of the data being inserted into the database, double-check the N_DIM parameter against your embedding model&#39;s output dimensionality. Mismatches here can lead to data truncation or insertion failures.</li></ol><h3>Part 1: Extracting Text</h3><p>For this example I’ve chosen to demonstrate how to extract text from a PDF. After Part 1, Step 1 the process remains exactly the same for text extracted from any document format;<em>.txt,</em> .<em>docx</em>, .<em>html</em>, etc.. So for us the first part of our process involves reading a PDF document and extracting its text. This is a crucial step as the extracted text will be used for generating embeddings.</p><h4>Step 1: Reading a PDF Document</h4><p>We’ll use the pypdf library to read a PDF document from your local machine. PDF parsing has been battleground for a long time, and there many many different libraries who can do it (this <a href="https://medium.com/analytics-vidhya/python-packages-for-pdf-data-extraction-d14ec30f0ad0">post</a> outlines a few). I chose pypdf as I found it had the largest community support.</p><pre>from pypdf import PdfReader<br><br>def read_pdf(file_path):<br>    pdf_reader = PdfReader(file_path)<br>    text = &quot;&quot;<br><br>    for page in pdf_reader.pages:<br>        extracted_text = page.extract_text()<br>        if extracted_text:  # Check if text is extracted successfully<br>            text += extracted_text + &quot;\n&quot;  # Append text of each page<br><br>    return text<br><br># Example usage<br>pdf_text = read_pdf(&quot;path_to_your_pdf.pdf&quot;)<br>print(pdf_text)</pre><h4>Step 2: Splitting Text into Manageable Chunks</h4><p>After extracting the text, we’ll split it into smaller, manageable chunks. This is particularly useful for large documents, as embedding generation typically has a limit on the text length. For this tutorial I’ve just used a naive chunking strategy, this <a href="https://www.pinecone.io/learn/chunking-strategies/">article</a> by <a href="https://www.linkedin.com/in/roiecohen/">Roie Schwaber-Cohen</a> outlines why and when different strategies should be used. <strong>TLDR</strong>, it will take some experimentation to figure out the best way to chunk your documents.</p><pre>def split_text(text, chunk_size=500, overlap=50):<br>    chunks = []<br>    start = 0<br>    end = 0<br><br>    while end &lt; len(text):<br>        end = start + chunk_size<br>        if end &gt; len(text):<br>            end = len(text)<br>        chunks.append(text[start:end])<br>        start = end - overlap  # Overlap chunks<br><br>    return chunks<br><br># Example usage<br>text_chunks = split_text(pdf_text)</pre><h3>Part 2: Generating Text Embeddings</h3><p>After splitting the text into manageable chunks, we’ll generate embeddings for each chunk using OpenAI’s text embedding model. This involves sending the chunks to the model and receiving a vector representation for each. The vector dimension for OpenAI embeddings is</p><pre>import openai<br><br>openai.api_key = &#39;your_openai_api_key&#39;<br><br>def generate_embeddings(text_chunks):<br>    embeddings = []<br>    for chunk in text_chunks:<br>        response = openai.Embedding.create(<br>            input=chunk,<br>            model=&quot;text-embedding-ada-002&quot;<br>        )<br>        embeddings.append(response[&#39;data&#39;][0][&#39;embedding&#39;])<br>    return embeddings</pre><h3>Part 3: Storing Embeddings in PostgreSQL</h3><p>To store the embeddings in PostgreSQL, we’ll first need to ensure the PG Vector extension is enabled in our database. Then, using SQLAlchemy and pgvector.sqlalchemy, we’ll create a table to hold our text embeddings. Typically you’ll also want to store the content in some fashion to augment your LLM prompt. This can be useful for citations as well. I used N_DIM=1526 here as that is the output dimension of OpenAI’s embedding model.</p><pre>from sqlalchemy import create_engine, Column, Integer, String<br>from sqlalchemy.ext.declarative import declarative_base<br>from sqlalchemy.orm import sessionmaker<br>from pgvector.sqlalchemy import Vector<br>import numpy as np<br><br>Base = declarative_base()<br>N_DIM = 1536<br><br>class TextEmbedding(Base):<br>    __tablename__ = &#39;text_embeddings&#39;<br>    id = Column(Integer, primary_key=True, autoincrement=True)<br>    content = Column(String)<br>    embedding = Vector(N_DIM)<br><br># Connect to PostgreSQL<br>engine = create_engine(&#39;postgresql://user:password@localhost/dbname&#39;)<br>Base.metadata.create_all(engine)<br><br># Create a session<br>Session = sessionmaker(bind=engine)<br>session = Session()</pre><p>To insert embeddings:</p><pre>def insert_embeddings(embeddings):<br>    for embedding in embeddings:<br>        new_embedding = TextEmbedding(embedding=embedding)<br>        session.add(new_embedding)<br>    session.commit()</pre><h3>Part 4: Querying for Similar Text Embeddings</h3><p>Finally, we’ll query the stored embeddings to find similar text. PG Vector supports various similarity and distance metrics that can be leveraged for this purpose.</p><p>In the find_similar_embeddings function, k and similarity_threshold are parameters used to refine the search for embeddings similar to a given query:</p><ul><li>k (limit=5): Specifies the maximum number of similar embeddings to retrieve. Setting k = 5 means the query will return at most five embeddings that meet the similarity criteria. It controls the breadth of the search results, balancing between relevance and quantity.</li><li>similarity_threshold (0.7): Defines how similar an embedding needs to be to the query embedding to be considered a match, based on cosine distance. A threshold of 0.7 filters for embeddings that are sufficiently similar to the query, with lower values indicating greater distance (less similarity) and higher values indicating closer proximity (more similarity). This parameter helps ensure the relevance of the results to the query.</li></ul><p>Together, k and similarity_threshold fine-tune the search by determining the quantity and relevance of the returned embeddings, allowing for a balanced retrieval based on the specific needs of the application.</p><pre>def find_similar_embeddings(query_embedding, limit=5):<br>    k = 5<br>    similarity_threshold = 0.7<br>    query = session.query(TextEmbedding, TextEmbedding.embedding.cosine_distance(query_embedding)<br>            .label(&quot;distance&quot;))<br>            .filter(TextEmbedding.embedding.cosine_distance(query_embedding) &lt; similarity_threshold)<br>            .order_by(&quot;distance&quot;)<br>            .limit(k)<br>            .all()</pre><p>This method allows you to pass a query embedding and retrieve the most similar embeddings from the database, based on a specified similarity threshold and ordered by similarity.</p><h3>Conclusion</h3><p>By following these steps, you can efficiently transform textual content into vectorized forms and perform similarity queries using PostgreSQL, PG Vector, and SQLAlchemy. Hopefullt this setup simplifies the architecture for applications requiring text similarity searches while leveraging a free and database such as PostgreSQL.</p><p>To further enhance this solution, consider integrating more advanced text preprocessing, experimenting with different embedding models, and optimizing database queries for performance.</p><p>Remember to replace placeholder values (e.g., database connection strings, OpenAI API key) with your actual configuration details.</p><p>Please share your thoughts, questions, or any corrections in the comments below. I hope this was helpful!</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=d08d96bfa293" width="1" height="1" alt="">]]></content:encoded>
        </item>
    </channel>
</rss>