<?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 Pranav Dhoolia on Medium]]></title>
        <description><![CDATA[Stories by Pranav Dhoolia on Medium]]></description>
        <link>https://medium.com/@pranavdhoolia?source=rss-595481777489------2</link>
        <image>
            <url>https://cdn-images-1.medium.com/fit/c/150/150/1*ESbWIV_RcpIfQtLa4fObVw.png</url>
            <title>Stories by Pranav Dhoolia on Medium</title>
            <link>https://medium.com/@pranavdhoolia?source=rss-595481777489------2</link>
        </image>
        <generator>Medium</generator>
        <lastBuildDate>Sat, 14 Nov 2026 02:57:03 GMT</lastBuildDate>
        <atom:link href="https://medium.com/@pranavdhoolia/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[How To Cut Your API Spend by 61% Without Removing a Single Tool]]></title>
            <link>https://medium.com/@pranavdhoolia/how-to-cut-your-api-spend-by-61-without-removing-a-single-tool-e1a16e9fbd8a?source=rss-595481777489------2</link>
            <guid isPermaLink="false">https://medium.com/p/e1a16e9fbd8a</guid>
            <category><![CDATA[lead-generation]]></category>
            <category><![CDATA[automation]]></category>
            <category><![CDATA[b2b-sales]]></category>
            <category><![CDATA[ai-agent]]></category>
            <category><![CDATA[sales-automation]]></category>
            <dc:creator><![CDATA[Pranav Dhoolia]]></dc:creator>
            <pubDate>Tue, 24 Feb 2026 02:57:04 GMT</pubDate>
            <atom:updated>2026-02-24T02:57:04.387Z</atom:updated>
            <content:encoded><![CDATA[<h4><em>8 Meetings vs. 24 Meetings: The Only Change Was Adding 4 Constraints</em></h4><p>So you deployed an AI SDR. The one that was supposed to replace two headcount and pay for itself in 90 days. Feels good, right?</p><p>Here’s the thing. It’s probably doing something really stupid right now. And you won’t know until your domain reputation is shot or your pipeline has gone cold.</p><blockquote>Companies deploy AI SDRs expecting them to be smarter than the sales reps they’re replacing. They’re faster, sure. They’re also dumber.</blockquote><p>I recently looked at a case that made me want to bang my head against the wall. A Series A B2B SaaS company deployed an AI SDR to handle outbound prospecting. The pitch was compelling: automated lead research, personalized email sequences, CRM updates, and meeting booking. Six weeks after launch, the system was live.</p><p>Three months later? They were spending $18,400 a month in API fees and tool subscriptions, burning through sending domains, and watching their reply rates crater from 12% to 3.2%. The sales team was spending more time cleaning up the AI’s mess than they were before the tool existed.</p><p>They eventually fixed it-recovering $156,000 in annual savings and tripling their qualified pipeline. But only after they recognized the AI is a process that needs guardrails.</p><h3>The Pitch vs. The Reality</h3><p>Let me set the scene. This is a 45-person company with a 4-person sales team trying to punch above their weight. They were doing what every early-stage startup does: grinding through manual prospecting, researching accounts on LinkedIn, writing custom emails, and logging everything in HubSpot.</p><p>The plan was to build an AI SDR that could handle the grunt work. It had access to six tools: a lead enrichment API, LinkedIn data, their CRM, an email sending service, a calendar booking tool, and a domain health monitor.</p><p>It looked incredible. The agent researched a prospect, pulled company news, wrote a personalized email, scheduled a follow-up sequence, and logged everything in HubSpot-all in under 30 seconds.</p><p>The founders high-fived. The sales team got excited. They shipped it to production.</p><p>For the first two weeks, volume exploded. The AI was sending 400 emails a day across five sending domains. Meetings started appearing on calendars. This is the dream, right?</p><p>Then the numbers started telling a different story. Reply rates collapsed. Bounce rates spiked. Gmail started throttling their domains. And the sales team noticed something troubling: the “personalized” emails weren’t always… accurate.</p><h3>The Hidden Costs</h3><p>When they finally audited the system, the numbers were brutal.</p><p>The agent had a <strong>34% task success rate</strong>. Let that sink in. Two out of three outbound sequences had at least one critical failure-wrong data, bad personalization, or emails that never reached the inbox.</p><p>Here’s the breakdown:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/782/1*ONujZPaF3eZEFBUU5zS9Lw.png" /></figure><p>The real killer was the domain damage. <a href="https://www.landbase.com/blog/low-email-deliverability-heres-how-ai-protects-your-domain-health">Email deliverability for high-volume senders dropped from 50% to 28%</a> between Q1 2024 and Q1 2025. One spam trap hit can cut your deliverability by 50% overnight, and recovery takes months. Months!</p><p>The company estimated they were losing <strong>$12,000 a month</strong> in opportunity cost from leads that never saw their emails, plus another <strong>$4,200 a month</strong> in sales rep time spent manually fixing CRM records and rewriting AI-generated emails that didn’t make sense.</p><h3>Finding the Failures</h3><p>Here’s the frustrating part. Production logs are useless for debugging AI agents. You can see individual API calls and error messages, but you can’t see the pattern. You can’t figure out why the agent decided to recommend a German-speaking candidate for an English-only sales role.</p><p>So, they ran a structured evaluation using synthetic simulation. They generated 200 outbound scenarios-everything from cold prospects to warm inbound leads to re-engagement sequences-and ran the agent through a gauntlet.</p><p>The simulation exposed four structural problems hiding in plain sight. And honestly? These are problems I see everywhere.</p><h3>1. The “Spray and Pray” Enrichment Problem</h3><p>The agent was calling the lead enrichment API, LinkedIn API, and company news API <strong>for every single prospect</strong>-even when the CRM already had complete data.</p><p>Think about that for a second. In 62% of cases, the agent was paying for data it already had. It would pull fresh LinkedIn info for a contact that was enriched last week. It would call the company news API for a prospect whose company page hadn’t been updated in six months.</p><p>This single inefficiency was burning <strong>$6,200 a month</strong> in redundant API calls. The agent wasn’t checking what it already knew before going out and asking the internet. Classic.</p><h3>2. The “German Email” Problem</h3><p>The email personalization was based on a language model that had no constraints on what “personalized” meant. The agent was pulling whatever data seemed interesting and weaving it into the email.</p><p>In 18% of cases, this led to embarrassing errors:</p><ul><li>Referencing a prospect’s previous company instead of their current one</li><li>Mentioning a job posting that had been filled six months ago</li><li>Writing in the wrong language because it pulled the prospect’s native language from LinkedIn instead of checking the company’s operating language</li></ul><p>The system prompt said “write personalized emails.” It didn’t say “verify the data is current” or “match the language to the target market.” You see the problem?</p><h3>3. The “Burned Domain” Problem</h3><p>The agent had no concept of sending limits or domain rotation. It was configured to “send emails” and it did exactly that-blasting through 400 emails a day from a single domain until Gmail and Microsoft flagged it as spam.</p><p>Industry data is clear: the safe ceiling is <a href="https://salesmotion.io/blog/ai-sales-email-deliverability-limits">200 emails per mailbox per day</a>. Anything above that triggers spam filters. The agent didn’t know this. It just kept sending until the domain was toast.</p><p>By the time the team noticed, three of their five sending domains had deliverability scores below 30%. They had to retire those domains entirely-a process that takes 6–12 months of warm-up to recover. That’s not a typo. Six to twelve months.</p><h3>4. The “CRM Graveyard” Problem</h3><p>The agent was supposed to update HubSpot with activity logs and lead status changes. But <a href="https://www.uptrends.com/state-of-api-reliability-2025">8–10% of the time</a>, the CRM API would return a partial success or timeout during high-traffic periods.</p><p>When that happened, the agent treated it as <em>done</em> and moved on. It didn’t verify the write. It didn’t retry. It just assumed success.</p><p>This created a CRM that was technically populated but practically useless. Sales reps would open a contact, see incomplete activity history, and have no idea what the AI had already sent. They’d send duplicate emails, creating the exact “spray and pray” experience the AI was supposed to prevent. Ironic, isn’t it?</p><h3>The Fix: Constraints Over Creativity</h3><p>The solution was better structure.</p><p><strong>1. Cache-First Enrichment</strong></p><p>They added a checkpoint before every enrichment call. The agent now checks the CRM first: When was this record last enriched? If it’s within 30 days, skip the API call. This alone saved 17,000 API calls per month.</p><p><strong>2. Data Validation Gates</strong></p><p>They added explicit validation rules to the email generation step. Before drafting an email, the agent must verify:</p><ul><li>Current company (not previous employer)</li><li>Job title last updated within 90 days</li><li>Company operating language matches email language</li></ul><p>If any check fails, the agent flags the record for human review instead of sending a broken email.</p><p><strong>3. Domain Throttling Rules</strong></p><p>They hardcoded sending limits into the system prompt. Each domain is capped at 150 emails per day. The agent must rotate domains based on daily send volume. If all domains are at capacity, it queues the email for the next day instead of force-sending.</p><p><strong>4. Write Verification</strong></p><p>They added a retry-with-verification pattern for CRM writes. After every HubSpot update, the agent reads back the record to confirm the write succeeded. If the data doesn’t match, it retries up to three times before escalating to a human.</p><p>Simple stuff, really. But nobody thought to build it in from the start.</p><h3>The Results</h3><p>The impact was immediate.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/858/1*cBHGEGH0oyxFqav7dYvzXw.png" /></figure><p><strong>Total annualized savings: $156,000</strong> (including recovered pipeline value and reduced tool spend).</p><p>The reply rate increase alone changed the unit economics of their outbound motion. They went from needing 312 emails to book one meeting to needing 102. That’s a 3x improvement in efficiency without changing their ICP or messaging. Same playbook, just not sabotaged by their own tool.</p><h3>Why This Keeps Happening</h3><p>I see this pattern constantly. Companies deploy AI SDRs expecting them to be smarter than the sales reps they’re replacing. They’re faster, sure. They’re also dumber.</p><p><a href="https://www.commonroom.io/blog/common-room-ai-agent-pipeline-generation/">Research shows that 50–70% of AI SDR tools churn</a>-double the rate of human SDRs. <a href="https://agentiveaiq.com/blog/how-much-will-ai-agents-cost-in-2025">66% of IT leaders report AI cost overruns</a>, and <a href="https://agentiveaiq.com/blog/how-much-will-ai-agents-cost-in-2025">53% of AI teams see costs exceed forecasts by 40% or more</a> during scaling.</p><p>The problem is the assumption that the model will figure out the process. It won’t. You need to tell it:</p><ul><li>What to check before calling an API</li><li>When to stop sending emails</li><li>How to verify its own work</li><li>When to ask a human for help</li></ul><p>Without those constraints, you’re deploying an automated spam machine with your brand name attached to it.</p><h3>Is Your AI SDR Actually Working?</h3><p>If you have an AI handling outbound right now, ask yourself:</p><ul><li>Do you know your actual inbox placement rate?</li><li>Is your CRM data more accurate since deploying the agent, or less?</li><li>How many API calls is the agent making per prospect? If it’s more than 4, you’re probably wasting money.</li><li>What happens when a CRM write fails? Does the agent retry, or does it move on?</li></ul><p>If you don’t know the answers, your AI is probably costing you more than it’s saving. The demo looked great. The logs might even look fine. But the pipeline tells the real story.</p><blockquote><strong><em>Want to know what your AI SDR is actually doing?</em></strong></blockquote><blockquote><em>I’ll build a custom benchmark for your agent (200+ synthetic scenarios covering everything from cold outreach to re-engagement sequences) and run it through a structured gauntlet.</em></blockquote><blockquote><em>You get back scored, annotated traces showing every wrong turn: the redundant API calls, the validation gaps, the emails that burned your domain. Plus a prioritized fix list of updated prompts, tighter tool schemas, and the workflow constraints that turn an automated spam machine into a reliable pipeline engine.</em></blockquote><blockquote><strong><em>Book a meeting on </em></strong><a href="https://calendly.com/pranav-dhoolia/30min"><strong><em>https://calendly.com/pranav-dhoolia/30min</em></strong></a></blockquote><h3>Sources</h3><ul><li><a href="https://www.commonroom.io/blog/common-room-ai-agent-pipeline-generation/">The AI SDR is Dead, Long Live the AI SDR</a></li><li><a href="https://agentiveaiq.com/blog/how-much-will-ai-agents-cost-in-2025">AI Agent Cost in 2025: Hidden Expenses</a></li><li><a href="https://superrep.ai/blog/state-of-ai-sdr">State of AI SDR 2025</a></li><li><a href="https://www.uptrends.com/state-of-api-reliability-2025">The State of API Reliability 2025</a></li><li><a href="https://www.landbase.com/blog/low-email-deliverability-heres-how-ai-protects-your-domain-health">Email Deliverability in 2025: AI &amp; Domain Health</a></li><li><a href="https://salesmotion.io/blog/ai-sales-email-deliverability-limits">AI Sales Email Deliverability Limits</a></li><li><a href="https://composio.dev/blog/ai-agent-sales-integration-failures">Why AI Recruiting Breaks: CRM Integration Failures</a></li><li><a href="https://dev.to/imshashank/the-hidden-costs-of-inefficient-ai-agents-and-how-to-fix-them-2k3d">The Hidden Costs of Inefficient AI Agents</a></li><li><a href="https://www.langchain.com/state-of-agent-engineering">State of AI Agents Report | LangChain</a></li><li><a href="https://www.anthropic.com/engineering/writing-tools-for-agents">Writing Effective Tools for AI Agents | Anthropic</a></li><li><a href="https://www.cirrusinsight.com/blog/ai-in-sales">AI in Sales 2025: Statistics &amp; Trends</a></li><li><a href="https://www.pragmaticcoders.com/resources/ai-agent-statistics">200+ AI Agent Statistics for 2025</a></li></ul><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=e1a16e9fbd8a" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[How to Tell If Your AI Agent Costs More Than the Team It Replaced]]></title>
            <link>https://medium.com/@pranavdhoolia/how-to-tell-if-your-ai-agent-costs-more-than-the-team-it-replaced-60ad57fb9265?source=rss-595481777489------2</link>
            <guid isPermaLink="false">https://medium.com/p/60ad57fb9265</guid>
            <category><![CDATA[llm-evaluation]]></category>
            <category><![CDATA[large-language-models]]></category>
            <category><![CDATA[ai-tools]]></category>
            <category><![CDATA[ai-agents-in-action]]></category>
            <category><![CDATA[ai-agent]]></category>
            <dc:creator><![CDATA[Pranav Dhoolia]]></dc:creator>
            <pubDate>Tue, 03 Feb 2026 11:21:46 GMT</pubDate>
            <atom:updated>2026-02-03T11:21:46.283Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*ZJrIuZ5YVqM6YHxuhBWf0A.jpeg" /></figure><blockquote><em>On paper, it was perfect. But within a month, they were babysitting a piece of software!</em></blockquote><p>Everyone loves a slick demo. You watch the AI agent effortlessly glide through tasks-checking inventory, updating suppliers, tracking shipments-and you think, “Finally, we can stop drowning in busywork.”</p><p>That was the exact headspace of a Series B logistics company recently. They had about 80 employees, they were growing fast, and their operations team was absolutely buried under repetitive tasks. The solution seemed obvious: build an internal AI agent to automate the workflow.</p><p>The team ran the numbers, and the results were brutal. Before the agent, the team spent 35 hours a week on routine tasks. After the agent? They were spending 28 hours a week debugging errors and manually fixing what the bot broke.</p><p>The tool that was supposed to save them was actually <strong>costing them $277,000 annually</strong> in wasted labor and API fees. Here is how they found the hidden costs-and fixed them.</p><h3>The Promise: Automate Everything</h3><p>Let’s look at why this company was the perfect candidate for automation. They had an operations team stuck in a loop of “status check” hell. They were constantly querying inventory, tracking shipments across three different carriers (FedEx, UPS, DHL), and messaging suppliers.</p><p>The plan was to connect an AI agent to their entire tech stack. We’re talking about <strong>eight different tools</strong> spanning three external vendors. The logic was simple: let the agent handle the routine queries so the humans could focus on strategic problems, like why shipping costs were spiking or how to optimize routes.</p><p>And for the first few weeks, it looked like it worked. The agent could answer questions. It could update the dashboard. But the gap between “it works in a demo” and “it works in production” is massive. In fact, <a href="https://fortune.com/2025/08/18/mit-report-95-percent-generative-ai-pilots-at-companies-failing-cfo/">research from MIT</a> found that <strong>95% of AI pilots deliver no measurable P&amp;L impact</strong>, largely because AI tools fail to adapt to enterprise workflows. Additionally, <a href="https://fortune.com/2025/08/18/mit-report-95-percent-generative-ai-pilots-at-companies-failing-cfo/">only 33% of in-house AI builds reach successful deployment</a>, compared to 67% for externally purchased solutions.</p><p>This company was about to become part of that statistic.</p><h3>The Irony: The Tool That Was Supposed to Save Time</h3><p>The problem wasn’t that the agent stopped working. It was that it kept working <strong>incorrectly</strong>, and it did so with total confidence.</p><ul><li>Shipments were being tracked with the wrong carriers</li><li>Inventory updates were failing silently, meaning the system thought products were in stock when they weren’t</li><li>Supplier messages went out with incomplete data</li></ul><p>The alerting system-designed to warn humans only when things went wrong-started firing off dozens of false alarms a day.</p><p>When the ops manager looked at the timesheets, the irony was painful. The team was spending nearly as much time fixing the agent’s mess as they used to spend doing the actual work. But now, that work was frustrating, high-stress debugging rather than simple data entry.</p><p>The engineering team had logs-thousands of them. They could see individual API calls and error messages. But they couldn’t see the pattern. They couldn’t figure out why the agent was making these expensive choices.</p><p>To fix it, they needed to visualize the mess. They used something called <strong>tool graph analysis</strong>.</p><h3>The Diagnostic Breakthrough</h3><p>A tool graph maps the chain of events across all an agent’s tools. It shows you dependencies-which tool calls rely on previous ones-and reveals where errors propagate.</p><p>When they visualized the agent’s behavior, they found three massive money pits.</p><h3>Problem 1: The “Shotgun Approach” to APIs</h3><p>The first issue was almost comical in its inefficiency. Every time the agent needed to track a shipment, it called <strong>all three carrier APIs (</strong>FedEx, UPS, and DHL) sequentially.</p><p>It did this every single time.</p><p>The order record already contained the carrier ID. The agent didn’t need to ask all three carriers where the package was. It just needed to look at the order, see “FedEx,” and call FedEx.</p><p>By failing to do this simple check, the agent was making two unnecessary API calls for every single task. At the scale this logistics company operates, that waste piled up fast. Between the wasted API costs and the latency of waiting for three responses, this single inefficiency was <strong>burning roughly $8,000 per month</strong>.</p><blockquote><strong><em>This is risky business.</em></strong><em> </em><a href="https://www.carrierintegrations.com/carrier-api-reliability-crisis-2025-performance-data-shows-60-more-downtime/"><em>Carrier API uptime dropped from 99.66% to 99.46%</em></a><em> recently, resulting in 60% more downtime. When you call three APIs instead of one, you are tripling your exposure to those outages.</em></blockquote><h3>Problem 2: The Silent Failures</h3><p>The second problem was a quirk in their ERP (Enterprise Resource Planning) system. About <strong>8% of the time</strong>-usually during high traffic-the ERP API would return a partial response. Maybe a field was missing, or the data was truncated.</p><p>A human would look at that and say, “That looks wrong, let me refresh.” The agent? It took the incomplete data and ran with it.</p><ul><li>It used missing SKU information to run inventory checks</li><li>It sent supplier messages with the wrong quantities</li><li>It fired alerts based on corrupted status fields</li></ul><p>These are called <strong>cascading failures</strong>. One bad data point from the ERP triggered errors in four downstream tools. The ops team was spending 68 hours a month just tracing these error chains and apologizing to suppliers. That’s roughly <strong>$6,800 a month</strong> in fully-loaded labor costs just to clean up after the bot.</p><p>Industry data confirms this is a widespread challenge. According to <a href="https://www.uptrends.com/state-of-api-reliability-2025">The State of API Reliability 2025</a>, logistics saw the sharpest decline in API uptime as providers expanded digital ecosystems to meet rising demand. The rapid growth increased reliance on external APIs across warehousing, transport, and delivery networks — amplifying the risk of downtime from system overloads, partner outages, and inconsistent monitoring practices.</p><h3>Problem 3: The Dangerous Assumptions</h3><p>The third problem was the one that kept the CTO awake at night.</p><p>The agent was built to <strong>read</strong> data. It wasn’t supposed to modify critical orders without permission. But in about <strong>15% of edge cases</strong>, the agent got confused. It interpreted ambiguous instructions like “Check if we can change the delivery date” as a command to “Change the delivery date.”</p><p>It had the API access to do it, but it lacked the business logic to know it shouldn’t.</p><p>Most of the time, the supplier API rejected these attempts. But sometimes, they went through. This created operational chaos-wrong delivery dates, incorrect quantities, and cancelled orders that should have remained active.</p><p>This is a known failure mode in agentic systems. <a href="https://www.traxtech.com/ai-in-supply-chain/ai-in-logistics-separating-real-results-from-overhyped-promises-in-2025">Research on AI in logistics</a> found that one of the key areas where AI underdelivered in 2025 was autonomous warehouse operations encountering too many edge cases for consistent performance.</p><h3>The Numbers: Before and After</h3><p>Once the team used the tool graph to identify these issues, they implemented structured fixes. The results were immediate.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*gqCyBoEPa5xDxTriGxcpoQ.png" /></figure><p><strong>Total monthly savings: $23,100 ($277K annualized)</strong></p><p>Look at that success rate. Before the fix, the agent was failing more than half the time. After the fixes, the team reclaimed their time, and the false alarms (which cause massive alert fatigue) dropped by 86%.</p><p>The false-positive alert reduction deserves special attention. According to the <a href="https://www.stamus-networks.com/blog/what-the-2025-sans-detection-response-survey-reveals-false-positives-alert-fatigue-are-worsening">2025 SANS Detection &amp; Response Survey</a>, alert fatigue has escalated to crisis levels, with <a href="https://www.helpnetsecurity.com/2023/07/20/soc-analysts-tools-effectiveness/">67% of alerts getting ignored</a> because analysts are unable to deal with them. The ops team was spending hours every week investigating and dismissing these false alarms. Reducing that to 12 per week freed up significant cognitive bandwidth and reduced the risk that real alerts would be ignored.</p><h3>How They Fixed It</h3><p>The solutions weren’t magic. They were just good engineering governance applied to AI.</p><h3>1. Enforced Lookup</h3><p>They changed the instruction hierarchy. The agent was <strong>forced to check the Carrier ID</strong> before calling a shipping API. If the ID was missing, only then could it fallback to checking all three. This saved 17,000 wasted API calls a month.</p><h3>2. Validation Gates</h3><p>They added a checkpoint after every ERP call. If the data came back incomplete, the agent was forced to retry or escalate to a human. It wasn’t allowed to pass bad data downstream. This <strong>cut error propagation by 87%</strong>.</p><h3>3. Authorization Checks</h3><p>They updated the system prompt with strict boundaries. The agent could read status, but it <strong>could not modify an order</strong> without presenting the proposed action to a human for approval first.</p><h3>Complexity Multiplies Cost</h3><p>This wasn’t a uniquely complex agent. It used eight tools and standard logistics workflows. But that level of complexity is exactly where unstructured agents fall apart.</p><p>The more tools you add, the more ways the system can break. An agent that works perfectly with three tools might fail catastrophically with eight. Every additional API introduces new rate limits, data format mismatches, and latency spikes.</p><p>This aligns with broader industry trends. <a href="https://www.uptrends.com/state-of-api-reliability-2025">The State of API Reliability 2025</a> found that the logistics sector saw the sharpest decline in API uptime as providers expanded digital ecosystems to meet rising demand for real-time tracking and third-party integrations. And <a href="https://logisticsviewpoints.com/2025/12/22/ai-in-logistics-what-actually-worked-in-2025-and-what-will-scale-in-2026/">data on AI-driven logistics</a> showed that data inconsistencies in areas like AI-driven carrier selection significantly limit accuracy, with humans still making final decisions even where AI reduced the time to compare options.</p><p>The companies that win with AI agents treat evaluation as a strict deployment gate. They don’t just ship it and hope for the best. They map the dependencies. They look for the waste. They simulate the failures.</p><p><strong>If you don’t do this, you aren’t automating your operations. You’re just automating your errors.</strong></p><h3>The Hidden Cost of “Good Enough”</h3><p>If you are running an agent right now, ask yourself: <strong>How do you measure success?</strong></p><p>Are you looking at “response rate” (how often the bot answers?), or are you looking at “task success rate” (how often it actually solves the problem without human help?)</p><p>If your team is spending hours reviewing agent outputs, or if you’re paying for thousands of API calls that don’t result in finished tasks, your agent might be costing you more than the team it replaced.</p><p>Don’t let the demo fool you. Check the logs, map the tools, and find the hidden costs before they eat your budget alive.</p><h3>Sources</h3><ul><li><a href="https://logisticsviewpoints.com/2025/12/22/ai-in-logistics-what-actually-worked-in-2025-and-what-will-scale-in-2026/">AI in Logistics: What Actually Worked in 2025 and What Will Scale in 2026</a></li><li><a href="https://fortune.com/2025/08/18/mit-report-95-percent-generative-ai-pilots-at-companies-failing-cfo/">MIT report: 95% of generative AI pilots at companies are failing</a></li><li><a href="https://research.aimultiple.com/llm-pricing/">LLM Pricing: Top 15+ Providers Compared in 2026</a></li><li><a href="https://www.cloudidr.com/blog/llm-pricing-comparison-2026">Complete LLM Pricing Comparison 2026: We Analyzed 60+ Models So You Don’t Have To</a></li><li><a href="https://www.uptrends.com/state-of-api-reliability-2025">The State of API Reliability 2025</a></li><li><a href="https://www.carrierintegrations.com/carrier-api-reliability-crisis-2025-performance-data-shows-60-more-downtime/">Carrier API Reliability Crisis: 2025 Performance Data Shows 60% More Downtime</a></li><li><a href="https://agentiveaiq.com/blog/how-much-do-ai-agents-cost-in-2025-pricing-breakdown">AI Agent Cost in 2025: Hidden Pricing &amp; LLM Fees</a></li><li><a href="https://www.traxtech.com/ai-in-supply-chain/ai-in-logistics-separating-real-results-from-overhyped-promises-in-2025">AI in Logistics: Separating Real Results From Overhyped Promises in 2025</a></li><li><a href="https://www.stamus-networks.com/blog/what-the-2025-sans-detection-response-survey-reveals-false-positives-alert-fatigue-are-worsening">What the 2025 SANS Detection &amp; Response Survey Reveals: False Positives &amp; Alert Fatigue Are Worsening</a></li><li><a href="https://www.helpnetsecurity.com/2023/07/20/soc-analysts-tools-effectiveness/">67% of daily security alerts overwhelm SOC analysts</a></li></ul><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=60ad57fb9265" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Why an AI Agent Opened 7 Browser Tabs to Answer This One-Line Question]]></title>
            <link>https://medium.com/@pranavdhoolia/why-an-ai-agent-opened-7-browser-tabs-to-answer-this-one-line-question-7eadd38ddfef?source=rss-595481777489------2</link>
            <guid isPermaLink="false">https://medium.com/p/7eadd38ddfef</guid>
            <category><![CDATA[customer-support]]></category>
            <category><![CDATA[agent-evaluation]]></category>
            <category><![CDATA[mcp-server]]></category>
            <category><![CDATA[ai-agent]]></category>
            <category><![CDATA[llm]]></category>
            <dc:creator><![CDATA[Pranav Dhoolia]]></dc:creator>
            <pubDate>Mon, 02 Feb 2026 09:40:15 GMT</pubDate>
            <atom:updated>2026-02-02T09:40:15.189Z</atom:updated>
            <content:encoded><![CDATA[<blockquote><em>It Took Just 500 Made-up Tickets to Find What 50,000 Real Ones Couldn’t</em></blockquote><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*mk64ucwdpfCNgU8HcC9A9Q.jpeg" /><figcaption>credits: <a href="https://medium.com/@CWSkelly/analysis-this-is-fine-meme-e8980ff61e78">https://medium.com/@CWSkelly/analysis-this-is-fine-meme-e8980ff61e78</a></figcaption></figure><blockquote>You know that feeling when you buy a gadget that looks amazing in the ad, then get it home and find it’s just… meh? That’s exactly what’s happening with AI agents right now. Everyone has a shiny demo. Everyone has a slide deck promising to slash support costs by half. When these things actually hit production, the reality is often a lot messier, and a lot more expensive.</blockquote><p>I recently looked at a case involving a mid-market SaaS company that shipped a tier-1 AI support agent. On paper, it was perfect. Three months later? It was burning through $14,200 a month in API spend, escalating nearly a third of its tickets to humans, and failing to resolve basic tasks.</p><blockquote>Imagine asking a support rep to check a refund status, and they open seven different browser tabs, check three of them twice, and then ask you for your name again. That’s what the AI was doing.</blockquote><p>They eventually fixed it, saving over $200,000 a year. They found the problem by realizing that their “smart” agent was actually doing some pretty dumb things.</p><h3>The Demo Day Trap</h3><p>Let’s set the scene.</p><p>This is a <strong>200-employee business handling about 50,000 support interactions a month</strong> across chat, email, and phone. They built an AI agent to serve as the first point of contact. It had access to five core tools: their CRM, a knowledge base, a payment processor, a ticketing system, and an escalation router.</p><p>On demo day, the thing sang. It handled account lookups, processed refunds, and answered questions about the product without breaking a sweat. The engineering team high-fived, shipped it to production, and moved on to the next fire.</p><p>For the first month, silence. CSAT scores held steady. It seemed like a win.</p><p>But then the finance team started looking at the bills. The LLM API costs were climbing, yet the human support team was still drowning in tickets. The agent was running without crashes, yet wildly inefficient. It was like hiring a contractor who charges by the hour and takes the longest possible route to the job site.</p><h3>The Hidden Cost Problem</h3><p>When they finally dug into the numbers, the picture was ugly.</p><p>The agent had a 58% success rate. In other words, 42% of interactions were failures. It was making an average of 7.2 tool calls per task. Imagine asking a support rep to check a refund status, and they open seven different browser tabs, check three of them twice, and then ask you for your name again. That’s what the AI was doing.</p><p>Here is the breakdown of the damage:</p><ul><li><strong>Monthly API Cost:</strong> $14,200</li><li><strong>Escalation Rate:</strong> 31%</li><li><strong>Redundant API Calls:</strong> ~38,000 per month</li></ul><p>The real kicker was the escalation cost. Every time the AI gave up or got confused, a human had to step in (often inheriting a frustrated customer and a garbled ticket history). In the SaaS world, handling a support ticket from scratch costs <a href="https://livechatai.com/blog/customer-support-cost-benchmarks">$25 to $35</a>. If engineering has to get involved, that jumps to $50 or $100.</p><p>The company estimated the AI’s poor escalation behavior was adding $22,000 a month in labor costs. The tool that was supposed to save money was actually creating a massive operational tax!</p><h3>Finding the Failures</h3><p>AI agents fail in messy ways. They don’t throw clean error codes. They hallucinate, they get confused, or they just get stuck in a loop. Production logs are messy, and trying to find the root cause by reading thousands of chat transcripts is a nightmare.</p><p>So, the company tried something different. They ran a structured evaluation using synthetic simulation. Instead of waiting for users to find the bugs, they generated 500 scenarios (everything from simple password resets to messy, multi-step billing disputes), and ran the agent through a gauntlet.</p><p>The simulation exposed four structural problems that were hiding in plain sight.</p><h3>1. The “Cart Before the Horse” Problem</h3><p>The agent was calling the CRM API before checking the knowledge base in 40% of cases.</p><p>If a customer asks, “How do I export a PDF?”, the answer is in the documentation. The agent was doing expensive CRM lookups for generic questions. It was pure waste!</p><h3>2. Missing Validation Gates</h3><p>The refund tool was being triggered without checking the account status first.</p><p>In 18% of cases, the agent tried to process a refund, failed because it didn’t have the account details, and then entered a retry loop. <strong>It was like trying to withdraw cash from an ATM without putting your card in first</strong>. The system prompt didn’t have a rule saying, “Verify the user before you try to give them money.”</p><h3>3. The “Vibes-Based” Escalation</h3><p>The escalation router was triggered based on vague signals. If a request “<em>seemed</em> <em>complicated</em>,” the agent punted it to a human.</p><p>This led to a 31% escalation rate. For context, <a href="https://www.fullview.io/blog/ai-agent-vs-chatbot">a well-optimized agent should be closer to 15–20%</a>. The AI was getting nervous and handing off routine tasks because it didn’t have clear boundaries on what it was actually allowed to solve.</p><h3>4. Giving Up Too Easy</h3><p>The payment processor API had a 4% error rate due to network blips. When a call failed, the agent treated it as a hard stop and escalated the ticket.</p><p>Deterministic code handles this with a simple retry. The AI, lacking that instruction, just threw its hands up.</p><h3>The Fix: Rules Over Vibes</h3><p>The solution was better structure.</p><p>They rewrote the system prompts to enforce a <strong>strict dependency graph</strong>. Now, for product questions, the agent must check the knowledge base first. It must validate the account before attempting a write operation (like a refund).</p><p>They added explicit constraints to the tool schemas. The refund tool literally cannot be called unless the account verification step returns a success signal.</p><p>They also ripped out the vague escalation instructions. Instead of “escalate if complex,” the rules became specific: “Escalate if fraud flag is true,” or “Escalate if refund &gt; $500.”</p><p>Finally, they added a retry-with-backoff pattern for the payment API. If the network blips, the agent waits a second and tries again. That simple fix resolved 85% of the payment errors without a human ever seeing them.</p><h3>The Results</h3><p>The impact was immediate.</p><ul><li><strong>Success Rate:</strong> Jumped from 58% to 84%.</li><li><strong>Tool Calls:</strong> Dropped from 7.2 to 4.1 per task.</li><li><strong>Monthly API Cost:</strong> Fell from $14,200 to $5,800.</li><li><strong>Escalation Rate:</strong> Dropped to 18%.</li></ul><p><strong>Total annualized savings? $211,000.</strong></p><h3>Why This Keeps Happening</h3><p>I see this pattern everywhere. Companies treat LLMs like magic wands. <strong>They assume the model is smart enough to figure out the workflow</strong>. The model can’t do that.</p><p>Models need guardrails for process management. Without explicit instructions, an agent acts like a panicked intern-checking things they don’t need to check, asking for help too early, and giving up the moment something goes wrong.</p><p>Industry benchmarks back this up. <a href="https://www.fullview.io/blog/support-stats">State-of-the-art models still struggle with complex agentic tasks</a>. <a href="https://intuitionlabs.ai/articles/llm-api-pricing-comparison-2025">Token usage can spike by 300%</a> when you enable multi-step workflows without optimization.</p><p>If you have an AI agent in production right now, take a hard look at your logs. Is it calling tools in the right order? Is it escalating things it should be solving? Because if you haven’t checked, I can almost guarantee it’s burning money you didn’t plan on spending.</p><p>Building an AI agent that doesn’t bankrupt you is the real work.</p><blockquote>Want to know what your agent is actually doing with those tool calls?</blockquote><blockquote>I’ll build a custom benchmark for your agent (500 synthetic scenarios covering everything from one-step lookups to multi-tool billing disputes ), and run it through a structured gauntlet.</blockquote><blockquote>You get back scored, annotated traces showing every wrong turn: the redundant API calls, the missing validation gates, the escalations that should have been resolutions. Plus a prioritized fix list of updated prompts, tighter tool schemas, and the workflow constraints that turn a panicked intern into a reliable system.</blockquote><blockquote>Book a meeting on <a href="https://calendly.com/pranav-dhoolia/30min">https://calendly.com/pranav-dhoolia/30min</a></blockquote><h3>Sources</h3><ul><li><a href="https://livechatai.com/blog/customer-support-cost-benchmarks">The True Cost of Customer Support: 2025 Analysis Across 50 Industries</a></li><li><a href="https://www.fullview.io/blog/support-stats">100+ Customer Support Statistics &amp; Trends for 2025</a></li><li><a href="https://intuitionlabs.ai/articles/llm-api-pricing-comparison-2025">LLM API Pricing Comparison (2025): OpenAI, Gemini, Claude</a></li><li><a href="https://agentiveaiq.com/blog/how-much-does-ai-cost-per-month-real-pricing-revealed">AI Agent Cost Per Month 2025: Real Pricing Revealed</a></li><li><a href="https://skywork.ai/blog/ai-agents-case-studies-2025/">9 Best AI Agents Case Studies 2025: Real Enterprise Results</a></li><li><a href="https://swifteq.com/post/zendesk-ai-agents-vs-intercom-fin">How Zendesk AI Agents and Intercom Fin Stack Up in Real Support Scenarios</a></li><li><a href="https://www.fullview.io/blog/best-ai-customer-support-agents">The 14 Best AI Customer Support Agents in 2025</a></li><li><a href="https://www.fullview.io/blog/ai-agent-vs-chatbot">AI Agent vs Chatbot: The Real Difference Support Leaders Need to Know</a></li><li><a href="https://www.freshworks.com/How-AI-is-unlocking-ROI-in-customer-service/">How AI is unlocking ROI in customer service: 58 stats and key insights for 2025</a></li><li><a href="https://convin.ai/blog/support-agent-ai">How AI Support Agent Can Boost CSAT by 27% in Call Centers</a></li></ul><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=7eadd38ddfef" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Building a Universal Assistant to connect with any API]]></title>
            <link>https://medium.com/heurislabs/building-a-universal-assistant-to-connect-with-any-api-89d7c353e524?source=rss-595481777489------2</link>
            <guid isPermaLink="false">https://medium.com/p/89d7c353e524</guid>
            <category><![CDATA[model-context-protocol]]></category>
            <category><![CDATA[ai-integration]]></category>
            <category><![CDATA[anthropic-claude]]></category>
            <category><![CDATA[langgraph]]></category>
            <category><![CDATA[ai-assistant]]></category>
            <dc:creator><![CDATA[Pranav Dhoolia]]></dc:creator>
            <pubDate>Fri, 07 Feb 2025 14:47:14 GMT</pubDate>
            <atom:updated>2025-02-07T14:47:14.338Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*LzdeOcwF_lE4KIscsqo3wg.png" /><figcaption>Universal Assistant connected to multiple APIs</figcaption></figure><p>In our previous blog, we built a <a href="https://github.com/esxr/langgraph-mcp">universal assistant</a> that could use <strong>Claude’s Model Context Protocol (MCP)</strong> to interface with different services (such as our filesystem, a sqlite database, etc.), and help make our daily tasks more efficient.</p><p>In this blog, we’ll extend our <a href="https://github.com/esxr/langgraph-mcp"><strong>Universal Assistant</strong></a> to be able to connect to almost any API and automatically call its endpoints!</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/854/1*mSgJmL2lP-FeOiHaQlqZ4w.gif" /></figure><p>You can access the code for this blog <a href="https://github.com/esxr/langgraph-mcp/tree/ed8d6d5f2fcaa5a94d0a4236068199c7f473a3ea">here</a>.</p><h3>Steps</h3><blockquote>Note: I’ve included links for all the relevant changes for greater clarity.</blockquote><h4>Initial Setup</h4><p>In a regular MCP server setup, we’re limited by the number of MCP servers out there, or have to build our own. So adding support for each service we use with our assistant would mean creating a robust MCP implementation of the same. This approach might <em>currently</em> not be scalable.</p><p>Fortunately, there is an easier way of doing the same. Most of the popular APIs can be specified in a standard OpenAPI format. We just need a generic MCP server that can convert an OpenAPI spec into MCP tools.</p><p>After some research, I found <a href="https://github.com/snaggle-ai/openapi-mcp-server">this mcp server</a>, which takes an OpenAPI Spec and dynamically exposes its endpoints as tools. All we have to do is add a config to our <a href="https://github.com/esxr/langgraph-mcp/blob/ed8d6d5f2fcaa5a94d0a4236068199c7f473a3ea/mcp-servers-config.sample.json">mcp-servers-config.json</a> for our desired spec!</p><pre>{<br>  &quot;mcpServers&quot;: {<br>    ...<br>    &quot;jira&quot;: {<br>        &quot;description&quot;: &quot;Project management and issue tracking, e.g., get, modify, or search projects, issues, bugs, etc.&quot;,<br>        &quot;command&quot;: &quot;npx&quot;,<br>        &quot;args&quot;: [<br>            &quot;-y&quot;,<br>            &quot;openapi-mcp-server@1.1.0&quot;,<br>            &quot;/path/to/openapi-specs/jira-openapi.json&quot;<br>        ],<br>        &quot;env&quot;: {<br>            &quot;OPENAPI_MCP_HEADERS&quot;: &quot;{\&quot;Authorization\&quot;: \&quot;Basic [base64 encoded version of: &lt;YOUR.JIRA.EMAIL@YOUR.JIRA.TOKEN&gt;]\&quot;}&quot;,<br>            &quot;JIRA_HOST&quot;: &quot;your_workspace.atlassian.net&quot;,<br>            &quot;JIRA_EMAIL&quot;: &quot;you_email@example.com&quot;,<br>            &quot;JIRA_API_TOKEN&quot;: &quot;YOUR.JIRA.TOKEN&quot;<br>        }<br>    }<br>  }<br>}</pre><p>We’ve connected our JIRA workspace to our assistant using a simple config. Now, let’s tell our assistant to create a JIRA ticket for us.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/640/1*ZLJQXgCxbd7kzudPfWQAKw.gif" /></figure><p>It’s able to select the right tool (from a list of 500 or so tools!) most of the time. However, it cannot correctly map the information from the conversation context to tool arguments.</p><p>Tracing this with the help of LangSmith reveals the problem. The tool definitions returned by the MCP server has refs to the information schema (instead of the actual schema). Therefore, the target schema of tool input arguments is effectively not available to our tool orchestrator.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/640/1*snHD1O30cPPf1Xez0VSsQg.gif" /></figure><p>To address the problem, we need to make sure that the tool definition has resolved schema references.</p><h4>Improving the openapi-mcp-server integration</h4><p><strong>Openapi-mcp-server </strong>by itself doesn’t check for the presence of refs and resolve them. This is what I needed to correct. After going through LangChain <a href="https://python.langchain.com/api_reference/langchain/chains/langchain.chains.openai_functions.openapi.openapi_spec_to_openai_fn.html">docs</a>, I found a utility called <a href="https://github.com/esxr/langgraph-mcp/blob/ed8d6d5f2fcaa5a94d0a4236068199c7f473a3ea/src/langgraph_mcp/utils/openapi_utils.py">openapi_spec_to_openai_fn</a>, which automatically resolves the refs during the transformation.</p><p>So let’s extend GetTools of the mcp_wrapper.py with <a href="https://github.com/esxr/langgraph-mcp/blob/ed8d6d5f2fcaa5a94d0a4236068199c7f473a3ea/src/langgraph_mcp/mcp_wrapper.py#L91"><strong>GetOpenAPITools</strong></a>.</p><pre>class GetOpenAPITools(GetTools):<br>    def __init__(self, openapi_spec: dict, **kwargs):<br>        self.openapi_spec_dict = openapi_spec<br>        self.openapi_spec = OpenAPISpec.from_spec_dict(self.openapi_spec_dict)<br><br>    async def __call__(<br>        self, server_name: str, session: ClientSession<br>    ) -&gt; list[dict[str, Any]]:<br>        tools = await session.list_tools()<br>        openai_fns = openapi_spec_to_openai_fn(self.openapi_spec)[0]<br>        <br>        # Making the JSON schema given by openapi_spec_to_openai_fn<br>        # compatible to Runtool function (MCP) <br>        for fn, tool in zip(openai_fns, tools.tools):<br>            op_id = &quot;&quot;.join(tool.name.split(&quot;-&quot;)[1:])<br>            # This will be useful later<br>            fn[&quot;metadata&quot;] = {}<br>            fn[&quot;metadata&quot;][&quot;tool_info&quot;] = extract_inlined_operation_data(<br>                self.openapi_spec_dict, op_id<br>            )<br><br>            fn[&quot;parameters&quot;][&quot;properties&quot;] = merge_json_structure(<br>                fn[&quot;parameters&quot;][&quot;properties&quot;]<br>            )<br>            fn[&quot;name&quot;] = tool.name<br>            # truncate all openai_fn descriptions to 1024 characters<br>            if len(fn[&quot;description&quot;]) &gt; 1024:<br>                fn[&quot;description&quot;] = fn[&quot;description&quot;][:1021] + &quot;...&quot;<br><br>        return openai_fns</pre><p>The graph may now use GetOpenAPITools instead of the usual GetTools while asking for tools from an <strong>openapi-mcp-server.</strong></p><pre>async def mcp_orchestrator(<br>    state: State, *, config: RunnableConfig<br>) -&gt; dict[str, list[BaseMessage]]:<br>    &quot;&quot;&quot;Orchestrates MCP server processing.&quot;&quot;&quot;<br><br>    ...<br>    <br>    # Fetch tools from the MCP server conditionally<br>    tools = []<br>    args = (<br>        server_config[&quot;args&quot;][1:]<br>        if server_config[&quot;args&quot;][0] == &quot;-y&quot;<br>        else server_config[&quot;args&quot;]<br>    )<br><br>    # Separate integration for openapi-mcp-server@1.1.0<br>    # TODO: refactor this into an adapter pattern later<br>    # You can also contribute to &quot;Github: esxr/langgraph-mcp&quot; for this!<br>    if args[0] == &quot;openapi-mcp-server@1.1.0&quot;:<br>        openapi_path = args[1]<br><br>        # Get the openapi file as a json<br>        with open(openapi_path, &quot;r&quot;) as file:<br>            openapi_spec = json.load(file)  # Converts JSON to a Python dictionary<br><br>        # convert the spec to openai tools<br>        tools = await mcp.apply(<br>            server_name,<br>            server_config,<br>            mcp.GetOpenAPITools(openapi_spec),<br>        )<br>    else:<br>        tools = await mcp.apply(server_name, server_config, mcp.GetTools())<br><br><br>    ...</pre><p>This yields much better results!</p><p>However, though <a href="https://github.com/esxr/langgraph-mcp/blob/ed8d6d5f2fcaa5a94d0a4236068199c7f473a3ea/src/langgraph_mcp/utils/openapi_utils.py">openapi_spec_to_openai_fn</a> resolves the refs, it ignores some of the operation details available in the spec. E.g., it ignores <em>examples</em>, and any additional <em>annotations</em> that API spec providers may have added.</p><p>My first intuition was to enhance openapi_spec_to_openai_fn to include these, but for APIs like JIRA (with 500+ operations) that significantly increases the number of tokens with the bind tools. After some experimentation, the approach I chose was to selectively refine the schema details after the tool selection by the orchestrator.</p><p>The graph structure for that looks somewhat like this:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*NSge-LWUz_Lg7MwqlsV2aw.png" /></figure><p>The <strong>refine_tool_call </strong>binds that single selected tool with the additional schema details and re-attempts the <a href="https://github.com/esxr/langgraph-mcp/blob/ed8d6d5f2fcaa5a94d0a4236068199c7f473a3ea/src/langgraph_mcp/prompts.py#L58">tool orchestration prompt</a>.</p><pre>TOOL_REFINER_PROMPT = &quot;&quot;&quot;You are an intelligent assistant with access to various specialized tools.<br><br>Objectives:<br>1. Analyze the conversation to understand the user&#39;s intent and context.<br>2. Select the most appropriate info from the conversation for the tool_call<br>3. Combine tool outputs logically to provide a clear and concise response.<br><br>Steps to follow:<br>1. Understand the conversation&#39;s context.<br>2. Select the most appropriate info from the conversation for the tool_call.<br>3. If there is a tool response, combine the tool&#39;s output to provide a clear and concise answer to the user&#39;s query, or attempt to select another tool if needed to provide a more comprehensive answer.<br><br>{tool_info}<br><br>System time: {system_time}<br>&quot;&quot;&quot;</pre><p>Here’s how the <strong>refine_tool_call</strong> node looks like in <a href="https://github.com/esxr/langgraph-mcp/blob/ed8d6d5f2fcaa5a94d0a4236068199c7f473a3ea/src/langgraph_mcp/assistant_graph.py#L271">assistant_graph.py</a></p><pre>async def refine_tool_call(<br>    state: State, *, config: RunnableConfig<br>) -&gt; dict[str, list[BaseMessage]]:<br>    &quot;&quot;&quot;Call the MCP server tool.&quot;&quot;&quot;<br><br>    if state.current_tool == None:<br>        return<br><br>    # Fetch the current state and configs<br>    ...<br><br>    # Get the tool info<br>    tool_info = state.current_tool.get(&quot;metadata&quot;, {}).get(&quot;tool_info&quot;, {})<br><br>    # Bind the tool call to the model<br>    # Prepare the LLM<br>    prompt = ChatPromptTemplate.from_messages(<br>        [<br>            (&quot;system&quot;, configuration.tool_refiner_prompt),<br>            (&quot;placeholder&quot;, &quot;{messages}&quot;),<br>        ]<br>    )<br>    model = load_chat_model(configuration.tool_refiner_model)<br>    message_value = await prompt.ainvoke(<br>        {<br>            &quot;messages&quot;: state.messages[:-1],<br>            &quot;tool_info&quot;: str(tool_info),<br>            &quot;system_time&quot;: datetime.now(tz=timezone.utc).isoformat(),<br>        },<br>        config,<br>    )<br><br>    # Replace the last tool call with this one<br>    # we don&#39;t want 2 tool calls in a line<br>    last_msg_id = state.messages[-1].id<br>    response = await model.bind_tools([state.current_tool]).ainvoke(<br>        message_value, config<br>    )<br>    response.id = last_msg_id<br><br>    return {<br>      &quot;messages&quot;: [response], <br>      &quot;current_tool&quot;: None # unset the current tool<br>    }</pre><p>You can view all the above changes together <a href="https://github.com/esxr/langgraph-mcp/commit/ed8d6d5f2fcaa5a94d0a4236068199c7f473a3ea">here</a>.</p><h3>Conclusion</h3><p>In this article we saw how to turn any server end-point with an Open API specification to an MCP server using a generic wrapper. This greatly enhances the current reach of our MCP based Universal assistant!</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=89d7c353e524" width="1" height="1" alt=""><hr><p><a href="https://medium.com/heurislabs/building-a-universal-assistant-to-connect-with-any-api-89d7c353e524">Building a Universal Assistant to connect with any API</a> was originally published in <a href="https://medium.com/heurislabs">HeurisLabs</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Creating an embeddable chatbot]]></title>
            <link>https://medium.com/@pranavdhoolia/creating-an-embeddable-chatbot-9668ab5d22c8?source=rss-595481777489------2</link>
            <guid isPermaLink="false">https://medium.com/p/9668ab5d22c8</guid>
            <category><![CDATA[langgraph]]></category>
            <category><![CDATA[artificial-intelligence]]></category>
            <category><![CDATA[ai]]></category>
            <category><![CDATA[chatbots-for-business]]></category>
            <category><![CDATA[ai-assistant]]></category>
            <dc:creator><![CDATA[Pranav Dhoolia]]></dc:creator>
            <pubDate>Tue, 28 Jan 2025 08:18:36 GMT</pubDate>
            <atom:updated>2025-01-28T08:18:36.067Z</atom:updated>
            <content:encoded><![CDATA[<p>In these demos, I create and deploy a RAG (Retrieval Augmented Generation) based chatbot for Question Answering over website content using AI solution templates that I developed at AGAILE. The backend for our solution templates is built on LangGraph, and Assistant UI over NextJS on AWS ECS for the frontend.</p><p>Here are the demos:</p><ul><li><a href="https://www.youtube.com/watch?v=XVpsoJEmKs0&amp;list=PLA6D0bgYkEFNpd0OyBCBuGvahO6zPMAAu&amp;index=1&amp;pp=gAQBiAQB">AGAILE Virtual Workplace Assistant: Indexing a website</a></li><li><a href="https://www.youtube.com/watch?v=nSdJh1FOMDI&amp;list=PLA6D0bgYkEFNpd0OyBCBuGvahO6zPMAAu&amp;index=2&amp;pp=gAQBiAQB">AGAILE Virtual Workplace Assistant: Configuring a new assistant</a></li><li><a href="https://www.youtube.com/watch?v=W2sXhE1SSyY&amp;list=PLA6D0bgYkEFNpd0OyBCBuGvahO6zPMAAu&amp;index=3&amp;pp=gAQBiAQB">AGAILE Virtual Workplace Assistant Embeddable Chatbot</a></li></ul><p>I’ve also discussed extensively on the implementation of the LangGraph based backend in my previous blog:</p><p><a href="https://medium.com/@pranavdhoolia/building-and-deploying-a-virtual-assistant-with-langgraph-5c68dabd82db">Building &amp; Deploying an Agent with LangGraph</a></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=9668ab5d22c8" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Building our own Universal Assistant with LangGraph and MCP]]></title>
            <link>https://medium.com/heurislabs/building-our-own-universal-assistant-with-langgraph-and-mcp-50b503fea1f0?source=rss-595481777489------2</link>
            <guid isPermaLink="false">https://medium.com/p/50b503fea1f0</guid>
            <category><![CDATA[langgraph]]></category>
            <category><![CDATA[machine-learning-ai]]></category>
            <category><![CDATA[model-context-protocol]]></category>
            <category><![CDATA[ai-assistant]]></category>
            <category><![CDATA[anthropic-claude]]></category>
            <dc:creator><![CDATA[Pranav Dhoolia]]></dc:creator>
            <pubDate>Mon, 20 Jan 2025 08:18:12 GMT</pubDate>
            <atom:updated>2025-02-07T16:59:02.494Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*LzdeOcwF_lE4KIscsqo3wg.png" /></figure><p><a href="https://modelcontextprotocol.io/introduction"><strong>Model Context Protocol (MCP)</strong></a> is an open protocol that enables seamless integration between LLM applications and external data sources and tools. Whether you’re building an AI-powered IDE, enhancing a chat interface, or creating custom AI workflows, MCP provides a standardized way to connect LLMs with the context they need.</p><p>Think of MCP like a USB-C port for AI applications. Just as USB-C provides a standardized way to connect your devices to various peripherals and accessories, MCP provides a standardized way to connect AI models to different data sources and tools.</p><p><a href="https://langchain-ai.github.io/langgraph/"><strong>LangGraph</strong></a> is a framework designed to enable seamless integration of language models into complex workflows and applications. It emphasizes modularity and flexibility. Workflows are represented as graphs. Nodes correspond to actions, tools, or model queries. Edges define the flow of information between them.</p><p>LangGraph provides a structured yet dynamic way to execute tasks, making it ideal for writing AI applications involving natural language understanding, automation, and decision-making.</p><p>In <a href="https://medium.com/@pranavdhoolia/building-and-deploying-a-virtual-assistant-with-langgraph-5c68dabd82db">this earlier article</a> we enhanced LangGraph’s retrieval agent template to develop and deploy an AI solution.</p><p>In this work, we combine LangGraph with MCP to build our own Universal Assistant. You can access the code <a href="https://github.com/esxr/langgraph-mcp">here</a>.</p><p>Before we get into it, let’s experience what we built in this demo:</p><iframe src="https://cdn.embedly.com/widgets/media.html?src=https%3A%2F%2Fwww.youtube.com%2Fembed%2Fy6MG-aZqmFw%3Ffeature%3Doembed&amp;display_name=YouTube&amp;url=https%3A%2F%2Fwww.youtube.com%2Fwatch%3Fv%3Dy6MG-aZqmFw&amp;image=https%3A%2F%2Fi.ytimg.com%2Fvi%2Fy6MG-aZqmFw%2Fhqdefault.jpg&amp;type=text%2Fhtml&amp;schema=youtube" width="854" height="480" frameborder="0" scrolling="no"><a href="https://medium.com/media/457c22e83b7bbccf43f86710c2ea86c8/href">https://medium.com/media/457c22e83b7bbccf43f86710c2ea86c8/href</a></iframe><blockquote>Note: In the <a href="https://medium.com/@pranavdhoolia/building-a-universal-assistant-to-connect-with-any-api-89d7c353e524">next blog</a>, we explore how to enhance the capabilities of this assistant even further, so that we can plug any OpenAPI spec for an API and connect it!</blockquote><p><a href="https://medium.com/@pranavdhoolia/building-a-universal-assistant-to-connect-with-any-api-89d7c353e524">Building a Universal Assistant to connect with any API</a></p><h3>Implementation</h3><p>For our universal assistant we implement a multi-agent pattern as follows:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/500/1*LC60UdkVcymgurdLHTGjTg.png" /><figcaption>Assistant Flow</figcaption></figure><p>Assistant receives the user message and decides the agent to use. The agent node decides the right tool to use, and calls the tool on the MCP server. Since all our agents are based on MCP, a single MCP-Agent node is sufficient for LLM based orchestration, and another single node is sufficient to work with MCP servers to invoke their tools.</p><h3>Our Implementation Walk-thru</h3><p>There are 3 main parts to our implementation:</p><ol><li>Building the Router</li><li>The Assistant</li><li>A generic MCP wrapper</li></ol><h4>Building the Router</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/983/1*gJhlNLpF1JcKcfPJiJ_R7g.png" /><figcaption>Sequence Diagram for build_router_graph</figcaption></figure><p>Our graph to build the router is implemented in <a href="https://github.com/esxr/langgraph-mcp/blob/main/src/langgraph_mcp/build_router_graph.py">build_router_graph.py</a>. It collects routing information based on tools, prompts, and resources offered by each MCP server using our <a href="https://github.com/esxr/langgraph-mcp/blob/main/src/langgraph_mcp/mcp_wrapper.py">mcp_wrapper.py</a>. It indexes this routing information for each server in a vector database.</p><h4>The Assistant</h4><p>The assistant graph is implemented in <a href="https://github.com/esxr/langgraph-mcp/blob/main/src/langgraph_mcp/assistant_graph.py">assistant_graph.py</a>. The following animation describes the role of various nodes and the flow of control thru it, with the help of an example</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*l2hl5yqygMgj2Nfdr0iVGw.gif" /></figure><h4>A Generic MCP Wrapper</h4><p><a href="https://github.com/esxr/langgraph-mcp/blob/main/src/langgraph_mcp/mcp_wrapper.py">mcp_wrapper.py</a> employs a Strategy Pattern using an abstract base class (MCPSessionFunction) to define a common interface for executing various operations on MCP servers. The pattern includes:</p><ol><li><strong>Abstract Interface</strong></li></ol><ul><li>MCPSessionFunction defines an async __call__ method as a contract for all session functions.</li></ul><p>2. <strong>Concrete Implementations</strong></p><ul><li>RoutingDescription class implements fetching routing information based on tools, prompts, and resources.</li><li>GetTools class implements fetching tools for the MCP server and transforming them to the format consumable by LangGraph.</li><li>RunTool class implements invoking a tool on MCP server and returning its output.</li></ul><p>3. <strong>Processor Function</strong></p><ul><li>apply serves as a unified executor. It:</li><li>Initialises a session using stdio_client from mcp library.</li><li>Delegates the actual operation to the provided MCPSessionFunction instance via await fn(server_name, session).</li></ul><p>4. <strong>Extensibility</strong></p><ul><li>New operations can be added by subclassing MCPSessionFunction without modifying the cor e processor logic. for e.g. we should be able to add support for getting tools and executing tools using this pattern.</li></ul><h3>Setting it up</h3><p>Create and activate a virtual environment</p><pre>git clone https://github.com/esxr/langgraph-mcp.git <br>cd langgraph-mcp python3 -m venv .venv <br>source .venv/bin/activate</pre><p>Install Langgraph CLI</p><pre>pip install -U &quot;langgraph-cli[inmem]&quot;</pre><blockquote><strong>Note:</strong> “inmem” extra(s) are needed to run LangGraph API server in development mode (without requiring Docker installation)</blockquote><p>Install the dependencies</p><pre>pip install -e .</pre><p>Configure environment variables</p><pre>cp env.example .env</pre><p>Add your OPENAI_API_KEY, GITHUB_PERSONAL_ACCESS_TOKEN etc. to the .env</p><blockquote><strong>Note</strong>: We have added support for <em>Milvus Lite Retriever</em> (support file based URI). Milvus Lite won’t work on Windows. For Windows you may need to use Milvus Server (Easy to start using Docker), and change the MILVUS_DB config to the server based URI. You may also enhance the <a href="https://github.com/esxr/langgraph-mcp/blob/main/src/langgraph_mcp/retriever.py">retriever.py</a> to add retrievers for your choice of vector databases!</blockquote><h3>Conclusion</h3><p>We can now try to connect it to services we use daily (e.g. JIRA, Slack, other channels etc) to help make our daily tasks more efficient!</p><p>We tackle that in the following blog:</p><p><a href="https://medium.com/@pranavdhoolia/building-a-universal-assistant-to-connect-with-any-api-89d7c353e524">Building a Universal Assistant to connect with any API</a></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=50b503fea1f0" width="1" height="1" alt=""><hr><p><a href="https://medium.com/heurislabs/building-our-own-universal-assistant-with-langgraph-and-mcp-50b503fea1f0">Building our own Universal Assistant with LangGraph and MCP</a> was originally published in <a href="https://medium.com/heurislabs">HeurisLabs</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Building & Deploying an Agent with LangGraph]]></title>
            <link>https://medium.com/heurislabs/building-and-deploying-a-virtual-assistant-with-langgraph-5c68dabd82db?source=rss-595481777489------2</link>
            <guid isPermaLink="false">https://medium.com/p/5c68dabd82db</guid>
            <category><![CDATA[langgraph]]></category>
            <category><![CDATA[retrieval-augmented-gen]]></category>
            <category><![CDATA[langchain-agents]]></category>
            <dc:creator><![CDATA[Pranav Dhoolia]]></dc:creator>
            <pubDate>Fri, 17 Jan 2025 05:45:28 GMT</pubDate>
            <atom:updated>2025-01-17T05:57:30.707Z</atom:updated>
            <content:encoded><![CDATA[<p>This blog is part of a mini-series where I create and deploy a RAG (Retrieval Augmented Generation) based chatbot for Question Answering over website content using AI solution templates that I developed at AGAILE. This covers how to setup and deploy the backend on LangGraph.</p><p>LangGraph is the most popular platform for developing AI solutions. It provides ready templates for common AI solution patterns. We took its <a href="https://github.com/langchain-ai/retrieval-agent-template">retrieval agent template</a> and customised it for our client needs. In the subsequent sections I describe those customisations in detail.</p><p>Please refer to the code in <a href="https://github.com/esxr/retrieval-agent-template">this repo</a> to follow along.</p><h3>Start with langgraph template</h3><p>Let’s install the LangGraph CLI</p><pre>pip install langgraph-cli --upgrade</pre><p>Let’s now create a new app from a template</p><pre>langgraph new</pre><h3>Indexing Graph Enhancements</h3><p>Add starter_urls, and hops to IndexConfiguration in <a href="https://github.com/esxr/retrieval-agent-template/blob/main/src/retrieval_graph/configuration.py">configuration.py</a>. Also add a method to get the list of urls from the comma-separated starter_urls string.</p><pre>starter_urls: str = field(<br>	default=&quot;https://zohlar.com&quot;,<br>	metadata={<br>		&quot;description&quot;: &quot;Comma-separated string of starter URLs to crawl for indexing web pages.&quot;<br>	},<br>)<br><br>hops: int = field(<br> default=2,<br> metadata={<br>  &quot;description&quot;: &quot;Maximum number of hops to traverse pages linked to the starter URLs.&quot;<br> },<br>)<br><br>def parse_starter_urls(self) -&gt; list[str]:<br> &quot;&quot;&quot;Parse the starter URLs into a list.<br> Returns:<br>  list[str]: A list of URLs parsed from the comma-separated string.<br> &quot;&quot;&quot;<br> return [url.strip() for url in self.starter_urls.split(&quot;,&quot;) if url.strip()]</pre><h3>Support For Open-source Retriever (Milvus)</h3><p>Milvus is open source, and its Lite version can work off a file URI. This makes things economical (for dev), and handy for demos.</p><p>Let’s begin by adding langchain-milvus as a dependency in <a href="https://github.com/esxr/retrieval-agent-template/blob/main/pyproject.toml">pyproject.toml</a></p><p>Then add milvus to retriever_provider list</p><pre>retriever_provider: Annotated[<br>	Literal[&quot;elastic&quot;, &quot;elastic-local&quot;, &quot;pinecone&quot;, &quot;mongodb&quot;, &quot;milvus&quot;],<br>	{&quot;__template_metadata__&quot;: {&quot;kind&quot;: &quot;retriever&quot;}},<br>] = field(<br>	default=&quot;milvus&quot;,<br>	metadata={<br>		&quot;description&quot;: &quot;The vector store provider to use for retrieval. Options are &#39;elastic&#39;, &#39;pinecone&#39;, &#39;mongodb&#39;, or, &#39;milvus&#39;.&quot;<br>	},<br>)</pre><p>Now to add a new method to create a milvus retriever in <a href="https://github.com/esxr/retrieval-agent-template/blob/main/src/retrieval_graph/retrieval.py">retrieval.py</a></p><pre>@contextmanager<br>def make_milvus_retriever(<br>    configuration: IndexConfiguration, embedding_model: Embeddings<br>) -&gt; Generator[VectorStoreRetriever, None, None]:<br>    &quot;&quot;&quot;Configure this agent to use milvus lite file based uri to store the vector index.&quot;&quot;&quot;<br>    from langchain_milvus.vectorstores import Milvus<br><br>vstore = Milvus (<br>    embedding_function=embedding_model,<br>    collection_name=configuration.user_id,<br>    connection_args={&quot;uri&quot;: os.environ[&quot;MILVUS_DB&quot;]},<br>    auto_id=True<br>)<br>yield vstore.as_retriever()</pre><p>and then use this in the factory method</p><pre>@contextmanager<br>def make_retriever(<br>    config: RunnableConfig,<br>) -&gt; Generator[VectorStoreRetriever, None, None]:<br>    # ... same code as before<br><br>match configuration.retriever_provider:<br>      # ... same code as before<br>      case &quot;milvus&quot;:<br>          with make_milvus_retriever(configuration, embedding_model) as retriever:<br>              yield retriever<br>      case _:<br>          # ... as before</pre><p>We’ll also add the following file uri in .env to store the vector index:</p><pre>## Milvus<br>MILVUS_DB=milvus.db</pre><h3>Support for Crawling</h3><p>Out of the box implementation of index_graph (in <a href="https://github.com/esxr/retrieval-agent-template/blob/main/src/retrieval_graph/index_graph.py">index_graph.py</a>) expects as input all the documents to be indexed. Since we are enhancing the graph to include an ingestion pipeline that crawls starting at the specified URL, we’ll modify the index_docs node to kick start the crawl if docs list in the state is empty and starter_urls configuration has been provided.</p><h4>Custom Crawler (using Playright)</h4><p>We can create a Crawler Component using <a href="https://playwright.dev/python/">playwright</a>, which uses headless browsers (Note: Please add playwright &amp; requests as dependencies in <a href="https://github.com/esxr/retrieval-agent-template/blob/main/pyproject.toml">pyproject.toml</a>)</p><p>You can access the code to that <a href="https://github.com/esxr/retrieval-agent-template/blob/main/src/retrieval_graph/crawler.py">here</a>.</p><blockquote><strong><em>Note:</em></strong><em> Just adding </em><em>playwright package to the python environment is not sufficient. The headless browser, and its dependencies also need to be installed. So we need to run </em><em>playright install, and </em><em>playwright install-deps as well.</em></blockquote><blockquote><em>You may use LangGraph Studio app on mac, using docker to locally deploy your graphs. In this case </em><em>playwright install and </em><em>playwright install-deps need to happen in the docker. For that we’ll add the following to </em><a href="https://github.com/esxr/retrieval-agent-template/blob/main/langgraph.json"><em>langgraph.json</em></a></blockquote><blockquote><em>&quot;dockerfile_lines&quot;: [&quot;RUN pip install playwright&quot;, &quot;RUN python -m playwright install&quot;, &quot;RUN python -m playwright install-deps&quot;]</em></blockquote><h4>APIfy Crawler</h4><p>For simpler usecases, we can also use a readymade apify based crawler. We have to modify the indexx_graph.py like so:</p><pre># new imports<br>import json<br><br>from langchain_community.utilities import ApifyWrapper<br>from langchain_community.document_loaders import ApifyDatasetLoader<br># ... existing code<br>def load_site_dataset_map() -&gt; dict:<br>    with open(&quot;sites_dataset_map.json&quot;, &#39;r&#39;, encoding=&#39;utf-8&#39;) as file:<br>        return json.load(file)<br>def apify_crawl(tenant: str, starter_urls: list, hops: int):<br>    site_dataset_map = load_site_dataset_map()<br>    if dataset_id := site_dataset_map.get(tenant):<br>        loader = ApifyDatasetLoader(<br>            dataset_id=dataset_id,<br>            dataset_mapping_function=lambda item: Document(<br>                page_content=item[&quot;html&quot;] or &quot;&quot;, metadata={&quot;url&quot;: item[&quot;url&quot;]}<br>            ),<br>        )<br>    else:<br>        apify = ApifyWrapper()<br>        loader = apify.call_actor(<br>            actor_id=&quot;apify/website-content-crawler&quot;,<br>            run_input={<br>                &quot;startUrls&quot;: starter_urls,<br>                &quot;saveHtml&quot;: True,<br>                &quot;htmlTransformer&quot;: &quot;none&quot;<br>            },<br>            dataset_mapping_function=lambda item: Document(<br>                page_content=item[&quot;html&quot;] or &quot;&quot;, metadata={&quot;url&quot;: item[&quot;url&quot;]}<br>            ),<br>        )<br>        print(f&quot;Site: {tenant} crawled and loaded into Apify dataset: {loader.dataset_id}&quot;)<br>    return loader.load()<br># ... existing code<br>async def index_docs(<br>    state: IndexState, *, config: Optional[RunnableConfig] = None<br>) -&gt; dict[str, str]:<br>    # ... as before<br>    with retrieval.make_retriever(config) as retriever:<br>        # code to kick start crawl if required<br>        configuration = IndexConfiguration.from_runnable_config(config)<br>        if not state.docs and configuration.starter_urls:<br>            print(f&quot;starting crawl ...&quot;)<br>            # state.docs = await crawl (<br>            #     configuration.user_id,<br>            #     configuration.parse_starter_urls(),<br>            #     configuration.hops<br>            # )<br>            state.docs = apify_crawl (<br>                configuration.user_id,<br>                [{&quot;url&quot;: url} for url in configuration.parse_starter_urls()],<br>                configuration.hops<br>            )<br>        # rest remains the same as before<br>        stamped_docs = ensure_docs_have_user_id(state.docs, config)<br>        if configuration.retriever_provider == &quot;milvus&quot;:<br>            retriever.add_documents(stamped_docs)<br>        else:<br>            await retriever.aadd_documents(stamped_docs)<br>    return {&quot;docs&quot;: &quot;delete&quot;}<br># ... existing code</pre><p>I changed default values for parameters saveHtml, and htmlTransformer because, OpenAI embedding models understand HTML very well, and the clean up performed by html transformer loses some useful information.</p><h3>Handling Edge Cases</h3><h4>Splitting documents for fine-grained retrieval</h4><p>So far we directly embedded a crawled document. Crawled documents may be arbitrarily large.</p><ol><li>This puts an uncertainity on how many top_k retrieval results we should consider for response generation.</li><li>For complex queries, where composing high quality answers may need information from multiple documents, limiting top_k may lead to poor results.</li></ol><p>To address this, we split crawl documents into multiple documents as follows:</p><pre># src/retrieval_graph/index_graph.py<br><br># new import<br>from langchain_text_splitters import RecursiveCharacterTextSplitter<br># same as before<br>async def index_docs(<br>    state: IndexState, *, config: Optional[RunnableConfig] = None<br>) -&gt; dict[str, str]:<br>    if not config:<br>        raise ValueError(&quot;Configuration required to run index_docs.&quot;)<br>    with retrieval.make_retriever(config) as retriever:<br>        configuration = IndexConfiguration.from_runnable_config(config)<br>        if not state.docs and (configuration.starter_urls or configuration.apify_dataset_id):<br>            print(f&quot;starting crawl ...&quot;)<br>            crawled_docs = apify_crawl(configuration)<br>            # use a 1000 char size overlapping window based splitter to create smaller document chunks to index<br>            text_splitter = RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=200)<br>            state.docs = text_splitter.split_documents(crawled_docs)<br>            <br>            # same as before</pre><p>We can now retrieve a larger set of top_k results. Since the maximum chunk size here is 1000, we can deterministically configure the top_k based on the token limits supported by the language model used in the response generation node. E.g., if the token limit is 100,000 tokens, we can potentially use top_k upto 500 (assuming an average token size of 5 characters).</p><pre># src/retrieval_graph/retreival.py<br># ...<br>def make_milvus_retriever(<br>    configuration: IndexConfiguration, embedding_model: Embeddings, **kwargs<br>) -&gt; Generator[VectorStoreRetriever, None, None]:<br>    # ...<br>    yield vstore.as_retriever(search_kwargs=configuration.search_kwargs)<br># ...</pre><p>Here, search_kwargs = {&quot;k&quot;: 10} conveys that retriever will return top 10 results.</p><h4>Rate limits during embedding</h4><p>Embedding Models put limits on the number of token per unit time. Initially, we embedded the whole set of crawled (or split) documents in a single call. For large sites, this ran into rate limit errors.</p><p>With the following enhancement we can add a batch size configuration, split documents into multiple batches, and add a time delay to avoid rate limits.</p><pre># src/retrieval_graph/configuration.py<br># ...<br>@dataclass(kw_only=True)<br>class IndexConfiguration(CommonConfiguration):<br>    # ...<br>    batch_size: int = field(<br>        default=400,<br>        metadata={<br>            &quot;description&quot;: &quot;Number of documents to index in a single batch.&quot;<br>        },<br>    )<br>    # ...</pre><pre># src/retrieval_graph/index_graph.py<br># ...<br><br># generator function to create batches<br>def create_batches(docs, batch_size):<br>    &quot;&quot;&quot;Chunk documents into smaller batches.&quot;&quot;&quot;<br>    for i in range(0, len(docs), batch_size):<br>        yield docs[i:i + batch_size]<br>async def index_docs(<br>    state: IndexState, *, config: Optional[RunnableConfig] = None<br>) -&gt; dict[str, str]:<br>    if not config:<br>        raise ValueError(&quot;Configuration required to run index_docs.&quot;)<br>    with retrieval.make_retriever(config) as retriever:<br>        configuration = IndexConfiguration.from_runnable_config(config)<br>        # ...<br>        stamped_docs = ensure_docs_have_user_id(state.docs, config)<br>        # embed in batches to avoid rate limit errors<br>        batch_size = configuration.batch_size<br>        for i, batch in enumerate(create_batches(stamped_docs, batch_size)):<br>            if configuration.retriever_provider == &quot;milvus&quot;:<br>                retriever.add_documents(batch)<br>            else:<br>                await retriever.aadd_documents(batch)<br>            # sleep if there are more batches to embed<br>            if i &lt; (len(stamped_docs) // batch_size):<br>                time.sleep(60)<br>    return {&quot;docs&quot;: &quot;delete&quot;}<br># ...</pre><h3>Cloud Deployment</h3><p>We can now deploy the retrieval graph to langgraph cloud. The process is quite straight-forward:</p><ol><li>Create a Plus account on <a href="https://smith.langchain.com/">LangSmith</a>. This is necessary. Free-tier developer account does not allow deploying to LangGraph cloud. This costs 39 USD / month.</li><li>Now from LangSmith left-nav bar, go to <em>Deployments &gt; LangGraph Platform</em>. Here we can initiate a + New Deployment.</li><li>We can <em>Import from GitHub</em> my langGraph repo! And add environment configurations (the ones in .env file).</li><li>And just Submit to deploy! This step takes around 15+ minutes. Once this finishes, click on LangGraph Studio to access the studio to test the graphs!</li></ol><h3>References</h3><p>This blog was written in collaboration with Praneet Dhoolia (<a href="https://praneetdhoolia.github.io/">https://praneetdhoolia.github.io</a>) <a href="https://praneetdhoolia.github.io/2024/12/31/expanding-langgraph-retrieval-template.html">https://praneetdhoolia.github.io/2024/12/31/expanding-langgraph-retrieval-template.html</a></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=5c68dabd82db" width="1" height="1" alt=""><hr><p><a href="https://medium.com/heurislabs/building-and-deploying-a-virtual-assistant-with-langgraph-5c68dabd82db">Building &amp; Deploying an Agent with LangGraph</a> was originally published in <a href="https://medium.com/heurislabs">HeurisLabs</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Making a ChatGPT-like UI for your application in 15 minutes]]></title>
            <link>https://medium.com/heurislabs/making-a-chatgpt-like-ui-for-your-application-in-15-minutes-c522746f0911?source=rss-595481777489------2</link>
            <guid isPermaLink="false">https://medium.com/p/c522746f0911</guid>
            <category><![CDATA[langchain]]></category>
            <category><![CDATA[genai]]></category>
            <category><![CDATA[open-webui]]></category>
            <category><![CDATA[ai]]></category>
            <category><![CDATA[ollama]]></category>
            <dc:creator><![CDATA[Pranav Dhoolia]]></dc:creator>
            <pubDate>Mon, 24 Jun 2024 09:59:57 GMT</pubDate>
            <atom:updated>2024-06-24T09:59:57.591Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*zJD0PYmqFxLLGZl9.png" /></figure><p>Let us create a Generic Chat UI that we can use with ANY LLM solution we create in future!</p><p>In a <a href="https://esxr.io/local-llm-rag-solution-ollama-spotlight-langchain">previous blog</a>, we learnt how to extend the MacOS Spotlight search and build a smart solution on top of it that answers our questions by using the filesystem as a database.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*UdvI2iCbRGreTGOw.png" /></figure><p>We tested it on resumes stored in a folder to only retrieve resumes matching a specific criteria. However, the only way we could interact with such a system was through a commandline interface. Not exactly the dream!</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*KFLRHgJciX8n5O7g.png" /></figure><p>We want our solution to look somewhat like that of ChatGPT! As we saw in the first video, Ollama WebUI offers a very similar user experience. However, Ollama WebUI has primarily been designed to allow interactions with raw, out-of-the-box LLMs. Our motivation here is to use Ollama WebUI as the UI for our custom local RAG solution. <strong>So let’s get started!</strong></p><p>The code to the repo is provided here:</p><p><a href="https://github.com/esxr/local_llms_rag_solution">https://github.com/esxr/local_llms_rag_solution</a></p><p>Our intuition here is that Ollama web UI expects certain API end-points to be available to interact with Ollama’s API, i.e., the chat, generate, list, … etc. The main API being the chat which is invoked when we send a message. So if can provide the same API, and wrap it around our custom solution, we should be able to use Ollama WebUI for our user experience. And that’s exactly what we pursue in this video.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*QPPxVbWP1jPvHyGs.png" /></figure><p>If you haven’t seen the <a href="https://esxr.io/local-llm-rag-solution-ollama-spotlight-langchain">previous blog</a> or watched <a href="https://youtu.be/7S73a_XuTdg">the videos</a>, its recommended to watch them because this is an extension of that.</p><h3>Setup</h3><p>Let’s start by setting up the environment. To build our wrapper API we will use FastAPI. We will make pass-thru requests to Ollama’s API for all the other end-points that we don’t want to handle.</p><pre>conda create -n gen_ui python=3.11<br>conda activate gen_ui<br>pip install fastapi uvicorn httpx</pre><h3>Let’s build &amp; configure Ollama WebUI</h3><pre>git clone https://github.com/ollama-webui/ollama-webui.git<br>cd ollama-webui/</pre><pre># Copying required .env file<br>cp -RPp example.env .env</pre><p>Let’s now configure the .env file. We want our wrapper API to sit in middle of Ollama Web UI and Ollama. So we’ll modify the configuration here to point to our wrapper API instead of Ollama’s API.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*w2S-8e4lu789C0vz.jpg" /></figure><p>Let’s now build the Ollama WebUI frontend.</p><pre># Building Frontend Using Node<br>npm i<br>npm run build</pre><p>Let’s now install the Ollama WebUI backend dependencies.</p><pre>cd ./backend<br>pip install -r requirements.txt -U</pre><p>Let’s configure the backend to use our wrapper API instead.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*0Z1NqRKfE0jfGOVu.jpg" /></figure><p>Let’s now serve Ollama WebUI backend.</p><pre># Serving Frontend with the Backend<br>sh start.sh</pre><h3>Pass-thru Wrapper</h3><p>Let’s now start with wrapper API. Let’s begin by writing a pass-thru wrapper first. FastAPI extends the startlette library, which provides us with base middleware classes. We’ll use the BaseHTTPMiddleware to intercept all client requests coming on to our server, send them to Ollama’s API, and return the response to the client. We’ll add this middleware to the FastAPI application. We’ll use uvicorn to run our FastAPI application on port 5000.</p><pre># main.py<br>import asyncio<br>import json<br>from datetime import datetime<br><br>import httpx<br>from fastapi import FastAPI, Request<br>from fastapi.responses import StreamingResponse<br>from starlette.middleware.base import BaseHTTPMiddleware<br>from starlette.responses import Response<br>app = FastAPI()<br>OLLAMA_SERVER_URL = &quot;http://localhost:11434&quot;<br>class RelayMiddleware(BaseHTTPMiddleware):<br>    async def dispatch(self, request: Request, call_next):<br>        other_server_url = f&#39;{OLLAMA_SERVER_URL}{request.url.path}&#39;<br>        body = b&quot;&quot;<br>        async for chunk in request.stream():<br>            body += chunk<br>        async with httpx.AsyncClient() as client:<br>            req_data = {<br>                &quot;method&quot;: request.method,<br>                &quot;url&quot;: other_server_url,<br>                &quot;headers&quot;: request.headers.raw,<br>                &quot;params&quot;: request.query_params,<br>                &quot;content&quot;: body<br>            }<br>            response = await client.request(**req_data)<br>            return Response(response.content, status_code=response.status_code, headers=dict(response.headers))<br># Add the middleware to the FastAPI application<br>app.add_middleware(RelayMiddleware)<br>if __name__ == &quot;__main__&quot;:<br>    import uvicorn<br>    uvicorn.run(app, host=&quot;0.0.0.0&quot;, port=5000, log_level=&quot;info&quot;)</pre><p>Let’s do a quick test of our wrapper. Make sure to have Ollama running on port 11434.</p><p>Go to the dashboard at: <a href="http://localhost:8080.">http://localhost:8080.</a> You should see the dashboard without any errors.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*xNz3vby3kGeyrkx4.jpg" /></figure><p>Now select the model you want and chat. You should see the chat working as expected.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*zsSqrMCwyxHTMpsL.jpg" /></figure><p>Great! So our pass-thru wrapper is working as expected.</p><h3>Customizing /api/chat</h3><p>Let’s now override the /api/chat in our wrapper. Let’s begin by excluding /api/chat from the pass-thru wrapper. We’ll do this by adding a condition in the middleware to exclude /api/chat from the pass-thru wrapper.</p><pre>...<br>class RelayMiddleware(BaseHTTPMiddleware):<br>    async def dispatch(self, request: Request, call_next):<br>        # Check if the request is for the /api/chat route<br>        if request.url.path == &quot;/api/chat&quot;:<br>            # If so, just call the next item in the middleware stack<br>            return await call_next(request)<br><br># Otherwise, handle the request as before<br>        ...</pre><p>Before we customize the /api/chat endpoint, Let’s do a quick network analysis of Ollama WebUI to see what it expects from the /api/chat endpoint.</p><p>We find that:</p><ol><li>The /api/chat is a POST request with a text/event-stream content-type, and a application/x-ndjson response.</li></ol><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*g6mHJDqH9IeMCSOQ.jpg" /></figure><p>2. The request body is a JSON object with the following structure:</p><pre>{   <br>  &quot;model&quot;: &quot;&lt;model_name&gt;&quot;,   <br>  &quot;messages&quot;: [     <br>      {       <br>        &quot;role&quot;: &quot;&lt;user|assistant&gt;&quot;,       <br>        &quot;content&quot;: &quot;&lt;text&gt;&quot;     <br>      }   <br>  ] <br>}</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*DH_XHEJ-2d646UlY.jpg" /></figure><p>3. The response is a application/x-ndjson stream. With each message having line having the following structure:</p><pre>{     <br>  &quot;model&quot;: &quot;&lt;model_name&gt;&quot;,     <br>  &quot;created_at&quot;: &quot;&lt;datetime.utc.isoformat()&gt;&quot;,     <br>  &quot;message&quot;: {&quot;role&quot;: &quot;assistant&quot;, &quot;content&quot;: &quot;&lt;text&gt;&quot;},     <br>  &quot;done&quot;: &lt;False|True&gt; // True for the last message <br>}</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*05yFXfh6_bsTYqfe.jpg" /></figure><p>4. We learnt that the chat endpoint is a streaming endpoint. We read the input as an <strong>event-stream</strong>, and we’ll format the dummy output to the <strong>Newline delimited JSON</strong> (application/x-ndjson) format. NDJSON is a convenient format for storing or streaming structured data that may be processed one record at a time. Each line in an NDJSON file is a valid JSON value, but the entire file is not a valid JSON array. This format is particularly useful for streaming LLM outputs. Ollama uses it as well.</p><p>Let’s now customize the /api/chat endpoint to first push some dummy response. To begin with the processing logic is just a placeholder, and pretty much echoes back the request content.</p><pre>...<br>@app.post(&quot;/api/chat&quot;)<br>async def chat(request: Request):<br>    def process(input_data):<br>        # Process the input_data and return a string<br>        # This is a placeholder for actual processing logic<br>        processed_string = &quot;Processed: &quot; + str(input_data)<br>        return processed_string<br><br>async def generate_ndjson(model: str, msg: str):<br>        for word in msg.split():<br>            yield json.dumps({<br>                &quot;model&quot;: model,<br>                &quot;created_at&quot;: datetime.utcnow().isoformat() + &quot;Z&quot;,<br>                &quot;message&quot;: {<br>                    &quot;role&quot;: &quot;assistant&quot;,<br>                    &quot;content&quot;: word + &quot; &quot;<br>                },<br>                &quot;done&quot;: False<br>            }) + &quot;\n&quot;<br>            await asyncio.sleep(0.1)<br>        yield json.dumps({<br>            &quot;model&quot;: model,<br>            &quot;created_at&quot;: datetime.utcnow().isoformat() + &quot;Z&quot;,<br>            &quot;message&quot;: {&quot;role&quot;: &quot;assistant&quot;, &quot;content&quot;: &quot;.&quot;},<br>            &quot;done&quot;: True<br>        }) + &quot;\n&quot;<br>    # initialize input<br>    input_string = &quot;&quot;<br>    # Extract input from Ollama WebUI request (MIME type: text/event-stream)<br>    async for bytes in request.stream():<br>        if bytes:<br>            input_string = bytes.decode()<br>        else:<br>            continue<br>    # Process input_data<br>    input_data = json.loads(input_string)<br>    output = process(input_data)<br>    # Stream output in Ollama WebUI desired format (MIME type: application/x-ndjson)<br>    return StreamingResponse(generate_ndjson(model=input_data[&quot;model&quot;], msg=output), media_type=&quot;application/x-ndjson&quot;)<br>...</pre><p>Let’s now test our customized /api/chat endpoint.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*f_Jhc2ANLDN-QX1-.jpg" /></figure><p>Voila, our dummy echo based response is working as expected.</p><h3>Wrap /api/chat around our RAG solution</h3><p>Let’s now modify the chat API to invoke our RAG solution. But before that, we’ll need to modify our RAG solution. If you can recall from the <a href="https://github.com/esxr/local_llms_rag_solution">repo</a>, the code to our main function looks somewhat like this:</p><pre>...<br>def main():<br>    # Setup argument parser<br>    parser = argparse.ArgumentParser(description=&quot;Search files using Spotlight.&quot;)<br>    parser.add_argument(&quot;--folder&quot;, type=str, help=&quot;Folder to search in&quot;, default=None)<br>    parser.add_argument(&quot;query&quot;, type=str, help=&quot;Search query&quot;)<br><br>    # Parse commandline arguments<br>    args = parser.parse_args()<br>    # Create search query<br>    keywords = extract_keywords(args.query)<br>    query = &quot; OR &quot;.join(keywords)<br>    # Perform spotlight search<br>    search_results = search_with_spotlight(query, folder=args.folder)<br>    # Generate answer for the original query and top 3 search results<br>    print(<br>        generate_answer(<br>            args.query,<br>            search_results[:3],<br>            doc_type=&quot;Candidate&quot;<br>        )<br>    )<br><br>if __name__ == &quot;__main__&quot;:  # Entry point of the program<br>    main()<br>...</pre><p>Let’s modify this code a bit so that it can be used with other programs as well. Our final code should look somewhat like this</p><pre>def chat(query, folder=None):<br>    # Let&#39;s extact keywords from the query<br>    keywords = extract_keywords(query)<br><br>    # Construct an OR query from the keywords<br>    search_query = &quot; OR &quot;.join(keywords)<br>    # Perform spotlight search<br>    search_results = search_with_spotlight(search_query, folder=folder)<br>    # Generate answer for the original query and top 3 search results<br>    print(generate_answer(query, search_results[:3], doc_type=&quot;Candidate&quot;))<br>    # Print search results<br>    print(search_results)<br><br>if __name__ == &quot;__main__&quot;:  # Entry point of the program<br>    # Setup argument parser<br>    parser = argparse.ArgumentParser(description=&#39;Search files using Spotlight.&#39;)<br>    parser.add_argument(&#39;--folder&#39;, type=str, help=&#39;Folder to search in&#39;, default=None)<br>    parser.add_argument(&#39;query&#39;, type=str, help=&#39;Search query&#39;)<br>    # Parse commandline arguments<br>    args = parser.parse_args()<br>    chat(args.query, args.folder)</pre><p>Note that we’ve changed the function signature, and simplified it, so that we’re left with this signature</p><pre>...<br>def chat(query, folder=None):<br>...</pre><p>We can now import and plug this function anywhere we want to invoke the LLM-powered search.</p><p>let’s go back to our main.py` file for this project, and change the process function like so, replacing</p><pre>def process(input_data):<br>        ...<br>        processed_string = &quot;Processed: &quot; + str(input_data)<br>        return processed_string</pre><p>with this</p><pre>def process(input_data):<br>        ...<br>        processed_string = chat(<br>            input_data[&quot;messages&quot;][-2][&quot;content&quot;],<br>            folder=[&quot;~/Documents/Resumes/&quot;]<br>        )<br>        return processed_string</pre><p>That’s it! That’s all that was required to link our RAG solution to the API endpoint. We can now test the solution to see if it works.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*WSYHl6lgIVrYUesj.png" /></figure><p>Works like a charm!</p><h3>Conclusion</h3><p>In this blog, we have successfully designed a rapid and efficient approach to provide a user interface for all future projects, utilizing an existing open-source project (Ollama WebUI) as a foundation. Additionally, we have gained substantial insights into streaming APIs, a commonly utilized element in the realm of generative AI and LLMs.</p><p>We have also explored the API structure required by the Ollama WebUI and developed a wrapper for Ollama. This wrapper intercepts requests intended for Ollama and redirects them to the appropriate endpoints in our solution, seamlessly integrating with the Ollama framework.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=c522746f0911" width="1" height="1" alt=""><hr><p><a href="https://medium.com/heurislabs/making-a-chatgpt-like-ui-for-your-application-in-15-minutes-c522746f0911">Making a ChatGPT-like UI for your application in 15 minutes</a> was originally published in <a href="https://medium.com/heurislabs">HeurisLabs</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Adding ChatGPT-Like functionality to MacOS Spotlight Search]]></title>
            <link>https://medium.com/heurislabs/building-a-chatgpt-like-search-for-your-macos-with-ollama-and-langchain-ff878c1a82c6?source=rss-595481777489------2</link>
            <guid isPermaLink="false">https://medium.com/p/ff878c1a82c6</guid>
            <category><![CDATA[langchain]]></category>
            <category><![CDATA[ai]]></category>
            <category><![CDATA[langsmith]]></category>
            <category><![CDATA[genai]]></category>
            <category><![CDATA[ollama]]></category>
            <dc:creator><![CDATA[Pranav Dhoolia]]></dc:creator>
            <pubDate>Mon, 24 Jun 2024 09:50:23 GMT</pubDate>
            <atom:updated>2024-06-24T10:01:20.715Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*Pr09E-NGy4brcjxl.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*rja2PX_7W38z_o4a.png" /></figure><p>Adding ChatGPT like search system to the MacOS filesystem with Spotlight, Ollama, and LangChain</p><h3>Introduction</h3><p>In one of my <a href="https://youtu.be/4Rl2r9JV-6Q">YouTube videos</a>, I locally hosted LLMs using <a href="https://ollama.ai/">Ollama</a>, and interacted with them in a ChatGPT like interface using <a href="https://github.com/ollama-webui/ollama-webui">Ollama Web UI</a> also hosted locally. This continues that journey. Here, I build a solution using a locally hosted LLM and <a href="https://langchain.com/">LangChain</a> (the most popular framework for building LLM solutions).</p><p>The Youtube video for the same is available <a href="https://youtu.be/7S73a_XuTdg">here</a></p><h3>Motivation</h3><p>I love the built-in SpotLight on Mac. It indexes my documents and allows me to search for keywords and find relevant documents. In a lot of cases, I am not looking for a document, I am looking for answers to my questions that I know are somewhere there in my documents.</p><p>LLMs are good at answering questions. But they don’t have my documents. So I want to build a solution that retrieves relevant documents using Spotlight, and then passes them to LLM to generate answers to my questions. Such technique where we first retrieve information from a database or corpus, and then pass it on to an LLM to generate a response is also referred to as a <strong>Retrieval Augmented Generation</strong> solution. Since we’ll do everything local, let’s call this <strong>Local RAG</strong>.</p><h3>Setup</h3><p>Let’s begin by creating a python environment. Let me use conda for this.</p><pre>conda create -n local_rag python=3.11<br>conda activate local_rag</pre><p>Let’s now install langchain</p><pre>pip install langchain</pre><p>For this exercise I want to search thru PDF documents. So let’s install pypdf. It will help extract text from PDF documents.</p><pre>pip install pypdf</pre><h3>Programmatic use of Spotlight</h3><p>Let’s first write a python program to use Spotlight programmatically. Let’s create a shell program which receives the query as an input.</p><pre>import argparse  # To parse command line arguments<br>def main():<br>    # Setup argument parser<br>    parser = argparse.ArgumentParser(description=&#39;Search files using Spotlight.&#39;)<br>    parser.add_argument(&#39;query&#39;, type=str, help=&#39;Search query&#39;)<br>    # Parse commandline arguments<br>    args = parser.parse_args()<br><br>if __name__ == &quot;__main__&quot;:      # Entry point of the program<br>    main()</pre><p>Let’s now write a function to search using spotlight for files matching this query. We’ll use the mdfind command and run it as a subprocess. We’ll then call this from our main function.</p><pre>import argparse  # To parse command line arguments<br>import subprocess  # To run spotlight as a subprocess<br>def search_with_spotlight(query):<br>    # Prepare the command<br>    command = [&quot;mdfind&quot;]<br>    command += [query]<br><br>    # Execute mdfind command and capture output<br>    result = subprocess.run(command, capture_output=True, text=True)<br><br>    # Check if the command was successful<br>    if result.returncode != 0:<br>        print(&quot;Error in executing search&quot;)<br>        return []<br><br>    # Split the output into lines to get individual file paths<br>    file_paths = result.stdout.strip().split(&#39;\n&#39;)<br>    return file_paths<br><br>def main():<br>    # Setup argument parser<br>    parser = argparse.ArgumentParser(description=&#39;Search files using Spotlight.&#39;)<br>    parser.add_argument(&#39;query&#39;, type=str, help=&#39;Search query&#39;)<br>    # Parse commandline arguments<br>    args = parser.parse_args()<br>    # Perform spotlight search<br>    search_results = search_with_spotlight(args.query)<br>    # Print search results<br>    print(search_results)<br><br>if __name__ == &quot;__main__&quot;:  # Entry point of the program<br>    main()</pre><p>Let’s run it now.</p><pre>python spotlight.py &quot;search_with_spotlight using mdfind&quot;</pre><p>We should see the program file in results.</p><p>Let me also extend this to support restricting the search to a specific folder. We’ll add a new argument to our program to optionally take in a folder. We’ll relay this folder to the search function. And use it to restrict the search to this folder.</p><pre>import argparse  # To parse command line arguments<br>import subprocess  # To run spotlight as a subprocess<br><br>def search_with_spotlight(query, folder=None):<br>    # Prepare the command<br>    command = [&quot;mdfind&quot;]<br>    if folder is not None:<br>        command += [&quot;-onlyin&quot;, folder]<br>    command += [query]<br>    # Execute mdfind command and capture output<br>    result = subprocess.run(command, capture_output=True, text=True)<br>    # Check if the command was successful<br>    if result.returncode != 0:<br>        print(&quot;Error in executing search&quot;)<br>        return []<br>    # Split the output into lines to get individual file paths<br>    file_paths = result.stdout.strip().split(&#39;\n&#39;)<br>    return file_paths<br><br>def main():<br>    # Setup argument parser<br>    parser = argparse.ArgumentParser(description=&#39;Search files using Spotlight.&#39;)<br>    parser.add_argument(&#39;--folder&#39;, type=str, help=&#39;Folder to search in&#39;, default=None)<br>    parser.add_argument(&#39;query&#39;, type=str, help=&#39;Search query&#39;)<br>    # Parse commandline arguments<br>    args = parser.parse_args()<br>    # Perform spotlight search<br>    search_results = search_with_spotlight(args.query, folder=args.folder)<br>    # Print search results<br>    print(search_results)<br><br>if __name__ == &quot;__main__&quot;:  # Entry point of the program<br>    main()</pre><p>We can now run our search in a specific folder like this:</p><pre>python spotlight.py --folder . &quot;search_with_spotlight using mdfind&quot;</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/650/0*ZO6NVhTIA-QQl_FA.png" /></figure><p>Since we restricted our search to the current folder, it got us just our program file (which has the query string in it).</p><h3>Layering LLM on top of Spotlight</h3><p>While Spotlight gets us the files of interest containing certain keywords. It doesn’t precisely answer our questions. Let’s say we have a bunch of resumes in a folder, and want to ask a complex question like “<em>Give me a short (about 100 words) summary, including contact details, of candidates having coursera certification in Generative AI</em>”. Based on keywords like <em>resume</em>, <em>coursera</em>, <em>generative AI</em>; Spotlight may get us the resume files of interest. But it will not give us a short summary including contact details for the candidates. Let’s now use LLMs to do this for us.</p><p>Let’s begin by writing our driver function.</p><pre>def generate_answer(question: str, file_paths: list[str], **kwargs: dict) -&gt; str:<br>    # Get Document objects with text content from file paths<br>    docs = get_docs(file_paths)<br><br>    # Construct QA chain<br>    qa_chain = build_qa_chain()<br>    # Prepare input<br>    input = {&quot;docs&quot;:docs, &quot;question&quot;:question}<br>    input.update(kwargs)<br>    # Invoke the chain<br>    return qa_chain.invoke(input)<br><br>if __name__ == &quot;__main__&quot;:  # Entry point of the program<br>    print(generate_answer(<br>        question=&quot;Give me a short, 100 words summary of each candidate having a coursera certification in Generative AI. Please include their contact details as well.&quot;,<br>        file_paths=[&quot;~/Documents/Resumes/Pranav Dhoolia.pdf&quot;],<br>        doc_type=&quot;Candidate Resume&quot;<br>    ))</pre><p>For this exercise let’s assume all our documents are PDFs. Let’s write the get_docs function to extract text documents from pdf files.</p><pre>from langchain.document_loaders import PyPDFLoader<br>from langchain.schema import Document<br>def get_docs(file_paths: list[str]) -&gt; list[Document]:<br>    docs = []<br>    for file_path in file_paths:<br>        loader = PyPDFLoader(file_path)<br>        pages = loader.load()<br>        docs.append(Document(<br>            page_content=&quot;\n&quot;.join(page.page_content for page in pages),<br>            metadata={&quot;source&quot;: file_path}<br>        ))<br>    return docs<br><br>def generate_answer(question: str, file_paths: list[str], **kwargs: dict) -&gt; str:<br>    ...</pre><p>Let’s now build our QA chain. We’ll use the LangChain Expression Language syntax for this. Let’s start by defining the Prompt, the LLM model, and the OutputParser</p><pre>from langchain.document_loaders import PyPDFLoader<br>from langchain.prompts import PromptTemplate<br>from langchain.schema import Document, StrOutputParser<br>from langchain_community.chat_models import ChatOllama<br>from langchain_core.runnables.base import RunnableSerializable<br>def get_docs(file_paths: list[str]) -&gt; list[Document]:<br>    ...<br><br>def build_qa_chain() -&gt; RunnableSerializable:<br>    return (<br>        {}  ## Build the data to be injected into the prompt<br>        | PromptTemplate.from_template(<br>            &quot;Use only the following context to answer the question at the end.&quot;<br>            &quot;\nDo not use anything other than the context below to answer the question.&quot;<br>            &quot;\nI&#39;ll repeat it is extremely important that you only use the provided context below to answer the question.&quot;<br>            &quot;\nIf the context below is not sufficient to answer, just say that you don&#39;t know, don&#39;t try to make up an answer.&quot;<br>            &quot;\n\nContext:\n\n{context}\n\nQuestion: {question}&quot;<br>        )<br>        | ChatOllama(model=&quot;zephyr:7b-beta-q5_K_M&quot;)<br>        | StrOutputParser()<br>    )<br><br>def generate_answer(question: str, file_paths: list[str], **kwargs: dict) -&gt; str:<br>    ...</pre><p>Let’s now build the data to be injected into the prompt. we’ll use the docs, question, and doc_type expected while invoking the chain.</p><pre>...<br>from langchain_core.prompts import format_document<br>...<br>def build_qa_chain() -&gt; RunnableSerializable:<br>    def build_context(docs: list[Document], doc_type: str=&quot;Document&quot;) -&gt; str:<br>        doc_prompt = PromptTemplate.from_template(&quot;{page_content}&quot;)<br>        context = &quot;&quot;<br>        for i, doc in enumerate(docs):<br>            context += f&quot;{doc_type} {i + 1}\n---\n{format_document(doc, doc_prompt)}\n\n&quot;<br>        return context<br>    return (<br>        {<br>            &quot;context&quot;: lambda input: build_context(input[&quot;docs&quot;], input.get(&quot;doc_type&quot;, &quot;Document&quot;)),<br>            &quot;question&quot;: lambda input: input[&quot;question&quot;]<br>        }<br>        | ...<br>    )</pre><h3>Debugging LLM solutions</h3><p>One of the reasons I really love LangChain, is that with LangSmith, it offers a very powerful debugging experience. So before running our LLM layer, let’s set up LangSmith.</p><ol><li>Go to <a href="https://smith.langchain.com/">LangSmith</a> and login with your credentials</li></ol><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*soWtsF9Br6EPbqVP.jpg" /></figure><p>2. Create your API key for your Organization</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*65eWCaTdBxyxzk03.jpg" /></figure><p>3. Create a new project</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*GHMM6keqTz51dEZo.jpg" /></figure><p>4. Setup the environment variables. I have set these up in my .zshrc.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*xqhpKMn5sBlga0V5.jpg" /></figure><p>5. Let’s now run our LLM layer.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*BCHK9lAoS2ajqCd2.png" /></figure><p>It returns here a quick summary of the candidate matching the query, along with their contact details.</p><p>Let’s now goto LangSmith and see the various aspects of LLM execution that happened here!</p><ul><li>Let go back to our project and check Traces</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*4GHzkz-NUiBJjKuK.png" /></figure><p>We see a trace for our chain execution. Let’s expand it</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*mw2VjFCIIjBikEfl.png" /></figure><p>We can see the things corresponding to components we used in building the chains, i.e., the data, the prompt, the chat model, and the output parser</p><p>For the data, we can see the input, and what the context built from it looks like</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*0ewyndBZlt7-MtR7.png" /></figure><p>For the prompt, we can see data input to the template, and the prompt generated from it</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*hcYLooB2oizzm5Bd.png" /></figure><p>For the chat model, we can see the prompt input to the model, and the output generated by it</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*On5_9aZspHglUU6z.png" /></figure><p>For the output parser, we can see the input to it, and the output produced by it.</p><p>This offers a powerful general debugging experience.</p><p>Further, if we want to fine tune our model, based on the usage of our application over a period of time, we can select the traces we want to use for fine tuning, and easily construct a dataset to use for fine tuning from it like this.</p><p>Select the trace(s) and click on the Add to Dataset button</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*2WCuPbjhZeOXqYII.png" /></figure><p>Since we don’t already have a dataset, we’ll create a new one</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*j5x-Ngh8QyKKZeeU.png" /></figure><p>And now we can see a preview of dataset being created as well. — In the preview here we can see as input columns: the context, the question, and the doc domain type, and as output the LLM’s response.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*ob9nqt5_zqJ9PdYA.png" /></figure><p>Over a period of time, once we have enough traces, we can select and add all good traces to this dataset, and use it for fine tuning our model!</p><h3>Combining the two</h3><p>So we have built a programmatic interface to Spotlight, and we have built an LLM based QA chain that given some content can precisely answer a question based on that content. We now need to chain the two together. Before we do that, let’s first understand a little complication that we anticipate here. As we saw earlier, for our complex query, Spotlight didn’t return anything. That’s because it searched for files contained all the words in our query. But our query really wants a much smaller subset of keywords to be searched for. Most of the query is really a description of what we want from the files found containing that subset of keywords.</p><p>So we need to first extract the keywords from our query, and then use Spotlight to search for files containing those keywords. Once again an LLM chain is perhaps the best for identifying those keywords so let’s write first write a function to do that.</p><pre>import json<br>from langchain.prompts import PromptTemplate<br>from langchain.schema import StrOutputParser<br>from langchain_community.chat_models import ChatOllama<br><br>def extract_keywords(query: str) -&gt; list[str]:<br>    # Construct the keyword extraction chain<br>    keyword_extraction_chain = (<br>        {<br>            &quot;query&quot;: lambda query: query<br>        }<br>        | PromptTemplate.from_template(<br>            &quot;From the query below please extract search keywords. &quot;<br>            &quot;The search keywords must be part of the query string. &quot;<br>            &quot;Avoid words general to this domain such as: candidates, resume, contact, and details. &quot;<br>            &quot;Output a json formatted list of keyword strings. &quot;<br>            &quot;Each item in the output json should be a string.&quot;<br>            &quot;\n\nQuery:\n```{query}\n```&quot;<br>        )<br>        | ChatOllama(model=&quot;zephyr:7b-beta-q5_K_M&quot;)<br>        | StrOutputParser()<br>    )<br>    # Invoke the chain<br>    response = keyword_extraction_chain.invoke(query)<br>    # Get the substring between last `[` and `]`<br>    return json.loads(f&#39;[{response[response.rfind(&quot;[&quot;) + 1:response.rfind(&quot;]&quot;)].strip()}]&#39;)<br><br>if __name__ == &quot;__main__&quot;:  # Entry point of the program<br>    print(extract_keywords(&quot;Give me a short (about 100 words) summary of candidates having coursera certification in Generative AI. Please include their contact details.&quot;))</pre><p>Now that we have our search keywords extractor, let’s modify our main function to combine everything.</p><pre>...<br>from keywords import extract_keywords<br>from qa import generate_answer<br>def search_with_spotlight(query, folder=None):<br>    ...<br>    return file_paths<br><br>def main():<br>    ...<br>    args = parser.parse_args()<br>    # Let&#39;s extact keywords from the query<br>    keywords = extract_keywords(args.query)<br>    # Construct an OR query from the keywords<br>    query = &quot; OR &quot;.join(keywords)<br>    # Perform spotlight search<br>    search_results = search_with_spotlight(query, folder=args.folder)<br>    # Generate answer from the query and top 3 search results<br>    return generate_answer(args.query, search_results[:3], doc_type=&quot;Candidate&quot;)<br><br>if __name__ == &quot;__main__&quot;:  # Entry point of the program<br>    print(main())</pre><p>Let’s now run everything together!</p><pre>python spotlight.py --folder ../test/resume &quot;Give me a short (about 100 words) summary of candidates having coursera certification in Generative AI. Please include their contact details.&quot;</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*LVwF28xobQP4DSrj.png" /></figure><p>Works like a charm!</p><h3>Conclusion</h3><p>We successfully used locally hosted LLMs along with Langchain framework to build an LLM solution. This solution provided amazing answers to quite complex questions on our local content. What’s more, we didn’t even have to setup any additional tools.</p><p>We used Spotlight to search for files containing certain keywords, and then used LLMs to precisely answer questions based on those files. We also saw the power of the LangChain framework, in how easy it was to use LangSmith to debug our LLM based solutions, and how easily we could use it to create datasets, based on the usage traces of our solution, to fine tune our models.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=ff878c1a82c6" width="1" height="1" alt=""><hr><p><a href="https://medium.com/heurislabs/building-a-chatgpt-like-search-for-your-macos-with-ollama-and-langchain-ff878c1a82c6">Adding ChatGPT-Like functionality to MacOS Spotlight Search</a> was originally published in <a href="https://medium.com/heurislabs">HeurisLabs</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Build and Deploy GenAI Solutions Locally]]></title>
            <link>https://medium.com/heurislabs/build-and-deploy-genai-solutions-locally-c9ee98257414?source=rss-595481777489------2</link>
            <guid isPermaLink="false">https://medium.com/p/c9ee98257414</guid>
            <category><![CDATA[retrieval-augmented-gen]]></category>
            <category><![CDATA[ai]]></category>
            <category><![CDATA[ollama]]></category>
            <category><![CDATA[langchain]]></category>
            <category><![CDATA[genai]]></category>
            <dc:creator><![CDATA[Pranav Dhoolia]]></dc:creator>
            <pubDate>Sun, 28 Jan 2024 12:53:59 GMT</pubDate>
            <atom:updated>2024-06-24T10:05:52.923Z</atom:updated>
            <content:encoded><![CDATA[<p><strong>Bust the myth that AI is only for tech giants.</strong> The process of setting up and running sophisticated AI models at home is surprisingly simple!</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/797/0*HyLYuZMjYbUd3XQz.png" /></figure><p>This series explains:</p><ul><li><a href="https://youtu.be/4Rl2r9JV-6Q">Locally running a base AI model on your desktop</a></li><li><a href="https://youtu.be/7S73a_XuTdg">Building any AI solution on top of that model, using your own data</a></li><li><a href="https://youtu.be/sZ1aJ0zfgmY">Attaching a UI to your solution within minutes</a></li></ul><p>Empowered with this knowledge, anyone can create their own AI solution, tailored to their specific needs. The future of AI is accessible to everyone, we just need to take the first step!</p><p><em>Originally published at </em><a href="https://navvy.co/build-and-deploy-genai-solutions-locally"><em>http://navvy.co</em></a><em> on January 28, 2024.</em></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=c9ee98257414" width="1" height="1" alt=""><hr><p><a href="https://medium.com/heurislabs/build-and-deploy-genai-solutions-locally-c9ee98257414">Build and Deploy GenAI Solutions Locally</a> was originally published in <a href="https://medium.com/heurislabs">HeurisLabs</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
    </channel>
</rss>