<?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" xmlns:media="http://search.yahoo.com/mrss/" version="2.0">
    <channel>
        
        <title>
            <![CDATA[ Chidozie Managwu - freeCodeCamp.org ]]>
        </title>
        <description>
            <![CDATA[ Browse thousands of programming tutorials written by experts. Learn Web Development, Data Science, DevOps, Security, and get developer career advice. ]]>
        </description>
        <link>https://www.freecodecamp.org/news/</link>
        <image>
            <url>https://cdn.freecodecamp.org/universal/favicons/favicon.png</url>
            <title>
                <![CDATA[ Chidozie Managwu - freeCodeCamp.org ]]>
            </title>
            <link>https://www.freecodecamp.org/news/</link>
        </image>
        <generator>Eleventy</generator>
        <lastBuildDate>Tue, 15 Sep 2026 21:38:12 +0000</lastBuildDate>
        <atom:link href="https://www.freecodecamp.org/news/author/Doxzy/rss.xml" rel="self" type="application/rss+xml" />
        <ttl>60</ttl>
        
            <item>
                <title>
                    <![CDATA[ How to Build Referral-Aware Split Payment Flows in Django ]]>
                </title>
                <description>
                    <![CDATA[ When a product has a single checkout, payment logic is usually simple: charge the user, mark the order as paid, and move on. But once the business model includes a deposit now, a balance later, and re ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-referral-aware-split-payment-flows-in-django/</link>
                <guid isPermaLink="false">6a8c8383ca0d5a1002b19fa3</guid>
                
                    <category>
                        <![CDATA[ Django ]]>
                    </category>
                
                    <category>
                        <![CDATA[ payments ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Python ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Chidozie Managwu ]]>
                </dc:creator>
                <pubDate>Mon, 24 Aug 2026 17:46:43 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/4e8f9e07-789d-417b-96af-3a41b327d047.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>When a product has a single checkout, payment logic is usually simple: charge the user, mark the order as paid, and move on.</p>
<p>But once the business model includes a deposit now, a balance later, and referral or coupon attribution in between, the problem changes completely.</p>
<p>At that point, you're not just collecting money. You're managing a payment workflow.</p>
<p>In this tutorial, I’ll show you how to build a referral-aware split payment flow in Django that:</p>
<ul>
<li><p>tracks Step 2 deposit and balance separately</p>
</li>
<li><p>supports coupon and partner linkage</p>
</li>
<li><p>prevents duplicate payment processing</p>
</li>
<li><p>uses database transactions safely</p>
</li>
<li><p>keeps referral payouts consistent</p>
</li>
<li><p>unlocks deliverables only when the workflow is complete</p>
</li>
</ul>
<p>The main idea is simple: treat payment as a state transition, not just a webhook event.</p>
<h3 id="heading-table-of-contents">Table of Contents</h3>
<ul>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-project-structure">Project Structure</a></p>
</li>
<li><p><a href="#heading-designing-the-data-model">Designing the Data Model</a></p>
</li>
<li><p><a href="#heading-how-split-payments-work">How Split Payments Work</a></p>
</li>
<li><p><a href="#heading-finalizing-payments-safely">Finalizing Payments Safely</a></p>
</li>
<li><p><a href="#heading-handling-webhooks-idempotently">Handling Webhooks Idempotently</a></p>
</li>
<li><p><a href="#heading-applying-coupons-and-referral-attribution">Applying Coupons and Referral Attribution</a></p>
</li>
<li><p><a href="#heading-why-the-referral-payout-should-be-explicit">Why the Referral Payout Should Be Explicit</a></p>
</li>
<li><p><a href="#heading-unlocking-deliverables-at-the-right-time">Unlocking Deliverables at the Right Time</a></p>
</li>
<li><p><a href="#heading-common-mistakes">Common Mistakes</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>Before following along, you should already be comfortable with:</p>
<ul>
<li><p>Django models, views, and querysets</p>
</li>
<li><p>database transactions in Django</p>
</li>
<li><p>basic webhook concepts</p>
</li>
<li><p>Python class-based or function-based view patterns</p>
</li>
<li><p>how payment providers like Stripe or Paystack send event callbacks</p>
</li>
</ul>
<p>You don't need to be an expert in payments, but you should understand how Django talks to the database and how to store state safely.</p>
<h2 id="heading-project-structure">Project Structure</h2>
<p>Here's a simple structure for the parts we need:</p>
<pre><code class="language-text">payments/
├── models.py
├── services.py
├── views.py
├── urls.py
└── webhooks.py
</code></pre>
<p>This separation matters.</p>
<ul>
<li><p><code>models.py</code> stores the business state</p>
</li>
<li><p><code>services.py</code> contains the finalization logic</p>
</li>
<li><p><code>views.py</code> handles user-facing payment actions</p>
</li>
<li><p><code>webhooks.py</code> receives gateway callbacks</p>
</li>
<li><p><code>urls.py</code> connects endpoints</p>
</li>
</ul>
<p>Keeping payment logic out of views makes the system easier to test and much harder to break.</p>
<h2 id="heading-designing-the-data-model">Designing the Data Model</h2>
<p>The most important decision is to model the payment stages clearly.</p>
<p>Instead of storing one vague “paid” flag, define the stages your business actually uses. For example:</p>
<pre><code class="language-python">from django.db import models
from django.conf import settings

class Journey(models.Model):
    user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
    deposit_paid = models.BooleanField(default=False)
    balance_paid = models.BooleanField(default=False)
    deliverables_released = models.BooleanField(default=False)
    referral_code = models.CharField(max_length=50, blank=True, default="")
    partner_name = models.CharField(max_length=120, blank=True, default="")
    created_at = models.DateTimeField(auto_now_add=True)

class Payment(models.Model):
    STAGE_DEPOSIT = "deposit"
    STAGE_BALANCE = "balance"

    STAGE_CHOICES = [
        (STAGE_DEPOSIT, "Deposit"),
        (STAGE_BALANCE, "Balance"),
    ]

    STATUS_PENDING = "pending"
    STATUS_SUCCEEDED = "succeeded"
    STATUS_FAILED = "failed"

    STATUS_CHOICES = [
        (STATUS_PENDING, "Pending"),
        (STATUS_SUCCEEDED, "Succeeded"),
        (STATUS_FAILED, "Failed"),
    ]

    journey = models.ForeignKey(Journey, on_delete=models.CASCADE, related_name="payments")
    stage = models.CharField(max_length=20, choices=STAGE_CHOICES)
    gateway_reference = models.CharField(max_length=120, unique=True)
    amount = models.DecimalField(max_digits=10, decimal_places=2)
    discount_amount = models.DecimalField(max_digits=10, decimal_places=2, default=0)
    net_amount = models.DecimalField(max_digits=10, decimal_places=2)
    status = models.CharField(max_length=20, choices=STATUS_CHOICES, default=STATUS_PENDING)
    raw_payload = models.JSONField(null=True, blank=True)
    finalized_at = models.DateTimeField(null=True, blank=True)

class ReferralPayout(models.Model):
    payment = models.OneToOneField(Payment, on_delete=models.CASCADE, related_name="referral_payout")
    partner_name = models.CharField(max_length=120)
    amount = models.DecimalField(max_digits=10, decimal_places=2)
    is_paid = models.BooleanField(default=False)
    created_at = models.DateTimeField(auto_now_add=True)
</code></pre>
<p>This model design gives you a clean separation:</p>
<ul>
<li><p><code>Journey</code> represents the customer’s overall progress</p>
</li>
<li><p><code>Payment</code> represents each financial event</p>
</li>
<li><p><code>ReferralPayout</code> represents what the partner earns from that payment</p>
</li>
</ul>
<p>That separation is what keeps the logic manageable.</p>
<h2 id="heading-how-split-payments-work">How Split Payments Work</h2>
<p>Split payments usually follow a simple pattern:</p>
<ol>
<li><p>the customer pays a deposit</p>
</li>
<li><p>the system records that deposit</p>
</li>
<li><p>a later payment clears the balance</p>
</li>
<li><p>the full workflow becomes complete</p>
</li>
<li><p>deliverables unlock only after the right stage</p>
</li>
</ol>
<p>The important part is that each payment stage should be explicit.</p>
<p>If you treat the deposit and balance as two different milestones, then:</p>
<ul>
<li><p>discounts can apply to one stage and not the other</p>
</li>
<li><p>referral attribution can be recorded per stage</p>
</li>
<li><p>payouts can happen only when the stage is truly completed</p>
</li>
<li><p>admin users can see the exact status of the workflow</p>
</li>
</ul>
<p>That's much safer than trying to infer meaning from the amount alone.</p>
<h2 id="heading-finalizing-payments-safely">Finalizing Payments Safely</h2>
<p>The finalization logic should live in a service function, not directly inside the webhook view.</p>
<p>Here's a simple example:</p>
<pre><code class="language-python">from django.db import transaction
from django.utils import timezone

def finalize_payment(*, payment):
    with transaction.atomic():
        locked_payment = Payment.objects.select_for_update().select_related("journey").get(pk=payment.pk)

        if locked_payment.status == Payment.STATUS_SUCCEEDED:
            return locked_payment

        locked_payment.status = Payment.STATUS_SUCCEEDED
        locked_payment.finalized_at = timezone.now()
        locked_payment.save(update_fields=["status", "finalized_at"])

        journey = locked_payment.journey

        if locked_payment.stage == Payment.STAGE_DEPOSIT:
            journey.deposit_paid = True
        elif locked_payment.stage == Payment.STAGE_BALANCE:
            journey.balance_paid = True

        if journey.deposit_paid and journey.balance_paid:
            journey.deliverables_released = True

        journey.save(update_fields=["deposit_paid", "balance_paid", "deliverables_released"])

        if journey.referral_code and not hasattr(locked_payment, "referral_payout"):
            ReferralPayout.objects.create(
                payment=locked_payment,
                partner_name=journey.partner_name,
                amount=locked_payment.net_amount * 0.10,
            )

        return locked_payment
</code></pre>
<p>There are three important ideas here.</p>
<p>First, <code>transaction.atomic()</code> makes sure the update happens as one unit.</p>
<p>Second, <code>select_for_update()</code> locks the row so two processes don't finalize the same payment at the same time.</p>
<p>Third, the function checks whether the payment was already processed before doing any work.</p>
<p>That gives you a safe and repeatable finalization path.</p>
<h2 id="heading-handling-webhooks-idempotently">Handling Webhooks Idempotently</h2>
<p>Payment gateways can send the same webhook more than once.</p>
<p>That means your webhook handler must be idempotent, which simply means it can safely run multiple times without creating duplicate records or breaking state.</p>
<p>Here's a clean pattern:</p>
<pre><code class="language-python">import json
from django.http import HttpResponse, JsonResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST

@csrf_exempt
@require_POST
def payment_webhook(request):
    payload = json.loads(request.body.decode("utf-8"))

    event_type = payload.get("event")
    data = payload.get("data", {})
    reference = data.get("reference")

    if not reference:
        return JsonResponse({"error": "Missing reference"}, status=400)

    if event_type != "charge.success":
        return HttpResponse(status=200)

    payment = Payment.objects.filter(gateway_reference=reference).first()
    if not payment:
        return JsonResponse({"error": "Payment not found"}, status=404)

    finalize_payment(payment=payment)
    return HttpResponse(status=200)
</code></pre>
<p>This view stays intentionally small.</p>
<p>It doesn't try to decide business rules. It only reads the webhook, finds the payment, and passes it to the service layer.</p>
<p>That makes it much easier to test and debug.</p>
<h2 id="heading-applying-coupons-and-referral-attribution">Applying Coupons and Referral Attribution</h2>
<p>Coupons and partner codes become tricky when the payment is split across stages.</p>
<p>For example, a coupon might apply only to the deposit. Or it might apply to the balance only. Or it might affect both.</p>
<p>The best solution is to store that rule explicitly.</p>
<p>Here's a simple model for stage-aware coupon logic:</p>
<pre><code class="language-python">class DiscountCode(models.Model):
    APPLIES_DEPOSIT = "deposit"
    APPLIES_BALANCE = "balance"
    APPLIES_BOTH = "both"

    APPLIES_CHOICES = [
        (APPLIES_DEPOSIT, "Deposit only"),
        (APPLIES_BALANCE, "Balance only"),
        (APPLIES_BOTH, "Both stages"),
    ]

    code = models.CharField(max_length=50, unique=True)
    partner_name = models.CharField(max_length=120, blank=True, default="")
    applies_to = models.CharField(max_length=20, choices=APPLIES_CHOICES, default=APPLIES_BOTH)
    percent_off = models.PositiveSmallIntegerField(default=0)
    is_active = models.BooleanField(default=True)
</code></pre>
<p>Now your payment flow can check whether the coupon is valid for the current stage before applying it.</p>
<p>A helper function might look like this:</p>
<pre><code class="language-python">def calculate_discount(amount, coupon, stage):
    if not coupon or not coupon.is_active:
        return 0

    if coupon.applies_to == DiscountCode.APPLIES_DEPOSIT and stage != Payment.STAGE_DEPOSIT:
        return 0

    if coupon.applies_to == DiscountCode.APPLIES_BALANCE and stage != Payment.STAGE_BALANCE:
        return 0

    return amount * (coupon.percent_off / 100)
</code></pre>
<p>This keeps referral and coupon logic predictable.</p>
<h2 id="heading-why-the-referral-payout-should-be-explicit">Why the Referral Payout Should Be Explicit</h2>
<p>A lot of systems accidentally mix these ideas:</p>
<ul>
<li><p>payment received</p>
</li>
<li><p>coupon applied</p>
</li>
<li><p>referral credited</p>
</li>
<li><p>referral paid out</p>
</li>
</ul>
<p>Those aren't the same thing.</p>
<p>A referral code can be attached at checkout, but the actual payout should be created only when the business rules say it's safe.</p>
<p>For example, you might decide:</p>
<ul>
<li><p>the partner gets credited when the deposit is paid</p>
</li>
<li><p>the payout is created only after the balance clears</p>
</li>
<li><p>the payout amount is based on the final net payment</p>
</li>
</ul>
<p>That way, you don't pay out early if the customer never completes the full flow.</p>
<h2 id="heading-unlocking-deliverables-at-the-right-time">Unlocking Deliverables at the Right Time</h2>
<p>One of the biggest mistakes in split payment systems is unlocking everything after the first payment.</p>
<p>That creates operational problems and trust issues.</p>
<p>A better rule is:</p>
<ul>
<li><p>deposit confirms intent</p>
</li>
<li><p>balance confirms completion</p>
</li>
<li><p>deliverables unlock only after the balance is received</p>
</li>
</ul>
<p>You can keep that logic very simple in the <code>Journey</code> model:</p>
<pre><code class="language-python">def update_delivery_state(journey):
    journey.deliverables_released = journey.deposit_paid and journey.balance_paid
    journey.save(update_fields=["deliverables_released"])
</code></pre>
<p>The logic is readable, testable, and easy for an admin to understand.</p>
<h2 id="heading-common-mistakes">Common Mistakes</h2>
<p>Here are the mistakes that usually cause trouble in split payment systems:</p>
<h4 id="heading-1-using-one-payment-flag-for-everything">1. Using one payment flag for everything</h4>
<p>A single <code>paid=True</code> field isn't enough when the business has multiple payment stages.</p>
<h4 id="heading-2-letting-the-webhook-write-directly-to-many-tables">2. Letting the webhook write directly to many tables</h4>
<p>That makes the flow hard to test and easy to break. Use a service layer instead.</p>
<h4 id="heading-3-forgetting-idempotency">3. Forgetting idempotency</h4>
<p>If the gateway retries a webhook, you shouldn't create duplicate payouts or double-update the journey.</p>
<h4 id="heading-4-applying-coupons-without-checking-the-stage">4. Applying coupons without checking the stage</h4>
<p>A code that's valid for the deposit may not be valid for the balance.</p>
<h4 id="heading-5-releasing-deliverables-too-early">5. Releasing deliverables too early</h4>
<p>Payment received doesn't always mean the workflow is complete.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>Referral-aware split payment systems aren't hard because of the payment gateway. They're hard because the business rules are multi-step.</p>
<p>If you want the system to stay reliable, you should:</p>
<ul>
<li><p>model each payment stage explicitly</p>
</li>
<li><p>store coupon and referral logic separately</p>
</li>
<li><p>finalize payments inside <code>transaction.atomic()</code></p>
</li>
<li><p>lock rows with <code>select_for_update()</code></p>
</li>
<li><p>make webhook handling idempotent</p>
</li>
<li><p>unlock deliverables only when the full workflow is complete</p>
</li>
</ul>
<p>That approach keeps your Django app honest, traceable, and much easier to maintain as the product grows.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build a Production-Ready AI Agent for $0/Month Using PHP, cPanel, and Gemini Flash ]]>
                </title>
                <description>
                    <![CDATA[ In this tutorial, you’ll build a practical AI agent that can receive a user prompt, decide whether it needs to use a tool, execute that tool in PHP, store conversation history in MySQL, and continue r ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-a-production-ready-ai-agent-for-0-month-using-php-cpanel-and-gemini-flash/</link>
                <guid isPermaLink="false">6a7e0113e19ef21c9188a0c1</guid>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ PHP ]]>
                    </category>
                
                    <category>
                        <![CDATA[ cpanel ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Gemini integration ]]>
                    </category>
                
                    <category>
                        <![CDATA[ SQL ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Chidozie Managwu ]]>
                </dc:creator>
                <pubDate>Thu, 13 Aug 2026 17:38:27 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/1e6049b1-5342-44de-9daf-bcaa33a0d6c0.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>In this tutorial, you’ll build a practical AI agent that can receive a user prompt, decide whether it needs to use a tool, execute that tool in PHP, store conversation history in MySQL, and continue reasoning until it produces a final answer.</p>
<p>The goal isn't to build a flashy demo. The goal is to show how an AI agent can work on a stack that is realistic for many developers: PHP for request handling, MySQL for persistence, Gemini Flash for reasoning and function calling, and cPanel for deployment on standard shared hosting.</p>
<p>By the end of this article, you’ll understand:</p>
<ul>
<li><p>How to structure an agent loop</p>
</li>
<li><p>How tool calling works in practice</p>
</li>
<li><p>How to store and reload conversation memory</p>
</li>
<li><p>How to expose the system through a public API endpoint</p>
</li>
<li><p>How to deploy the project on standard shared hosting</p>
</li>
</ul>
<h3 id="heading-table-of-contents">Table of Contents</h3>
<ul>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-architecture-overview">Architecture Overview</a></p>
</li>
<li><p><a href="#heading-project-structure">Project Structure</a></p>
</li>
<li><p><a href="#heading-set-up-the-mysql-database">Set Up the MySQL Database</a></p>
</li>
<li><p><a href="#heading-connect-php-to-mysql">Connect PHP to MySQL</a></p>
</li>
<li><p><a href="#heading-call-gemini-flash-from-php">Call Gemini Flash from PHP</a></p>
</li>
<li><p><a href="#heading-define-the-tool-registry">Define the Tool Registry</a></p>
</li>
<li><p><a href="#heading-build-the-tools">Build the Tools</a></p>
</li>
<li><p><a href="#heading-add-mysql-conversation-memory">Add MySQL Conversation Memory</a></p>
</li>
<li><p><a href="#heading-create-the-agent-loop">Create the Agent Loop</a></p>
</li>
<li><p><a href="#heading-expose-the-public-api-endpoint">Expose the Public API Endpoint</a></p>
</li>
<li><p><a href="#heading-deploy-on-cpanel">Deploy on cPanel</a></p>
</li>
<li><p><a href="#heading-test-the-agent">Test the Agent</a></p>
</li>
<li><p><a href="#heading-production-hardening-ideas">Production Hardening Ideas</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h3 id="heading-prerequisites">Prerequisites</h3>
<p>Before you start, you should have:</p>
<ul>
<li><p>PHP 8.1 or newer</p>
</li>
<li><p>MySQL access</p>
</li>
<li><p>cURL enabled in PHP</p>
</li>
<li><p>A Gemini API key from Google AI Studio</p>
</li>
<li><p>Basic familiarity with PHP arrays, JSON, and SQL</p>
</li>
<li><p>Access to cPanel and phpMyAdmin</p>
</li>
</ul>
<p>You don't need a separate application server. The PHP files can run on ordinary shared hosting, provided your account supports PHP, cURL, MySQL, and outbound HTTPS requests.</p>
<p>In a typical cPanel setup, the project will be stored inside a folder under <code>public_html</code>.</p>
<h3 id="heading-architecture-overview">Architecture Overview</h3>
<p>The system has five main parts:</p>
<ol>
<li><p>A public endpoint receives the user request.</p>
</li>
<li><p>An agent loop sends the conversation to Gemini Flash.</p>
</li>
<li><p>A tool registry tells Gemini which functions are available.</p>
</li>
<li><p>PHP tools perform actions such as saving notes, searching the web, or sending email.</p>
</li>
<li><p>MySQL stores the conversation so the agent can continue across requests.</p>
</li>
</ol>
<p>The flow is straightforward. A user sends a message to the PHP endpoint. The endpoint passes the message and session ID to the agent. The agent loads previous messages and sends the conversation to Gemini along with the available tools. Gemini then decides what to do.</p>
<p>If the request can be answered directly, Gemini returns text. If an action is required, Gemini returns a function call containing the tool name and its arguments.</p>
<p>PHP receives the function call, runs the matching tool, and adds the result to the conversation. That result is sent back to Gemini, which can then call another tool or return a final answer.</p>
<p>This loop is what makes the application an agent rather than a basic chatbot.</p>
<h3 id="heading-project-structure">Project Structure</h3>
<p>Create a folder named <code>agent</code> inside <code>public_html</code>:</p>
<pre><code class="language-text">/public_html/agent/
├── index.php
├── agent.php
├── gemini.php
├── db.php
├── memory.php
├── tool_registry.php
├── tools/
│   ├── save_note.php
│   ├── search_web.php
│   ├── send_email.php
│   └── .htaccess
└── .htaccess
</code></pre>
<p>Each file has one main responsibility:</p>
<ul>
<li><p><code>index.php</code> receives the HTTP request and returns JSON.</p>
</li>
<li><p><code>agent.php</code> contains the reasoning loop.</p>
</li>
<li><p><code>gemini.php</code> communicates with Gemini.</p>
</li>
<li><p><code>db.php</code> creates the MySQL connection.</p>
</li>
<li><p><code>memory.php</code> loads and saves conversation history.</p>
</li>
<li><p><code>tool_registry.php</code> describes the available tools.</p>
</li>
<li><p>The <code>tools</code> folder contains the functions that perform actions.</p>
</li>
</ul>
<p>Keeping the files separate makes the project easier to maintain. You can add a new tool without changing the rest of the application.</p>
<p>Add this to <code>tools/.htaccess</code>:</p>
<pre><code class="language-apache">Deny from all
</code></pre>
<p>The tools folder shouldn't be accessible through a public URL. These files can write to the database, make external requests, or send email.</p>
<h3 id="heading-set-up-the-mysql-database">Set Up the MySQL Database</h3>
<p>The application needs one table for conversation history and another for saved notes.</p>
<p>Run this SQL in phpMyAdmin:</p>
<pre><code class="language-sql">CREATE DATABASE IF NOT EXISTS ai_agent;

USE ai_agent;

CREATE TABLE agent_memory (
    id INT AUTO_INCREMENT PRIMARY KEY,
    session_id VARCHAR(64) NOT NULL,
    role VARCHAR(20) NOT NULL,
    content TEXT NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    INDEX idx_session_id (session_id)
);

CREATE TABLE agent_notes (
    id INT AUTO_INCREMENT PRIMARY KEY,
    session_id VARCHAR(64) NOT NULL,
    note TEXT NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
</code></pre>
<p>The <code>agent_memory</code> table stores the messages that make up each conversation. The <code>session_id</code> column separates one conversation from another, while <code>role</code> identifies whether the message came from the user, model, or a function.</p>
<p>The <code>agent_notes</code> table stores information that the user deliberately asks the agent to remember. Keeping notes separate from conversation history makes them easier to retrieve and use as application data.</p>
<p>If cPanel adds an account prefix to your database name, use the complete name in <code>db.php</code>. For example, <code>ai_agent</code> may become <code>account_ai_agent</code>.</p>
<h3 id="heading-connect-php-to-mysql">Connect PHP to MySQL</h3>
<p>Create <code>db.php</code>:</p>
<pre><code class="language-php">&lt;?php

function db(): PDO
{
    static $pdo = null;

    if ($pdo === null) {
        $pdo = new PDO(
            "mysql:host=localhost;dbname=ai_agent;charset=utf8mb4",
            "db_user",
            "db_password",
            [
                PDO::ATTR_ERRMODE =&gt; PDO::ERRMODE_EXCEPTION,
                PDO::ATTR_DEFAULT_FETCH_MODE =&gt; PDO::FETCH_ASSOC,
            ]
        );
    }

    return $pdo;
}
</code></pre>
<p>The function uses PDO to connect to MySQL. The static variable ensures that the same connection is reused for subsequent requests, rather than opening a new one each time the agent accesses the database.</p>
<p><code>PDO::ATTR_ERRMODE</code> makes database failures throw exceptions. This makes errors easier to detect and handle.</p>
<p>Replace <code>ai_agent</code>, <code>db_user</code>, and <code>db_password</code> with the actual database details from cPanel. The <code>utf8mb4</code> character set allows the database to store a wide range of characters, including emoji and non-English text.</p>
<h3 id="heading-call-gemini-flash-from-php">Call Gemini Flash from PHP</h3>
<p>Gemini supports function calling. It can decide that a tool is needed and return the tool name and arguments. It doesn't execute the PHP function itself. Your application is responsible for validating the request and running the tool.</p>
<p>For example, Gemini might return:</p>
<pre><code class="language-json">{
  "note": "Our launch is on 1 September 2026"
}
</code></pre>
<p>Create <code>gemini.php</code>:</p>
<pre><code class="language-php">&lt;?php

function gemini_request(array $contents, array $tools = []): array
{
    $apiKey = "YOUR_GEMINI_API_KEY";

    $url =
        "https://generativelanguage.googleapis.com/v1beta/models/" .
        "gemini-1.5-flash:generateContent?key=" . $apiKey;

    $payload = [
        "contents" =&gt; $contents
    ];

    if (!empty($tools)) {
        $payload["tools"] = [
            [
                "functionDeclarations" =&gt; $tools
            ]
        ];
    }

    $ch = curl_init($url);

    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER =&gt; true,
        CURLOPT_POST =&gt; true,
        CURLOPT_HTTPHEADER =&gt; [
            "Content-Type: application/json"
        ],
        CURLOPT_POSTFIELDS =&gt; json_encode($payload),
        CURLOPT_TIMEOUT =&gt; 30
    ]);

    $response = curl_exec($ch);
    $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);

    if ($response === false) {
        $error = curl_error($ch);
        curl_close($ch);
        throw new RuntimeException("Gemini request failed: " . $error);
    }

    curl_close($ch);

    if ($httpCode !== 200) {
        throw new RuntimeException("Gemini API error: " . $response);
    }

    $decoded = json_decode($response, true);

    if (!is_array($decoded)) {
        throw new RuntimeException("Gemini returned invalid JSON.");
    }

    return $decoded;
}

function parse_gemini_response(array $response): array
{
    $part = $response["candidates"][0]["content"]["parts"][0] ?? [];

    if (isset($part["functionCall"])) {
        return [
            "type" =&gt; "function_call",
            "name" =&gt; $part["functionCall"]["name"],
            "args" =&gt; $part["functionCall"]["args"] ?? []
        ];
    }

    return [
        "type" =&gt; "text",
        "text" =&gt; $part["text"] ?? ""
    ];
}
</code></pre>
<p>The <code>gemini_request</code> function sends the conversation and tool definitions to Gemini. The parser converts Gemini's response into either a text response or a function call.</p>
<p>The agent loop can then make a simple decision:</p>
<ul>
<li><p>If the type is <code>function_call</code>, execute the requested tool.</p>
</li>
<li><p>If the type is <code>text</code>, return the answer to the user.</p>
</li>
</ul>
<p>For a real deployment, store the API key outside the public web directory whenever possible.</p>
<h3 id="heading-define-the-tool-registry">Define the Tool Registry</h3>
<p>The tool registry tells Gemini which tools exist and what arguments they require.</p>
<p>Create <code>tool_registry.php</code>:</p>
<pre><code class="language-php">&lt;?php

function tool_definitions(): array
{
    return [
        [
            "name" =&gt; "save_note",
            "description" =&gt; "Save an important note to the database.",
            "parameters" =&gt; [
                "type" =&gt; "object",
                "properties" =&gt; [
                    "note" =&gt; [
                        "type" =&gt; "string"
                    ]
                ],
                "required" =&gt; ["note"]
            ]
        ],
        [
            "name" =&gt; "search_web",
            "description" =&gt; "Search the web for current information.",
            "parameters" =&gt; [
                "type" =&gt; "object",
                "properties" =&gt; [
                    "query" =&gt; [
                        "type" =&gt; "string"
                    ]
                ],
                "required" =&gt; ["query"]
            ]
        ],
        [
            "name" =&gt; "send_email",
            "description" =&gt; "Send an email when the user explicitly asks.",
            "parameters" =&gt; [
                "type" =&gt; "object",
                "properties" =&gt; [
                    "to" =&gt; [
                        "type" =&gt; "string"
                    ],
                    "subject" =&gt; [
                        "type" =&gt; "string"
                    ],
                    "body" =&gt; [
                        "type" =&gt; "string"
                    ]
                ],
                "required" =&gt; ["to", "subject", "body"]
            ]
        ]
    ];
}
</code></pre>
<p>The description helps Gemini decide when to use a tool. The parameters describe the values that Gemini should provide.</p>
<p>The registry doesn't replace server-side validation. Every PHP tool must still check its own arguments before performing an action.</p>
<h3 id="heading-build-the-tools">Build the Tools</h3>
<p>Each tool should:</p>
<ol>
<li><p>Read the arguments from Gemini.</p>
</li>
<li><p>Validate the input.</p>
</li>
<li><p>Perform the action.</p>
</li>
<li><p>Return a JSON result.</p>
</li>
</ol>
<h4 id="heading-save-note-tool">Save Note Tool</h4>
<p>Create <code>tools/save_note.php</code>:</p>
<pre><code class="language-php">&lt;?php

require_once __DIR__ . "/../db.php";

function save_note_tool(array $args, string $sessionId): string
{
    $note = trim($args["note"] ?? "");

    if ($note === "") {
        return json_encode([
            "success" =&gt; false,
            "message" =&gt; "Empty note"
        ]);
    }

    $stmt = db()-&gt;prepare(
        "INSERT INTO agent_notes (session_id, note)
         VALUES (:session_id, :note)"
    );

    $stmt-&gt;execute([
        ":session_id" =&gt; $sessionId,
        ":note" =&gt; $note
    ]);

    return json_encode([
        "success" =&gt; true,
        "message" =&gt; "Note saved"
    ]);
}
</code></pre>
<p>The note is trimmed and checked before it is saved. The prepared statement prevents SQL injection and safely handles the input.</p>
<h4 id="heading-search-web-tool">Search Web Tool</h4>
<p>Create <code>tools/search_web.php</code>:</p>
<pre><code class="language-php">&lt;?php

function search_web_tool(array $args): string
{
    $query = trim($args["query"] ?? "");

    if ($query === "") {
        return json_encode([
            "success" =&gt; false,
            "message" =&gt; "Search query is empty"
        ]);
    }

    $url = "https://api.example.com/search?q=" . urlencode($query);

    $ch = curl_init($url);

    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER =&gt; true,
        CURLOPT_TIMEOUT =&gt; 15
    ]);

    $response = curl_exec($ch);

    if ($response === false) {
        $error = curl_error($ch);
        curl_close($ch);

        return json_encode([
            "success" =&gt; false,
            "message" =&gt; "Search failed",
            "error" =&gt; $error
        ]);
    }

    curl_close($ch);

    return $response;
}
</code></pre>
<p>The URL is a placeholder. Replace it with your chosen search provider and add any required API key or authentication header.</p>
<p>The timeout prevents a slow external service from keeping the PHP request open indefinitely.</p>
<h4 id="heading-send-email-tool">Send Email Tool</h4>
<p>Create <code>tools/send_email.php</code>:</p>
<pre><code class="language-php">&lt;?php

function send_email_tool(array $args): string
{
    $to = filter_var(
        $args["to"] ?? "",
        FILTER_VALIDATE_EMAIL
    );

    $subject = trim($args["subject"] ?? "");
    $body = trim($args["body"] ?? "");

    if (!$to) {
        return json_encode([
            "success" =&gt; false,
            "message" =&gt; "Invalid email address"
        ]);
    }

    if ($subject === "" || $body === "") {
        return json_encode([
            "success" =&gt; false,
            "message" =&gt; "Email subject and body are required"
        ]);
    }

    $headers = "From: agent@yourdomain.com\r\n";
    $headers .= "Content-Type: text/plain; charset=UTF-8\r\n";

    $sent = mail($to, $subject, $body, $headers);

    return json_encode([
        "success" =&gt; $sent,
        "message" =&gt; $sent ? "Email sent" : "Email failed"
    ]);
}
</code></pre>
<p>The recipient address, subject, and body are checked before sending. Replace the <code>From</code> address with one belonging to your domain.</p>
<p>The <code>mail()</code> function may be available on shared hosting, but an authenticated email service or SMTP provider is usually more reliable for production applications.</p>
<h3 id="heading-add-mysql-conversation-memory">Add MySQL Conversation Memory</h3>
<p>Gemini doesn't automatically remember previous API requests. The application must load the conversation from MySQL before each request and save the updated history afterwards.</p>
<p>Create <code>memory.php</code>:</p>
<pre><code class="language-php">&lt;?php

require_once __DIR__ . "/db.php";

function load_memory(string $sessionId): array
{
    $stmt = db()-&gt;prepare(
        "SELECT role, content
         FROM agent_memory
         WHERE session_id = :session_id
         ORDER BY id ASC"
    );

    $stmt-&gt;execute([
        ":session_id" =&gt; $sessionId
    ]);

    $history = [];

    foreach ($stmt-&gt;fetchAll() as $row) {
        $history[] = [
            "role" =&gt; $row["role"],
            "parts" =&gt; [
                [
                    "text" =&gt; $row["content"]
                ]
            ]
        ];
    }

    return $history;
}

function save_memory(string $sessionId, array $history): void
{
    $pdo = db();

    $delete = $pdo-&gt;prepare(
        "DELETE FROM agent_memory
         WHERE session_id = :session_id"
    );

    $delete-&gt;execute([
        ":session_id" =&gt; $sessionId
    ]);

    $insert = $pdo-&gt;prepare(
        "INSERT INTO agent_memory
         (session_id, role, content)
         VALUES (:session_id, :role, :content)"
    );

    foreach ($history as $turn) {
        $part = $turn["parts"][0] ?? [];

        $text = isset($part["text"])
            ? $part["text"]
            : json_encode($part);

        $insert-&gt;execute([
            ":session_id" =&gt; $sessionId,
            ":role" =&gt; $turn["role"],
            ":content" =&gt; $text
        ]);
    }
}
</code></pre>
<p><code>load_memory</code> Retrieves messages for the current session and rebuilds them in the format expected by Gemini.</p>
<p><code>save_memory</code> replaces the stored history with the current history. This is simple and suitable for a small tutorial application. A larger system could append only new messages, use a JSON column, or summarise older conversations to reduce database and API usage.</p>
<h3 id="heading-create-the-agent-loop">Create the Agent Loop</h3>
<p>The agent loop connects the database, Gemini, memory, and tools.</p>
<p>Create <code>agent.php</code>:</p>
<pre><code class="language-php">&lt;?php

require_once __DIR__ . "/gemini.php";
require_once __DIR__ . "/memory.php";
require_once __DIR__ . "/tool_registry.php";
require_once __DIR__ . "/tools/save_note.php";
require_once __DIR__ . "/tools/search_web.php";
require_once __DIR__ . "/tools/send_email.php";

function run_tool(
    string $name,
    array $args,
    string $sessionId
): string {
    return match ($name) {
        "save_note" =&gt; save_note_tool($args, $sessionId),
        "search_web" =&gt; search_web_tool($args),
        "send_email" =&gt; send_email_tool($args),
        default =&gt; json_encode([
            "success" =&gt; false,
            "message" =&gt; "Unknown tool"
        ])
    };
}

function run_agent(
    string $message,
    string $sessionId
): string {
    $history = load_memory($sessionId);

    $history[] = [
        "role" =&gt; "user",
        "parts" =&gt; [
            [
                "text" =&gt; $message
            ]
        ]
    ];

    $tools = tool_definitions();
    $limit = 5;
    $step = 0;

    while ($step &lt; $limit) {
        $step++;

        $response = gemini_request($history, $tools);
        $parsed = parse_gemini_response($response);

        if ($parsed["type"] === "text") {
            $history[] = [
                "role" =&gt; "model",
                "parts" =&gt; [
                    [
                        "text" =&gt; $parsed["text"]
                    ]
                ]
            ];

            save_memory($sessionId, $history);

            return $parsed["text"];
        }

        if ($parsed["type"] === "function_call") {
            $toolName = $parsed["name"];
            $toolArgs = $parsed["args"];
            $result = run_tool($toolName, $toolArgs, $sessionId);

            $history[] = [
                "role" =&gt; "model",
                "parts" =&gt; [
                    [
                        "functionCall" =&gt; [
                            "name" =&gt; $toolName,
                            "args" =&gt; $toolArgs
                        ]
                    ]
                ]
            ];

            $history[] = [
                "role" =&gt; "function",
                "parts" =&gt; [
                    [
                        "functionResponse" =&gt; [
                            "name" =&gt; $toolName,
                            "response" =&gt; [
                                "content" =&gt; $result
                            ]
                        ]
                    ]
                ]
            ];
        }
    }

    save_memory($sessionId, $history);

    return "I could not complete the task within the allowed number of steps.";
}
</code></pre>
<p>The <code>run_tool</code> function routes the requested tool to the correct PHP function. The default case handles unexpected tool names safely.</p>
<p>The <code>run_agent</code> function first loads the existing history and adds the new user message. It then sends the conversation to Gemini.</p>
<p>If Gemini returns text, the response is saved and returned to the user.</p>
<p>If Gemini returns a function call, PHP executes the tool. The function call and its result are both added to the history before the next loop iteration.</p>
<p>The five-step limit prevents the model from repeatedly calling tools without finishing. You can adjust the limit according to the needs of your application.</p>
<h3 id="heading-expose-the-public-api-endpoint">Expose the Public API Endpoint</h3>
<p>Create <code>index.php</code>:</p>
<pre><code class="language-php">&lt;?php

require_once __DIR__ . "/agent.php";

header("Content-Type: application/json");

$input = json_decode(
    file_get_contents("php://input"),
    true
);

if (!is_array($input)) {
    http_response_code(400);

    echo json_encode([
        "error" =&gt; "Invalid JSON body"
    ]);

    exit;
}

$message = trim($input["message"] ?? "");
$sessionId = trim($input["session_id"] ?? "");

if ($message === "" || $sessionId === "") {
    http_response_code(400);

    echo json_encode([
        "error" =&gt; "message and session_id are required"
    ]);

    exit;
}

try {
    $reply = run_agent($message, $sessionId);

    echo json_encode([
        "reply" =&gt; $reply,
        "session_id" =&gt; $sessionId
    ]);
} catch (Throwable $e) {
    http_response_code(500);

    echo json_encode([
        "error" =&gt; $e-&gt;getMessage()
    ]);
}
</code></pre>
<p>The endpoint expects a JSON request containing a message and session ID:</p>
<pre><code class="language-json">{
  "message": "Save a note that our launch is on 1 September 2026",
  "session_id": "demo123"
}
</code></pre>
<p>The frontend should reuse the same session ID for messages in the same conversation. A new session ID creates a separate conversation.</p>
<p>During development, returning the exception message can help with debugging. In production, log detailed errors privately and return a general error message to users.</p>
<h3 id="heading-deploy-on-cpanel">Deploy on cPanel</h3>
<p>Follow these steps:</p>
<ol>
<li><p>Upload the project to <code>/public_html/agent/</code>.</p>
</li>
<li><p>Create a database and user in cPanel.</p>
</li>
<li><p>Grant the user access to the database.</p>
</li>
<li><p>Run the SQL in phpMyAdmin.</p>
</li>
<li><p>Update the credentials in <code>db.php</code>.</p>
</li>
<li><p>Add the Gemini API key in <code>gemini.php</code>.</p>
</li>
<li><p>Select PHP 8.1 or newer.</p>
</li>
<li><p>Enable the cURL extension.</p>
</li>
<li><p>Add the <code>.htaccess</code> file to the <code>tools</code> folder.</p>
</li>
</ol>
<p>The endpoint should then be available at:</p>
<pre><code class="language-text">https://yourdomain.com/agent/index.php
</code></pre>
<h3 id="heading-test-the-agent">Test the Agent</h3>
<p>Save a note:</p>
<pre><code class="language-bash">curl -X POST https://yourdomain.com/agent/index.php \
  -H "Content-Type: application/json" \
  -d '{"message":"Save a note that our launch is on 1 September 2026","session_id":"demo123"}'
</code></pre>
<p>The agent should call <code>save_note</code>, store the note in MySQL, and return a confirmation.</p>
<p>You can test memory by sending another request with the same session ID:</p>
<pre><code class="language-bash">curl -X POST https://yourdomain.com/agent/index.php \
  -H "Content-Type: application/json" \
  -d '{"message":"What note did I save earlier?","session_id":"demo123"}'
</code></pre>
<p>The agent should load the previous conversation from <code>agent_memory</code> and use it to answer.</p>
<p>If something fails, check the database credentials, database tables, Gemini API key, PHP version, cURL extension, and server error logs. Also make sure the search tool doesn't still point to the placeholder API URL.</p>
<h3 id="heading-production-hardening-ideas">Production Hardening Ideas</h3>
<p>Before allowing real users to access the application, consider adding:</p>
<h4 id="heading-authentication">Authentication</h4>
<p>Require an API token or another authentication method. Otherwise, anyone who discovers the endpoint may be able to use your tools and Gemini account.</p>
<h4 id="heading-rate-limiting">Rate limiting</h4>
<p>Limit requests by session ID, IP address, or authenticated user to prevent abuse and unexpected usage.</p>
<h4 id="heading-tool-logging">Tool logging</h4>
<p>Store the session ID, tool name, arguments, result, and execution time. This helps you investigate unexpected behaviour.</p>
<h4 id="heading-stronger-validation">Stronger validation</h4>
<p>Validate every tool argument. Check email addresses, reject empty values, restrict string lengths, and validate database identifiers.</p>
<h4 id="heading-better-memory-management">Better memory management</h4>
<p>Long conversations can make API requests larger and less efficient. Consider keeping recent messages, summarising older messages, or storing structured tool calls separately.</p>
<h4 id="heading-confirmation-for-sensitive-actions">Confirmation for sensitive actions</h4>
<p>For actions such as sending email, ask the user for confirmation before executing the tool. Prompt instructions are helpful, but the application should also handle confirmation.</p>
<h4 id="heading-credential-protection">Credential protection</h4>
<p>Don't store API keys and database passwords in a public repository. Keep sensitive configuration outside the public web directory when possible.</p>
<h3 id="heading-conclusion">Conclusion</h3>
<p>You don't need a complex cloud stack to build a useful AI agent.</p>
<p>With PHP, MySQL, Gemini Flash, and cPanel, you can create a system that reasons, calls tools, stores memory, and runs on infrastructure that many developers already understand.</p>
<p>The architecture is built around a simple process:</p>
<ol>
<li><p>The endpoint receives the user's request.</p>
</li>
<li><p>MySQL provides the previous conversation.</p>
</li>
<li><p>Gemini decides whether a tool is needed.</p>
</li>
<li><p>PHP executes the tool.</p>
</li>
<li><p>The result is sent back to Gemini.</p>
</li>
<li><p>Gemini produces the final answer.</p>
</li>
<li><p>The updated conversation is saved.</p>
</li>
</ol>
<p>This gives you a practical foundation for building agent-based applications on standard shared hosting.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build an AI-Powered Research Automation System with n8n, Groq, and Academic APIs ]]>
                </title>
                <description>
                    <![CDATA[ As a researcher and developer, I found myself spending hours manually searching academic databases, reading abstracts, and trying to synthesize findings across multiple sources. For my work on circula ]]>
                </description>
                <link>https://www.freecodecamp.org/news/build-an-ai-powered-research-automation-system-with-n8n-groq-and-academic-apis/</link>
                <guid isPermaLink="false">69b849372ad6ae5184dbb6b8</guid>
                
                    <category>
                        <![CDATA[ n8n ]]>
                    </category>
                
                    <category>
                        <![CDATA[ freeCodeCamp.org ]]>
                    </category>
                
                    <category>
                        <![CDATA[ General Programming ]]>
                    </category>
                
                    <category>
                        <![CDATA[ automation ]]>
                    </category>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Chidozie Managwu ]]>
                </dc:creator>
                <pubDate>Mon, 16 Mar 2026 18:17:27 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/d4660bc7-3f3c-4325-bee7-57770e821204.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>As a researcher and developer, I found myself spending hours manually searching academic databases, reading abstracts, and trying to synthesize findings across multiple sources.</p>
<p>For my work on circular economy and battery recycling, I needed a way to query multiple databases at once without the manual fatigue.</p>
<p>In this tutorial, you'll build an automated research pipeline using n8n that reduces roughly six hours of manual literature review into a five-minute automated process.</p>
<p>This isn’t a “cool demo workflow.” It’s a production-minded pipeline with parallel collection, normalisation, deduplication, structured AI extraction, scoring, and practical error handling.</p>
<h3 id="heading-table-of-contents">Table of Contents</h3>
<ol>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-the-problem-research-takes-too-long">The Problem: Research Takes Too Long</a></p>
</li>
<li><p><a href="#heading-the-tech-stack">The Tech Stack</a></p>
</li>
<li><p><a href="#heading-the-project-structure-how-to-think-about-an-n8n-workflow-like-software">The Project Structure: How to Think About an n8n Workflow Like Software</a></p>
</li>
<li><p><a href="#heading-stage-1-centralized-configuration">Stage 1: Centralised Configuration</a></p>
</li>
<li><p><a href="#heading-stage-2-parallel-api-collection=with-failure-isolation">Stage 2: Parallel API Collection (With Failure Isolation)</a></p>
</li>
<li><p><a href="#heading-stage-3-normalisation-and-deduplication-doifirst-title-fallback">Stage 3: Normalisation and Deduplication (DOI-first, Title fallback)</a></p>
</li>
<li><p><a href="#heading-stage-4-aipowered-content-extraction-strict-json">Stage 4: AI-Powered Content Extraction (Strict JSON)</a></p>
</li>
<li><p><a href="#heading-stage-5-scoring-and-synthesis">Stage 5: Scoring and Synthesis</a></p>
</li>
<li><p>[Beginner-Friendly Evals (Retrieval and Extraction QA)(#heading-beginnerfriendly-evals-retrieval-and-extraction-qa)</p>
</li>
<li><p><a href="#heading-key-learnings-and-error-handling">Key Learnings and Error Handling</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ol>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>You don’t need to be a DevOps engineer to follow this, but you should have:</p>
<ul>
<li><p>Basic comfort with APIs and JSON (request/response payloads)</p>
</li>
<li><p>Familiarity with spreadsheets (Google Sheets basics)</p>
</li>
<li><p>Willingness to use a small amount of JavaScript inside n8n Function/Code nodes</p>
</li>
</ul>
<p>Access to:</p>
<ul>
<li><p>An n8n instance (self-hosted or cloud)</p>
</li>
<li><p>A Groq API key (or a compatible LLM provider)</p>
</li>
<li><p>Optional API keys, depending on the databases you use</p>
</li>
</ul>
<p>What you’ll build assumes:</p>
<ul>
<li><p>You’re extracting from metadata + abstracts (not downloading full PDFs).</p>
</li>
<li><p>You can accept that some sources will occasionally rate-limit or return partial results (and your workflow will be designed to survive this).</p>
</li>
</ul>
<h2 id="heading-the-problem-research-takes-too-long">The Problem: Research Takes Too Long</h2>
<p>Manual research is often a bottleneck for innovation. Before building this automation, my workflow involved searching multiple academic databases, scanning abstracts, and manually extracting key findings. This process was not only slow but also prone to human error and inconsistent note-taking.</p>
<p>The goal of this automation is to provide a “full-stack research assistant” that handles the heavy lifting of collecting candidate papers, removing duplicates, extracting consistent fields, scoring relevance and quality, and delivering a curated daily or weekly report, so you can spend your time on high-level synthesis rather than repetitive collection.</p>
<h2 id="heading-the-tech-stack">The Tech Stack</h2>
<p>This workflow leverages a combination of automation tooling, high-speed LLM inference, and academic metadata providers.</p>
<table>
<thead>
<tr>
<th>Tool</th>
<th>Purpose</th>
</tr>
</thead>
<tbody><tr>
<td>n8n</td>
<td>The workflow engine that orchestrates all steps</td>
</tr>
<tr>
<td>Groq</td>
<td>Runs a fast LLM (for example, Llama 3.3 70B) for structured extraction/synthesis</td>
</tr>
<tr>
<td>Semantic Scholar / OpenAlex</td>
<td>Broad academic coverage for metadata, abstracts, citations</td>
</tr>
<tr>
<td>arXiv / PubMed</td>
<td>Strong specialised coverage (preprints, life sciences)</td>
</tr>
<tr>
<td>Google Sheets</td>
<td>A lightweight “research database” for storage + history</td>
</tr>
</tbody></table>
<p>Notes: coverage varies by provider. Some APIs return abstracts reliably, while others may omit them. Your pipeline should treat missing abstracts as a normal case, not a failure.</p>
<h2 id="heading-the-project-structure-how-to-think-about-an-n8n-workflow-like-software">The Project Structure: How to Think About an n8n Workflow Like Software</h2>
<p>While n8n is a visual tool, it helps to design your workflow as modular stages to avoid the “spaghetti workflow” problem.</p>
<pre><code class="language-text">.
├── configuration/         # Keywords, thresholds, limits, date filters
├── collectors/            # Parallel HTTP request nodes (multiple sources)
├── processing/            # Normalization + deduplication code nodes
├── extraction/            # LLM extraction nodes (strict JSON)
├── scoring/               # Relevance + quality scoring + filtering
└── delivery/              # Google Sheets + email/HTML report
</code></pre>
<p>Design principle: each stage should produce a clean, predictable output shape that the next stage can rely on.</p>
<h2 id="heading-stage-1-centralised-configuration">Stage 1: Centralised Configuration</h2>
<p>Instead of hardcoding search parameters (keywords, min year, citation thresholds) across multiple nodes, use one configuration node to define workflow variables.</p>
<p>This matters for maintainability (change a value once, not in ten nodes), reusability (repurpose the entire pipeline by swapping one config object), and debuggability (log the config at the start of each run so you can reproduce results).</p>
<p>Use a Set node, or a Code node returning JSON like this:</p>
<pre><code class="language-json">{
  "keywords": "circular economy battery recycling remanufacturing",
  "min_year": 2020,
  "max_results_per_source": 10,
  "min_citations": 2,
  "relevance_threshold": 15,
  "batch_size": 10
}
</code></pre>
<p>Tip: keep numeric fields as numbers (not strings) to avoid scoring bugs later.</p>
<h2 id="heading-stage-2-parallel-api-collection-with-failure-isolation">Stage 2: Parallel API Collection (With Failure Isolation)</h2>
<p>Your workflow should query multiple sources simultaneously. In n8n, you can branch from your configuration node into multiple HTTP Request nodes, and then merge results later.</p>
<p>The production mindset here is simple: APIs fail. Rate limits happen. Providers return partial data. The key is to prevent one failing collector from crashing the whole run.</p>
<p>To implement this, on each HTTP Request node, enable <strong>Continue On Fail</strong> (or the equivalent “don’t stop workflow” behaviour). Then, in the normalisation stage, treat missing or failed outputs as empty arrays so downstream stages still run.</p>
<p>In practice, it also helps to set explicit timeouts and add a small retry policy (one to two retries) for transient failures. “Good” looks like this: if two out of five sources fail, you still produce a useful report from the remaining three, and you log which sources failed so you can investigate later.</p>
<h2 id="heading-stage-3-normalisation-and-deduplication-doi-first-title-fallback">Stage 3: Normalisation and Deduplication (DOI-first, Title fallback)</h2>
<p>Each academic API returns different field names and shapes. One might use <code>title</code>, another <code>display_name</code>, another <code>paper_title</code>. Your next stage should normalise all inputs into one schema.</p>
<h3 id="heading-target-normalised-schema">Target normalised schema</h3>
<p>Here’s a simple baseline schema (expand later as needed):</p>
<pre><code class="language-json">{
  "title": "string",
  "abstract": "string|null",
  "doi": "string|null",
  "year": 2024,
  "citations": 12,
  "url": "string|null",
  "source": "Semantic Scholar|OpenAlex|arXiv|PubMed"
}
</code></pre>
<h3 id="heading-what-deduping-by-doi-means-and-what-a-doi-is">What deduping by DOI means (and what a DOI is)</h3>
<p>A <strong>DOI</strong> (Digital Object Identifier) is a unique, persistent identifier assigned to many scholarly publications. If a paper has a DOI, that DOI functions like a stable ID: the same paper may appear in multiple databases with slightly different metadata, but the DOI should remain consistent.</p>
<p>So, <strong>deduping by DOI</strong> means: if two records share the same DOI, treat them as the same paper and keep only one.</p>
<p>When a DOI is missing (which is common for some preprints and some API responses), the fallback is to dedupe using a normalised title key, lowercased, trimmed, punctuation stripped, and whitespace collapsed. It’s not as perfect as DOI-based matching, but it’s a strong pragmatic backup.</p>
<h3 id="heading-what-normalise-into-a-unified-object-means-whats-happening-in-the-code">What “normalise into a unified object” means (what’s happening in the code)</h3>
<p>“Normalise into a unified object” simply means converting every provider’s raw response into the same predictable shape (the schema above). Once everything looks the same, downstream steps, such as deduplication, scoring, AI extraction, and storage, become straightforward because they don’t need provider-specific logic.</p>
<p>In the code below, that’s what the <code>normalized</code> object is: it maps Semantic Scholar’s fields (<code>paper.title</code>, <code>paper.externalIds.DOI</code>, <code>paper.citationCount</code>) into your standard fields (<code>title</code>, <code>doi</code>, <code>citations</code>, etc.). After that, the workflow generates a dedupe key (<code>doi:...</code> if DOI exists, otherwise <code>title:...</code>) and uses a <code>Set</code> to keep only the first occurrence.</p>
<h4 id="heading-example-n8n-code-node-normalisation-dedupe-pattern">Example n8n Code Node (Normalisation + Dedupe Pattern)</h4>
<pre><code class="language-javascript">const itemsIn = $input.all();

const seen = new Set();
const results = [];

function titleKey(t) {
  return (t || "")
    .toLowerCase()
    .replace(/[\W_]+/g, " ")
    .replace(/\s+/g, " ")
    .trim();
}

for (const item of itemsIn) {
  // Example: Semantic Scholar response shape
  const papers = item.json?.data || [];

  for (const paper of papers) {
    // "Normalize into a unified object":
    // take the provider-specific fields and map them into our standard schema.
    const normalized = {
      title: paper.title || null,
      abstract: paper.abstract || null,
      doi: paper.externalIds?.DOI || null,
      year: paper.year || null,
      citations: paper.citationCount || 0,
      url: paper.url || null,
      source: "Semantic Scholar",
    };

    if (!normalized.title) continue;

    // Dedupe key: DOI is strongest; title is fallback
    const key = normalized.doi
      ? `doi:${normalized.doi.toLowerCase()}`
      : `title:${titleKey(normalized.title)}`;

    if (seen.has(key)) continue;
    seen.add(key);

    results.push(normalized);
  }
}

return results.map(r =&gt; ({ json: r }));
</code></pre>
<p>Production-minded note: keep a field like <code>source</code> so you can debug where bad metadata is coming from later.</p>
<h2 id="heading-stage-4-ai-powered-content-extraction-strict-json">Stage 4: AI-Powered Content Extraction (Strict JSON)</h2>
<p>Once you have a deduplicated list of papers, you can send each paper (or a small batch) to Groq for structured extraction.</p>
<h3 id="heading-why-structured-output-matters">Why structured output matters</h3>
<p>If your LLM returns narrative text instead of JSON, misses fields, or emits malformed JSON, your workflow breaks downstream. In a production workflow, that’s not a rare edge case; it’s something you should expect and design around.</p>
<p>That’s why you’ll use strict schema prompting <em>and</em> validate responses downstream.</p>
<h3 id="heading-system-prompt-vs-user-prompt-and-how-to-compose-them">System prompt vs user prompt (and how to compose them)</h3>
<p>A helpful way to think about prompts in production is:</p>
<ul>
<li><p>The <strong>system prompt</strong> defines the <em>non-negotiable contract</em>: output format, allowed keys, no commentary, and what to do in uncertain cases. This is where you say “return ONLY valid JSON” and “no extra keys.”</p>
</li>
<li><p>The <strong>user prompt</strong> provides the <em>variable data</em> for this specific request: title, year, citations, abstract, and the exact schema you want filled.</p>
</li>
</ul>
<p>Composing them this way keeps your workflow stable. The system prompt stays mostly constant (your formatting contract), while the user prompt changes per paper (your payload). It also makes debugging easier: if outputs start failing, you can adjust the system constraints without rewriting every payload template.</p>
<h3 id="heading-suggested-extraction-schema">Suggested extraction schema</h3>
<p>Extract only what you can support from abstract-level data:</p>
<ul>
<li><p><code>research_question</code></p>
</li>
<li><p><code>methodology</code></p>
</li>
<li><p><code>key_findings</code></p>
</li>
<li><p><code>limitations</code></p>
</li>
<li><p><code>notes</code> (for missing abstract / ambiguity)</p>
</li>
</ul>
<h3 id="heading-example-prompt-system-user">Example prompt (system + user)</h3>
<p><strong>System:</strong></p>
<p>You are a research extraction engine. You must return ONLY valid JSON.<br>No markdown. No extra keys. No commentary.<br>If the abstract is missing or too vague, set fields to null and include a reason in "notes".</p>
<p><strong>User:</strong></p>
<p>Extract structured fields from this paper.</p>
<p>TITLE: {{title}}<br>YEAR: {{year}}<br>CITATIONS: {{citations}}<br>ABSTRACT: {{abstract}}</p>
<p>Return JSON with keys:<br>research_question (string|null)<br>methodology (string|null)<br>key_findings (array of strings)<br>limitations (array of strings)<br>notes (string)</p>
<p>Model settings: keep temperature low (around 0.2–0.3) and keep responses short and structured.</p>
<h3 id="heading-batch-processing-to-avoid-timeouts">Batch processing to avoid timeouts</h3>
<p>Instead of sending 50 papers at once, process them in batches (for example, 10). This reduces latency spikes, failure blast radius, and cost surprises. Smaller batches also make it easier to retry only the failing chunk rather than re-running everything.</p>
<h2 id="heading-stage-5-scoring-and-synthesis">Stage 5: Scoring and Synthesis</h2>
<p>Not every retrieved paper is worth your time. Without scoring, your pipeline becomes a firehose: you’ve automated collection, but you still have to manually decide what to read. Scoring is what turns “a big list of results” into a shortlist you can trust.</p>
<p>I recommend computing two signals:</p>
<ul>
<li><p><strong>Relevance</strong>: Is this actually about your research question?</p>
</li>
<li><p><strong>Quality/priority</strong>: If it’s relevant, is it worth reading first?</p>
</li>
</ul>
<p>For <strong>relevance</strong>, keep it simple and explainable. Count keyword hits in the title and abstract (and optionally in extracted <code>key_findings</code>). Title matches should be weighted higher because titles are deliberately compact summaries. Abstract hits are useful too, but cap them so long abstracts don’t dominate the score.</p>
<p>For <strong>quality/priority</strong>, use lightweight metadata you already have. Recency is a strong signal in fast-moving areas, and citations can help, but they should be treated as a weak signal (and capped) so newer high-value papers aren’t unfairly penalised.</p>
<p>A solid first scoring model is: add a title bonus, add a capped abstract bonus, add a capped citations bonus, and add a small recency bonus for papers from the last two years. Then filter using the <code>relevance_threshold</code> results from Stage 1. The advantage of this approach is that it’s easy to debug and tune: you can always explain why a paper passed or failed.</p>
<p>Once you’ve filtered down to your “gold” set, synthesis becomes safer and more useful. Write one row per accepted paper to Google Sheets, then generate a daily/weekly HTML summary (for example, top 5 papers with 1–2 key findings each) and include links so you can verify quickly.</p>
<h2 id="heading-beginner-friendly-evals-retrieval-and-extraction-qa">Beginner-Friendly Evals: Retrieval and Extraction QA</h2>
<p>AI workflows regress silently. A prompt tweak, a model update, or an API schema change can break extraction without throwing an obvious error. Adding lightweight evals is the difference between “it worked last week” and “it’s reliable.”</p>
<p>The goal here isn’t to build a full evaluation framework. It’s to add small, cheap checks that catch the most common failure modes:</p>
<ul>
<li><p>Are collectors still returning results?</p>
</li>
<li><p>Are we actually removing duplicates?</p>
</li>
<li><p>Is the LLM returning valid JSON with the keys we require?</p>
</li>
</ul>
<h3 id="heading-what-it-looks-like-in-n8n-a-concrete-example">What it looks like in n8n (a concrete example)</h3>
<p>A simple implementation is to add an <strong>“Assertions” Code node</strong> immediately after your extraction step, plus (optionally) another one after normalisation/deduplication.</p>
<p>At a high level, the workflow section looks like:</p>
<ol>
<li><p>Collectors (parallel HTTP Request nodes)</p>
</li>
<li><p>Merge results</p>
</li>
<li><p>Normalise + dedupe (Code node)</p>
</li>
<li><p>Split in Batches (optional)</p>
</li>
<li><p>LLM extraction (Groq/OpenAI-compatible node)</p>
</li>
<li><p><strong>Assertions (Code node)</strong></p>
</li>
<li><p>If node (pass/fail)</p>
</li>
<li><p>Delivery (Sheets + email)</p>
</li>
</ol>
<h3 id="heading-example-assertions-code-node-after-extraction">Example: Assertions code node after extraction</h3>
<p>This code node assumes each item is a paper with:</p>
<ul>
<li><p><code>title</code>, <code>abstract</code> in the normalised fields, and</p>
</li>
<li><p>an <code>extraction</code> field (or whatever you name it) containing the LLM response as an object or JSON string.</p>
</li>
</ul>
<p>Adapt the field name to match your actual node output, but the pattern is the same: parse, validate required keys, compute percentages, then decide whether to fail or warn.</p>
<pre><code class="language-javascript">const items = $input.all();

let total = items.length;
let withTitle = 0;
let withAbstract = 0;

let parseOk = 0;
let schemaOk = 0;

const requiredKeys = [
  "research_question",
  "methodology",
  "key_findings",
  "limitations",
  "notes",
];

const failures = [];

for (let i = 0; i &lt; items.length; i++) {
  const p = items[i].json;

  if (p.title &amp;&amp; String(p.title).trim().length &gt; 0) withTitle++;
  if (p.abstract &amp;&amp; String(p.abstract).trim().length &gt; 0) withAbstract++;

  // Adjust this depending on where you store the model output:
  const raw = p.extraction ?? p.llm ?? p.model_output;

  let obj = null;
  try {
    obj = typeof raw === "string" ? JSON.parse(raw) : raw;
    parseOk++;
  } catch (e) {
    failures.push({ index: i, title: p.title || null, reason: "JSON parse failed" });
    continue;
  }

  const hasAllKeys = requiredKeys.every(k =&gt; Object.prototype.hasOwnProperty.call(obj, k));
  if (!hasAllKeys) {
    failures.push({ index: i, title: p.title || null, reason: "Missing required keys" });
    continue;
  }

  // Optional: ensure arrays are arrays
  const arraysOk = Array.isArray(obj.key_findings) &amp;&amp; Array.isArray(obj.limitations);
  if (!arraysOk) {
    failures.push({ index: i, title: p.title || null, reason: "key_findings/limitations not arrays" });
    continue;
  }

  schemaOk++;
}

const pct = (n) =&gt; (total === 0 ? 0 : Math.round((n / total) * 100));

const report = {
  total_papers: total,
  pct_with_title: pct(withTitle),
  pct_with_abstract: pct(withAbstract),
  pct_extraction_json_parse_ok: pct(parseOk),
  pct_extraction_schema_ok: pct(schemaOk),
  failures_sample: failures.slice(0, 5),
};

// Decide pass/fail thresholds
const HARD_FAIL_PARSE_BELOW = 90;
const HARD_FAIL_SCHEMA_BELOW = 85;

const shouldFail =
  report.pct_extraction_json_parse_ok &lt; HARD_FAIL_PARSE_BELOW ||
  report.pct_extraction_schema_ok &lt; HARD_FAIL_SCHEMA_BELOW;

return [
  {
    json: {
      eval_report: report,
      shouldFail,
    },
  },
];
</code></pre>
<p>Then add an <strong>If node</strong>:</p>
<ul>
<li><p>If <code>shouldFail</code> is true, then route to an “Alert/Stop” branch (Slack/email/log) and optionally stop the workflow.</p>
</li>
<li><p>If false, then continue to the delivery stage.</p>
</li>
</ul>
<p>This is the automation equivalent of unit tests: small, cheap, and extremely effective. It also gives you a concrete paper trail when something changes upstream.</p>
<h2 id="heading-key-learnings-and-error-handling">Key Learnings and Error Handling</h2>
<p>Building this automation taught me that the best workflows are designed for failure.</p>
<p>First, error resilience is not optional. Never let one failing API crash the workflow. Use “Continue On Fail” on your HTTP nodes, merge partial results, and log which sources failed in your final report so you can debug without losing an entire run.</p>
<p>Second, batching is your friend. Process papers in batches (often 5–15) to reduce timeouts and cost spikes. Keep LLM payloads small and focused on what you actually need (metadata + abstract), and retry transient failures once rather than repeatedly hammering the model or API.</p>
<p>Third, structured prompting is what makes AI reliable in automation. A strict JSON schema is the difference between a workflow that runs unattended and one that breaks randomly. Keep temperature low, enforce the schema in the system prompt, and validate everything downstream with simple parse-and-assert checks.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>A good research pipeline doesn’t just retrieve papers – it turns scattered results into a consistent, deduplicated, scored, and review-ready shortlist you can trust.</p>
<p>By treating your n8n workflow like software modular stages, strict contracts between steps, and lightweight eval checks, you can reduce hours of manual literature review into a fast, repeatable process that survives real-world API failures and model quirks.</p>
<p>If you build this with good defaults (failure isolation, batching, normalisation, strict JSON extraction, and simple scoring), you end up with something you can run daily or weekly and actually rely on without the manual fatigue.</p>
<h3 id="heading-about-me">About Me</h3>
<p>I am Chidozie Managwu, an award-winning AI Product Architect and founder focused on helping global tech talent build real, production-ready skills. I contribute to global AI initiatives as a GAFAI Delegate and lead the AI Titans Network, a community for developers learning how to ship AI products.</p>
<p>My work has been recognised with the Global Tech Hero award and featured on platforms like HackerNoon.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Ship a Production-Ready RAG App with FAISS (Guardrails, Evals, and Fallbacks) ]]>
                </title>
                <description>
                    <![CDATA[ Most LLM applications look great in a high-fidelity demo. Then they hit the hands of real users and start failing in very predictable yet damaging ways. They answer questions they should not, they bre ]]>
                </description>
                <link>https://www.freecodecamp.org/news/build-rag-app-faiss-fastapi/</link>
                <guid isPermaLink="false">69b841572ad6ae5184d54317</guid>
                
                    <category>
                        <![CDATA[ Python ]]>
                    </category>
                
                    <category>
                        <![CDATA[ FastAPI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ llm ]]>
                    </category>
                
                    <category>
                        <![CDATA[ RAG  ]]>
                    </category>
                
                    <category>
                        <![CDATA[ vector database ]]>
                    </category>
                
                    <category>
                        <![CDATA[ faiss ]]>
                    </category>
                
                    <category>
                        <![CDATA[ api ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Machine Learning ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Devops ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Chidozie Managwu ]]>
                </dc:creator>
                <pubDate>Mon, 16 Mar 2026 17:43:51 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/f9da3ad9-e285-4ce1-acb7-ad119579971c.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Most LLM applications look great in a high-fidelity demo. Then they hit the hands of real users and start failing in very predictable yet damaging ways.</p>
<p>They answer questions they should not, they break when document retrieval is weak, they time out due to network latency, and nobody can tell exactly what happened because there are no logs and no tests.</p>
<p>In this tutorial, you’ll build a beginner-friendly Retrieval Augmented Generation (RAG) application designed to survive production realities. This isn’t just a script that calls an API. It’s a system featuring a FastAPI backend, a persisted FAISS vector store, and essential safety guardrails (including a retrieval gate and fallbacks).</p>
<h3 id="heading-table-of-contents">Table of Contents</h3>
<ol>
<li><p><a href="#heading-why-rag-alone-does-not-equal-productionready">Why RAG Alone Does Not Equal Production-Ready</a></p>
</li>
<li><p><a href="#heading-the-architecture-you-are-building">The Architecture You Are Building</a></p>
</li>
<li><p><a href="#heading-project-setup-and-structure">Project Setup and Structure</a></p>
</li>
<li><p><a href="#heading-how-to-build-the-rag-layer-with-faiss">How to Build the RAG Layer with FAISS</a></p>
</li>
<li><p><a href="#heading-how-to-add-the-llm-call-with-structured-output">How to Add the LLM Call with Structured Output</a></p>
</li>
<li><p><a href="#heading-how-to-add-guardrails-retrieval-gate-and-fallbacks">How to Add Guardrails: Retrieval Gate and Fallbacks</a></p>
</li>
<li><p><a href="#heading-fast-api-app-creating-the-answer-endpoint">FastAPI App: Creating the /answer Endpoint</a></p>
</li>
<li><p><a href="#heading-how-to-add-beginnerfriendly-evals">How to Add Beginner-Friendly Evals</a></p>
</li>
<li><p><a href="#heading-what-to-improve-next-realistic-upgrades">What to Improve Next: Realistic Upgrades</a></p>
</li>
</ol>
<h2 id="heading-why-rag-alone-does-not-equal-production-ready">Why RAG Alone Does Not Equal Production-Ready</h2>
<p>Retrieval Augmented Generation (RAG) is often hailed as the hallucination killer. By grounding the model in retrieved text, we provide it with the facts it needs to be accurate. But simply connecting a vector database to an LLM isn’t enough for a production environment.</p>
<p>Production issues usually arise from the silent failures in the system surrounding the model:</p>
<ul>
<li><p><strong>Weak retrieval:</strong> If the app retrieves irrelevant chunks of text, the model tries to bridge the gap by inventing an answer anyway. Without a designated “I do not know” path, the model is essentially forced to hallucinate.</p>
</li>
<li><p><strong>Lack of visibility:</strong> Without structured outputs and basic logging, you can’t tell if bad retrieval, a confusing prompt, or a model update caused a wrong answer.</p>
</li>
<li><p><strong>Fragility:</strong> A simple API timeout or malformed provider response becomes a user-facing outage if you don’t implement fallbacks.</p>
</li>
<li><p><strong>No regression testing:</strong> In traditional software, we have unit tests. In AI, we need evals. Without them, a small tweak to your prompt might fix one issue but break ten others without you realising it.</p>
</li>
</ul>
<p>We’ll solve each of these issues systematically in this guide.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>This tutorial is beginner-friendly, but it assumes you have a few basics in place so you can focus on building a robust RAG system instead of getting stuck on setup issues.</p>
<h3 id="heading-knowledge">Knowledge</h3>
<p>You should be comfortable with:</p>
<ul>
<li><p><strong>Python fundamentals</strong> (functions, modules, virtual environments)</p>
</li>
<li><p><strong>Basic HTTP + JSON</strong> (requests, response payloads)</p>
</li>
<li><p><strong>APIs with FastAPI</strong> (what an endpoint is and how to run a server)</p>
</li>
<li><p><strong>High-level LLM concepts</strong> (prompting, temperature, structured outputs)</p>
</li>
</ul>
<h3 id="heading-tools-accounts">Tools + Accounts</h3>
<p>You’ll need:</p>
<ul>
<li><p><strong>Python 3.10+</strong></p>
</li>
<li><p>A working <strong>OpenAI-compatible API key</strong> (OpenAI or any provider that supports the same request/response shape)</p>
</li>
<li><p>A local environment where you can run a FastAPI app (Mac/Linux/Windows)</p>
</li>
</ul>
<h3 id="heading-what-this-tutorial-covers-and-what-it-doesnt">What This Tutorial Covers (and What It Doesn’t)</h3>
<p>We’ll build a production-minded baseline:</p>
<ul>
<li><p>A <strong>FAISS-backed retriever</strong> with a persisted index + metadata</p>
</li>
<li><p>A <strong>retrieval gate</strong> to prevent “forced hallucination”</p>
</li>
<li><p><strong>Structured JSON outputs</strong> so your backend is stable</p>
</li>
<li><p><strong>Fallback behavior</strong> for timeouts and provider errors</p>
</li>
<li><p>A small <strong>eval harness</strong> to prevent regressions</p>
</li>
</ul>
<p>We won’t implement advanced upgrades such as rerankers, semantic chunking, auth, background jobs beyond a roadmap at the end.</p>
<h2 id="heading-the-architecture-you-are-building">The Architecture You Are Building</h2>
<p>The flow of our application follows a disciplined path so every answer is grounded in evidence:</p>
<ol>
<li><p><strong>User query:</strong> The user submits a question via a FastAPI endpoint.</p>
</li>
<li><p><strong>Retrieval:</strong> The system embeds the question and retrieves the top-k most similar document chunks.</p>
</li>
<li><p><strong>The retrieval gate:</strong> We evaluate the similarity score. If the context is not relevant enough, we stop immediately and refuse the query.</p>
</li>
<li><p><strong>Augmentation and generation:</strong> If the gate passes, we send a context-augmented prompt to the LLM.</p>
</li>
<li><p><strong>Structured response:</strong> The model returns a JSON object containing the answer, sources used, and a confidence level.</p>
</li>
</ol>
<h2 id="heading-project-setup-and-structure">Project Setup and Structure</h2>
<p>To keep things organized and maintainable, we’ll use a modular structure. This allows you to swap out your LLM provider or your vector database without rewriting your entire core application.</p>
<h3 id="heading-project-structure">Project Structure</h3>
<pre><code class="language-python">.
├── app.py              # FastAPI entry point and API logic
├── rag.py              # FAISS index, persistence, and document retrieval
├── llm.py              # LLM API interface and JSON parsing
├── prompts.py          # Centralized prompt templates
├── data/               # Source .txt documents
├── index/              # Persisted FAISS index and metadata
└── evals/              # Evaluation dataset and runner script
    ├── eval_set.json
    └── run_evals.py
</code></pre>
<h3 id="heading-install-dependencies">Install Dependencies</h3>
<p>First, create a virtual environment to isolate your project:</p>
<pre><code class="language-python">python -m venv .venv
source .venv/bin/activate  # On Windows: .venv\Scripts\activate
pip install fastapi uvicorn faiss-cpu numpy pydantic requests python-dotenv
</code></pre>
<h3 id="heading-configure-the-environment">Configure the Environment</h3>
<p>Create a <code>.env</code> file in the root directory. We are targeting OpenAI-compatible providers:</p>
<pre><code class="language-python">OPENAI_API_KEY=your_actual_api_key_here
OPENAI_BASE_URL=https://api.openai.com/v1
OPENAI_MODEL=gpt-4o-mini
</code></pre>
<p>Important note on compatibility: The code below assumes an OpenAI-style API. If you use a provider that is not compatible, you must change the URL, headers (for example <code>X-API-Key</code>), and the way you extract embeddings and final message content in <code>embed_texts()</code> and <code>call_llm()</code>.</p>
<h2 id="heading-how-to-build-the-rag-layer-with-faiss">How to Build the RAG Layer with FAISS</h2>
<p>In <code>rag.py</code>, we handle the “Retriever” part of RAG. This involves turning raw text into mathematical vectors that the computer can compare.</p>
<h3 id="heading-what-is-faiss-and-what-does-it-do">What is FAISS (and What Does It Do)?</h3>
<p><strong>FAISS</strong> (Facebook AI Similarity Search) is a fast library for vector similarity search. In a RAG system, each chunk of text becomes an embedding vector (a list of floats). FAISS stores those vectors in an index so you can quickly ask:</p>
<blockquote>
<p>“Given this question embedding, which document chunks are closest to it?”</p>
</blockquote>
<p>In this tutorial, we use <code>IndexFlatIP</code> inner product and normalise vectors with <code>faiss.normalize_L2(...)</code>. With normalised vectors, the inner product behaves like <strong>cosine similarity</strong>, giving us a stable score we can use for a retrieval gate.</p>
<h3 id="heading-chunking-strategy-with-overlap">Chunking Strategy With Overlap</h3>
<p>We’ll use chunking with overlap. If we split a document at exactly 1,000 characters, we might cut a sentence in half, losing its meaning. By using an overlap, for example, 200 characters, we ensure that the end of one chunk and the beginning of the next share context.</p>
<h3 id="heading-implementation-of-ragpy">Implementation of <code>rag.py</code></h3>
<pre><code class="language-python">import os
import faiss
import numpy as np
import requests
import json
from typing import List, Dict
from dotenv import load_dotenv

load_dotenv()

INDEX_PATH = "index/faiss.index"
META_PATH = "index/meta.json"

def chunk_text(text: str, size: int = 1000, overlap: int = 200) -&gt; List[str]:
    chunks = []
    step = max(1, size - overlap)
    for i in range(0, len(text), step):
        chunk = text[i : i + size].strip()
        if chunk:
            chunks.append(chunk)
    return chunks

def embed_texts(texts: List[str]) -&gt; np.ndarray:
    # Note: If your provider is not OpenAI-compatible, change this URL and headers
    url = f"{os.getenv('OPENAI_BASE_URL')}/embeddings"
    headers = {"Authorization": f"Bearer {os.getenv('OPENAI_API_KEY')}"}
    payload = {"input": texts, "model": "text-embedding-3-small"}

    resp = requests.post(url, headers=headers, json=payload, timeout=30)
    resp.raise_for_status()
    # If your provider uses a different response format, change the line below
    vectors = np.array([item["embedding"] for item in resp.json()["data"]], dtype="float32")
    return vectors

def build_index() -&gt; None:
    all_chunks: List[str] = []
    metadata: List[Dict] = []

    if not os.path.exists("data"):
        os.makedirs("data")
        return

    for file in os.listdir("data"):
        if not file.endswith(".txt"):
            continue

        with open(f"data/{file}", "r", encoding="utf-8") as f:
            text = f.read()

        chunks = chunk_text(text)
        all_chunks.extend(chunks)
        for c in chunks:
            metadata.append({"source": file, "text": c})

    if not all_chunks:
        return

    embeddings = embed_texts(all_chunks)
    faiss.normalize_L2(embeddings)

    dim = embeddings.shape[1]
    index = faiss.IndexFlatIP(dim)
    index.add(embeddings)

    os.makedirs("index", exist_ok=True)
    faiss.write_index(index, INDEX_PATH)

    with open(META_PATH, "w", encoding="utf-8") as f:
        json.dump(metadata, f, ensure_ascii=False)

def load_index():
    if not (os.path.exists(INDEX_PATH) and os.path.exists(META_PATH)):
        raise FileNotFoundError(
            "FAISS index not found. Add .txt files to data/ and run build_index()."
        )

    index = faiss.read_index(INDEX_PATH)
    with open(META_PATH, "r", encoding="utf-8") as f:
        metadata = json.load(f)
    return index, metadata

def retrieve(query: str, k: int = 5) -&gt; List[Dict]:
    index, metadata = load_index()

    q_emb = embed_texts([query])
    faiss.normalize_L2(q_emb)

    scores, ids = index.search(q_emb, k)
    results = []
    for score, idx in zip(scores[0], ids[0]):
        if idx == -1:
            continue
        m = metadata[idx]
        results.append(
            {"score": float(score), "source": m["source"], "text": m["text"], "id": int(idx)}
        )
    return results
</code></pre>
<h2 id="heading-how-to-add-the-llm-call-with-structured-output">How to Add the LLM Call with Structured Output</h2>
<p>A major failure point in AI apps is the “chatty” nature of LLMs. If your backend expects a list of sources but the LLM returns conversational filler, your code will crash.</p>
<p>We solve this with <strong>structured output</strong>: instruct the model to return a strict JSON object, then parse it safely.</p>
<h3 id="heading-implementation-of-llmpy">Implementation of <code>llm.py</code></h3>
<pre><code class="language-python">import json
import requests
import os
from typing import Dict, Any

def call_llm(system_prompt: str, user_prompt: str) -&gt; Dict[str, Any]:
    # Note: Change URL/Headers if using a non-OpenAI compatible provider
    url = f"{os.getenv('OPENAI_BASE_URL')}/chat/completions"
    headers = {
        "Authorization": f"Bearer {os.getenv('OPENAI_API_KEY')}",
        "Content-Type": "application/json",
    }

    payload = {
        "model": os.getenv("OPENAI_MODEL"),
        "messages": [
            {"role": "system", "content": system_prompt},
            {"role": "user", "content": user_prompt},
        ],
        "response_format": {"type": "json_object"},
        "temperature": 0,
    }

    try:
        resp = requests.post(url, headers=headers, json=payload, timeout=30)
        resp.raise_for_status()
        content = resp.json()["choices"][0]["message"]["content"]

        parsed = json.loads(content)
        parsed.setdefault("answer", "")
        parsed.setdefault("refusal", False)
        parsed.setdefault("confidence", "medium")
        parsed.setdefault("sources", [])
        return parsed

    except (requests.Timeout, requests.ConnectionError):
        return {
            "answer": "The system is temporarily unavailable (network issue). Please try again.",
            "refusal": True,
            "confidence": "low",
            "sources": [],
            "error_type": "network_error",
        }
    except Exception:
        return {
            "answer": "A system error occurred while generating the answer.",
            "refusal": True,
            "confidence": "low",
            "sources": [],
            "error_type": "unknown_error",
        }
</code></pre>
<h2 id="heading-how-to-add-guardrails-retrieval-gate-and-fallbacks">How to Add Guardrails: Retrieval Gate and Fallbacks</h2>
<p>Guardrails are interceptors. They sit between the user and the model to prevent predictable failures.</p>
<h3 id="heading-the-retrieval-gate-how-it-works-and-how-to-add-it">The Retrieval Gate: How It Works and How to Add It</h3>
<p>In a standard RAG pipeline, the system always calls the LLM. If the user asks an irrelevant question, the retriever will still return the “closest” (but wrong) chunks.</p>
<p>The solution is the retrieval gate:</p>
<ol>
<li><p>Retrieve top-k chunks and get the <strong>top similarity score</strong></p>
</li>
<li><p>If the score is below a threshold (for example <code>0.30</code>), refuse immediately</p>
</li>
<li><p>Only call the LLM when retrieval is strong enough to ground the answer</p>
</li>
</ol>
<p>A threshold of <code>0.30</code> is a reasonable starting point when using normalised cosine similarity, but you should tune it using evals (next section).</p>
<h3 id="heading-fallbacks-and-why-they-matter">Fallbacks and Why They Matter</h3>
<p>Fallbacks ensure that if an API fails or times out, the user gets a helpful message instead of a crash. They also keep your API response shape consistent, which prevents frontend errors and makes logging meaningful.</p>
<p>In this tutorial, fallbacks are implemented inside <code>call_llm()</code> so your FastAPI layer stays simple.</p>
<h2 id="heading-fastapi-app-creating-the-answer-endpoint">FastAPI App: Creating the /answer Endpoint</h2>
<p>The <code>app.py</code> file is the conductor. It ties retrieval, guardrails, prompting, and generation together.</p>
<h3 id="heading-implementation-of-apppy">Implementation of <code>app.py</code></h3>
<pre><code class="language-python">from fastapi import FastAPI
from pydantic import BaseModel
from rag import retrieve
from llm import call_llm
import prompts
import time
import logging

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("rag_app")

app = FastAPI(title="Production-Ready RAG")

class QueryRequest(BaseModel):
    question: str

@app.post("/answer")
async def get_answer(req: QueryRequest):
    start_time = time.time()
    question = (req.question or "").strip()

    if not question:
        return {
            "answer": "Please provide a non-empty question.",
            "refusal": True,
            "confidence": "low",
            "sources": [],
            "latency_sec": round(time.time() - start_time, 2),
        }

    # 1) Retrieval
    results = retrieve(question, k=5)
    top_score = results[0]["score"] if results else 0.0

    logger.info("query=%r top_score=%.3f num_results=%d", question, top_score, len(results))

    # 2) Retrieval Gate (Guardrail)
    if top_score &lt; 0.30:
        return {
            "answer": "I do not have documents to answer that question.",
            "refusal": True,
            "confidence": "low",
            "sources": [],
            "latency_sec": round(time.time() - start_time, 2),
            "retrieval": {"top_score": top_score, "k": 5},
        }

    # 3) Augment
    context_text = "\n\n".join([f"Source {r['source']}: {r['text']}" for r in results])
    user_prompt = f"Context:\n{context_text}\n\nQuestion: {question}"

    # 4) Generation with Fallback
    response = call_llm(prompts.SYSTEM_PROMPT, user_prompt)

    # 5) Attach debug metadata
    response["latency_sec"] = round(time.time() - start_time, 2)
    response["retrieval"] = {"top_score": top_score, "k": 5}
    return response
</code></pre>
<h2 id="heading-centralized-prompt-template-promptspy">Centralized Prompt – Template: prompts.py</h2>
<p>A small but important habit: keep prompts centralised so they’re versionable and easy to evaluate.</p>
<h3 id="heading-example-promptspy">Example <code>prompts.py</code></h3>
<pre><code class="language-python">SYSTEM_PROMPT = """You are a RAG assistant. Use ONLY the provided Context to answer.
If the context does not contain the answer, respond with refusal=true.

Return a valid JSON object with exactly these keys:
- answer: string
- refusal: boolean
- confidence: "low" | "medium" | "high"
- sources: array of strings (source filenames you used)

Do not include any extra keys. Do not include markdown. Do not include commentary."""
</code></pre>
<h2 id="heading-how-to-add-beginner-friendly-evals">How to Add Beginner-Friendly Evals</h2>
<p>In AI systems, outputs are probabilistic. This makes testing harder than traditional software. Evals (evaluations) are a set of “golden questions” and “expected behaviours” you run repeatedly to detect regressions.</p>
<p>Instead of “does it output exactly this string,” you test:</p>
<ul>
<li><p>Should the app <strong>refuse</strong> when the retrieval is weak?</p>
</li>
<li><p>When it answers, does it include <strong>sources</strong>?</p>
</li>
<li><p>Is the behaviour stable across prompt tweaks and model changes?</p>
</li>
</ul>
<h3 id="heading-step-1-create-evalsevalsetjson">Step 1: Create <code>evals/eval_set.json</code></h3>
<p>This should contain both positive and negative cases.</p>
<pre><code class="language-json">[
  {
    "id": "in_scope_01",
    "question": "What is a retrieval gate and why is it important?",
    "expect_refusal": false,
    "notes": "Should explain gating and relate it to hallucination prevention."
  },
  {
    "id": "out_of_scope_01",
    "question": "What is the capital of France?",
    "expect_refusal": true,
    "notes": "If the knowledge base only includes our docs, the app should refuse."
  },
  {
    "id": "edge_01",
    "question": "",
    "expect_refusal": true,
    "notes": "Empty input should not call the LLM."
  }
]
</code></pre>
<h3 id="heading-step-2-create-evalsrunevalspy">Step 2: Create <code>evals/run_evals.py</code></h3>
<p>This runner calls your API endpoint (end-to-end) and checks expected behaviours.</p>
<pre><code class="language-python">import json
import requests

API_URL = "http://127.0.0.1:8000/answer"

def run():
    with open("evals/eval_set.json", "r", encoding="utf-8") as f:
        cases = json.load(f)

    passed = 0
    failed = 0

    for case in cases:
        resp = requests.post(API_URL, json={"question": case["question"]}, timeout=60)
        resp.raise_for_status()
        out = resp.json()

        got_refusal = bool(out.get("refusal", False))
        expect_refusal = bool(case["expect_refusal"])

        ok = (got_refusal == expect_refusal)

        # Beginner-friendly: if it answers, sources should exist and be a list
        if not got_refusal:
            ok = ok and isinstance(out.get("sources"), list)

        if ok:
            passed += 1
            print(f"PASS {case['id']}")
        else:
            failed += 1
            print(f"FAIL {case['id']} expected_refusal={expect_refusal} got_refusal={got_refusal}")
            print("Output:", json.dumps(out, indent=2))

    print(f"\nDone. Passed={passed} Failed={failed}")
    if failed:
        raise SystemExit(1)

if __name__ == "__main__":
    run()
</code></pre>
<h3 id="heading-how-to-use-evals-in-practice">How to Use Evals in Practice</h3>
<p>Run your server:</p>
<pre><code class="language-python">uvicorn app:app --reload
</code></pre>
<p>In another terminal, run evals:</p>
<pre><code class="language-python">python evals/run_evals.py
</code></pre>
<p>If an eval fails, you have a concrete signal that something changed in retrieval, gating, prompting, or provider behaviour.</p>
<h2 id="heading-what-to-improve-next-realistic-upgrades">What to Improve Next: Realistic Upgrades</h2>
<p>Building a reliable RAG app is iterative. Here are realistic next steps:</p>
<ul>
<li><p><strong>Semantic chunking:</strong> Break text based on meaning instead of character count.</p>
</li>
<li><p><strong>Reranking:</strong> Use a cross-encoder reranker to reorder the top-k chunks for higher precision.</p>
</li>
<li><p><strong>Metadata filtering:</strong> Filter results by category, date, or department to reduce false positives.</p>
</li>
<li><p><strong>Better citations:</strong> Store chunk IDs and show exactly which chunk(s) the answer came from.</p>
</li>
<li><p><strong>Observability:</strong> Add request IDs, structured logs, and traces so “what happened?” is answerable.</p>
</li>
<li><p><strong>Async + background indexing:</strong> Move index building to a background job and keep the API responsive.</p>
</li>
</ul>
<h2 id="heading-final-thoughts-production-ready-is-a-set-of-habits">Final Thoughts: Production-Ready Is a Set of Habits</h2>
<p>Building an AI application that survives in the real world is about building a system that is predictable, measurable, and safe.</p>
<ul>
<li><p><strong>Retrieval quality is measurable:</strong> Use similarity scores to gate your LLM.</p>
</li>
<li><p><strong>Refusal is a feature:</strong> It is better to say “I do not know” than to lie.</p>
</li>
<li><p><strong>Fallbacks are mandatory:</strong> Design for the moment the API goes down.</p>
</li>
<li><p><strong>Evals prevent regressions:</strong> Never deploy a change without running your tests.</p>
</li>
</ul>
<h2 id="heading-about-me">About Me</h2>
<p>I am Chidozie Managwu, an award-winning AI Product Architect and founder focused on helping global tech talent build real, production-ready skills. I contribute to global AI initiatives as a GAFAI Delegate and lead AI Titans Network, a community for developers learning how to ship AI products.</p>
<p>My work has been recognized with the Global Tech Hero award and featured on platforms like HackerNoon.</p>
 ]]>
                </content:encoded>
            </item>
        
    </channel>
</rss>
