<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[AI Software Development]]></title><description><![CDATA[Lycore is a custom software development company specializing in building high-quality mobile apps, web platforms, and AI-powered solutions. We help businesses t]]></description><link>https://aidevpro.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Tue, 27 Oct 2026 10:46:09 GMT</lastBuildDate><atom:link href="https://aidevpro.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Human-in-the-Loop AI Agents in Django: Approval Gates, Audit Trails, and Evals That Actually Ship]]></title><description><![CDATA[A practical guide to human-in-the-loop AI agents in Django: proposal models, approval gates, audit logs, risk tiers, and evals in CI.]]></description><link>https://aidevpro.hashnode.dev/human-in-the-loop-ai-agents-django-approval-gates</link><guid isPermaLink="true">https://aidevpro.hashnode.dev/human-in-the-loop-ai-agents-django-approval-gates</guid><category><![CDATA[ai agents]]></category><category><![CDATA[AI]]></category><category><![CDATA[Django]]></category><category><![CDATA[Python]]></category><category><![CDATA[llm]]></category><dc:creator><![CDATA[AI Developer]]></dc:creator><pubDate>Mon, 26 Oct 2026 12:49:18 GMT</pubDate><content:encoded><![CDATA[<p>Here's the agent demo everyone has seen: a model reads a messy input, calls three tools, and updates your database. Everyone claps. Then someone from compliance asks, "Who approved that change?" and nobody has an answer.</p>
<p>That question kills more AI features than model quality does. In 2026 the industry has mostly stopped arguing about whether agents work and started arguing about how to let them touch real systems safely. The talks lined up for QCon AI New York this December cover identity and authorization for agents, server-side guardrails, and eval loops (<a href="https://www.infoq.com/news/2026/10/qcon-ai-newyork-2026-sessionsv2/">InfoQ, Oct 2026</a>). Trend reports list "human approval becomes a design pattern" next to "agents move into production."</p>
<p>This post is the pattern we use at Lycore when an AI feature writes into a system where mistakes have consequences: care records, money, anything a lawyer might read later. It's Django-flavored, but the ideas carry over to any stack.</p>
<h2>TL;DR</h2>
<ul>
<li><strong>Never let the model write directly to your domain tables.</strong> It writes a <em>proposal</em>. A person or a policy turns that proposal into a change.</li>
<li><strong>Model the approval as data</strong>: a <code>Proposal</code> table with status, risk tier, the model's raw output, the validated payload, and who decided.</li>
<li><strong>Validate before a human ever sees it.</strong> Pydantic schemas plus domain checks. Humans should judge meaning, not catch malformed JSON.</li>
<li><strong>Tier the risk.</strong> Low-risk proposals can auto-apply under policy; high-risk ones need a named approver; some actions are never automated at all.</li>
<li><strong>Append-only audit log</strong> that records the prompt version, model, input hash, output, reviewer, and diff.</li>
<li><strong>Edits are signal.</strong> When reviewers change the draft, store the diff. That's your eval dataset, for free.</li>
<li><strong>Put evals in CI</strong> with a golden set and a zero-regression rule, so a prompt tweak can't quietly break things.</li>
</ul>
<h2>Why "agent writes to DB" fails in real workflows</h2>
<p>Three things go wrong when an LLM gets direct write access to production data.</p>
<p><strong>1. Accountability disappears.</strong> In regulated domains (care, health, finance, legal), every record needs an author. "The AI did it" isn't an author. If a medication note or an incident report is wrong, someone must have signed off on it.</p>
<p><strong>2. Errors are silent and plausible.</strong> A traditional bug usually crashes or produces obvious garbage. A model error produces a well-formatted, confident, wrong sentence. It gets past type checks and code review and lands in a report.</p>
<p><strong>3. You can't improve what you don't capture.</strong> If the model's output goes straight into the final record, you lose the difference between what the model said and what was true. That difference is the most valuable training and eval data you'll ever have.</p>
<p>The fix isn't "use a better model." It's architecture.</p>
<h2>The core pattern: propose, validate, approve, apply</h2>
<pre><code class="language-plaintext"> user input / event
        │
        ▼
 ┌──────────────┐   raw output   ┌──────────────┐
 │  LLM agent   │ ─────────────▶ │  Validator   │  (schema + domain rules)
 └──────────────┘                └──────┬───────┘
                                        │ valid payload
                                        ▼
                                 ┌──────────────┐
                                 │  Proposal    │  status = pending
                                 └──────┬───────┘
                      policy says       │      policy says
                      low risk          │      needs review
                 ┌──────────────────────┴───────────────┐
                 ▼                                      ▼
          auto-apply (logged)                  human review UI
                 │                         approve / edit / reject
                 └──────────────┬───────────────────────┘
                                ▼
                        ┌──────────────┐
                        │  Apply step  │  → domain tables + AuditEvent
                        └──────────────┘
</code></pre>
<p>The model never holds a database handle. Its only output is a proposal. The apply step is ordinary, deterministic, tested Django code that you'd write anyway.</p>
<h2>Step 1: Model the proposal as data</h2>
<pre><code class="language-python"># proposals/models.py
import uuid
from django.conf import settings
from django.db import models


class Proposal(models.Model):
    class Status(models.TextChoices):
        PENDING = "pending"
        APPROVED = "approved"
        REJECTED = "rejected"
        APPLIED = "applied"
        FAILED = "failed"
        EXPIRED = "expired"

    class Risk(models.TextChoices):
        LOW = "low"        # may auto-apply under policy
        MEDIUM = "medium"  # any qualified reviewer
        HIGH = "high"      # named role required, maybe two people

    id = models.UUIDField(primary_key=True, default=uuid.uuid4)
    tenant = models.ForeignKey("tenants.Tenant", on_delete=models.PROTECT)
    kind = models.CharField(max_length=64)          # e.g. "incident_report.create"
    target_ct = models.CharField(max_length=64, blank=True)
    target_id = models.CharField(max_length=64, blank=True)

    status = models.CharField(max_length=16, choices=Status.choices,
                              default=Status.PENDING, db_index=True)
    risk = models.CharField(max_length=8, choices=Risk.choices)

    # What the model actually produced, and what we validated
    raw_output = models.JSONField()
    payload = models.JSONField()
    final_payload = models.JSONField(null=True, blank=True)  # after human edits

    # Provenance
    model_name = models.CharField(max_length=128)
    prompt_version = models.CharField(max_length=32)
    input_hash = models.CharField(max_length=64)

    requested_by = models.ForeignKey(settings.AUTH_USER_MODEL,
                                     on_delete=models.PROTECT,
                                     related_name="+")
    decided_by = models.ForeignKey(settings.AUTH_USER_MODEL, null=True,
                                   blank=True, on_delete=models.PROTECT,
                                   related_name="+")
    decided_at = models.DateTimeField(null=True, blank=True)
    decision_note = models.TextField(blank=True)

    created_at = models.DateTimeField(auto_now_add=True)
    expires_at = models.DateTimeField(null=True, blank=True)

    class Meta:
        indexes = [models.Index(fields=["tenant", "status", "kind"])]
</code></pre>
<p>A few decisions worth explaining:</p>
<ul>
<li><code>raw_output</code><strong>,</strong> <code>payload</code><strong>, and</strong> <code>final_payload</code> <strong>are separate.</strong> Raw is what the model said. Payload is what survived validation. Final is what a human approved. Comparing them later tells you exactly where the model is weak.</li>
<li><code>prompt_version</code> <strong>and</strong> <code>model_name</code> <strong>are mandatory.</strong> When quality drops in three weeks, the first question will be "what changed?" Without these columns you're guessing.</li>
<li><code>input_hash</code><strong>, not the input itself.</strong> Inputs in care or finance often contain personal data. Store a hash for dedup and traceability, and keep the source data where your normal access controls already protect it.</li>
<li><code>on_delete=PROTECT</code> <strong>everywhere.</strong> Audit-relevant rows shouldn't vanish because a user was deleted.</li>
<li><code>tenant</code> <strong>is on the row.</strong> If you run multi-tenant SaaS, review queues must be tenant-scoped at the query level, not just in the UI.</li>
</ul>
<h2>Step 2: Validate before a human sees anything</h2>
<p>Reviewers get tired. If half the queue is broken JSON or missing fields, they start clicking "approve" to get through it. Make the machine catch everything a machine can catch.</p>
<pre><code class="language-python"># proposals/schemas.py
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, Field, field_validator


class IncidentDraft(BaseModel):
    occurred_at: datetime
    location: str = Field(min_length=2, max_length=120)
    category: Literal["fall", "medication", "behavioral",
                      "property", "injury", "other"]
    severity: Literal["low", "moderate", "serious"]
    summary: str = Field(min_length=40, max_length=2000)
    people_involved: list[str] = Field(default_factory=list, max_length=20)
    follow_up_actions: list[str] = Field(default_factory=list)

    @field_validator("occurred_at")
    @classmethod
    def not_in_future(cls, v: datetime) -&gt; datetime:
        from django.utils import timezone
        if v &gt; timezone.now():
            raise ValueError("incident time is in the future")
        return v
</code></pre>
<p>Schema validation catches shape. Domain validation catches meaning that code <em>can</em> check:</p>
<pre><code class="language-python"># proposals/validators.py
def validate_incident(draft: IncidentDraft, *, tenant, source_note) -&gt; list[str]:
    problems = []

    known = set(tenant.residents.values_list("display_name", flat=True))
    unknown = [p for p in draft.people_involved if p not in known]
    if unknown:
        problems.append(f"Unknown people referenced: {unknown}")

    # Cheap hallucination check: severe claims must be grounded in the source
    if draft.severity == "serious" and not any(
        w in source_note.lower() for w in ("hospital", "injur", "911", "ambulance", "bleed")
    ):
        problems.append("Marked serious but source note has no supporting detail")

    return problems
</code></pre>
<p>That second check is crude on purpose. Keyword heuristics won't catch everything, but they're deterministic, fast, and explainable, which is what you want in front of a reviewer. Problems don't necessarily block the proposal; they get displayed as warnings on the review screen so the human knows where to look.</p>
<p>When validation fails outright, retry once with the error message fed back to the model, then give up and show the user a plain form. A good fallback beats a fourth retry.</p>
<pre><code class="language-python">def draft_incident(note: str, *, tenant, user) -&gt; Proposal:
    for attempt in range(2):
        raw = llm.complete_json(
            prompt=INCIDENT_PROMPT_V3,
            input=note,
            schema=IncidentDraft.model_json_schema(),
            feedback=last_error if attempt else None,
        )
        try:
            draft = IncidentDraft.model_validate(raw)
            break
        except ValidationError as e:
            last_error = str(e)
    else:
        raise DraftFailed(last_error)  # UI falls back to a manual form

    warnings = validate_incident(draft, tenant=tenant, source_note=note)
    return Proposal.objects.create(
        tenant=tenant,
        kind="incident_report.create",
        risk=risk_for(draft, warnings),
        raw_output=raw,
        payload={**draft.model_dump(mode="json"), "_warnings": warnings},
        model_name=llm.model_name,
        prompt_version="incident-v3",
        input_hash=sha256(note.encode()).hexdigest(),
        requested_by=user,
    )
</code></pre>
<h2>Step 3: Tier the risk, and write the policy down as code</h2>
<p>Not everything deserves the same friction. If every AI suggestion needs a manager's signature, people will stop using the feature. If nothing does, you're back to "the AI did it."</p>
<pre><code class="language-python"># proposals/policy.py
AUTO_APPLY_KINDS = {"meal_log.summarize", "logbook.tag"}
NEVER_AUTOMATE = {"medication.administer", "record.share_external"}


def risk_for(draft, warnings) -&gt; str:
    if warnings:
        return "high"
    if getattr(draft, "severity", None) == "serious":
        return "high"
    return "medium"


def can_auto_apply(p: Proposal) -&gt; bool:
    return (p.kind in AUTO_APPLY_KINDS
            and p.risk == "low"
            and not p.payload.get("_warnings"))


def can_approve(user, p: Proposal) -&gt; bool:
    if p.kind in NEVER_AUTOMATE:
        # A human still performs the action itself; the AI may only prepare it
        return False
    if p.risk == "high":
        return user.has_perm("proposals.approve_high") and user != p.requested_by
    return user.has_perm("proposals.approve")
</code></pre>
<p>Three rules we've found worth keeping:</p>
<ol>
<li><strong>Some actions are never automated.</strong> Giving a medication, sharing records outside the organization, moving money. The AI can prepare a checklist or pre-fill a form, but the action is a person's, start to finish.</li>
<li><strong>High risk means four eyes.</strong> The person who requested the draft can't approve it. It's a one-line check, and auditors love it.</li>
<li><strong>Policy lives in one file.</strong> When compliance asks "what can the AI do on its own?", you send them this file, not a slide.</li>
</ol>
<h2>Step 4: Make the apply step boring and atomic</h2>
<pre><code class="language-python"># proposals/services.py
from django.db import transaction
from django.utils import timezone


@transaction.atomic
def decide(proposal_id, *, user, approve: bool, edited_payload=None, note=""):
    p = (Proposal.objects
         .select_for_update()
         .get(id=proposal_id, tenant=user.tenant))

    if p.status != Proposal.Status.PENDING:
        raise AlreadyDecided(p.status)
    if p.expires_at and p.expires_at &lt; timezone.now():
        p.status = Proposal.Status.EXPIRED
        p.save(update_fields=["status"])
        raise Expired()
    if approve and not can_approve(user, p):
        raise PermissionDenied()

    p.decided_by, p.decided_at, p.decision_note = user, timezone.now(), note

    if not approve:
        p.status = Proposal.Status.REJECTED
        p.save()
        AuditEvent.record(p, "rejected", actor=user)
        return p

    final = IncidentDraft.model_validate(edited_payload or p.payload)
    p.final_payload = final.model_dump(mode="json")

    obj = APPLIERS[p.kind](final, tenant=p.tenant, author=user)
    p.target_ct, p.target_id = obj._meta.label, str(obj.pk)
    p.status = Proposal.Status.APPLIED
    p.save()
    AuditEvent.record(p, "applied", actor=user,
                      diff=json_diff(p.payload, p.final_payload))
    return p
</code></pre>
<p>Details that matter:</p>
<ul>
<li><code>select_for_update()</code> prevents two reviewers approving the same proposal at the same time. Without it, you'll eventually get duplicate records, and it'll happen on the busiest day.</li>
<li><strong>Re-validate the edited payload.</strong> Humans make typos too. The reviewer's edit goes through the same schema.</li>
<li><strong>The author of the domain record is the approver</strong>, not "system". The record says who stood behind it.</li>
<li><strong>Expiry.</strong> A draft incident report from nine days ago probably shouldn't be approved without a fresh look. Expire stale proposals.</li>
</ul>
<h2>Step 5: An append-only audit log</h2>
<pre><code class="language-python">class AuditEvent(models.Model):
    proposal = models.ForeignKey(Proposal, on_delete=models.PROTECT)
    tenant = models.ForeignKey("tenants.Tenant", on_delete=models.PROTECT)
    action = models.CharField(max_length=32)
    actor = models.ForeignKey(settings.AUTH_USER_MODEL, null=True,
                              on_delete=models.PROTECT)
    diff = models.JSONField(default=dict)
    at = models.DateTimeField(auto_now_add=True)

    def save(self, *args, **kwargs):
        if self.pk:
            raise RuntimeError("AuditEvent is append-only")
        super().save(*args, **kwargs)

    def delete(self, *args, **kwargs):
        raise RuntimeError("AuditEvent is append-only")

    @classmethod
    def record(cls, p, action, *, actor=None, diff=None):
        return cls.objects.create(proposal=p, tenant=p.tenant,
                                  action=action, actor=actor, diff=diff or {})
</code></pre>
<p>Overriding <code>save</code> and <code>delete</code> stops accidents in application code. It doesn't stop a determined admin with <code>psql</code>. If you need stronger guarantees, revoke <code>UPDATE</code>/<code>DELETE</code> on the table for the app's database role, or add a trigger that rejects them. Know which guarantee you actually have, and don't claim more.</p>
<h2>Step 6: The review UI is the product</h2>
<p>Most teams spend 90% of their effort on the prompt and 10% on the review screen. Flip that ratio. A good review screen:</p>
<ul>
<li><strong>Shows the source next to the draft.</strong> The reviewer should never have to open another tab to check the model's claims.</li>
<li><strong>Highlights warnings inline</strong>: "Marked serious but source has no supporting detail" next to the severity field.</li>
<li><strong>Makes editing cheaper than rejecting.</strong> If fixing one field means rejecting and starting over, people approve bad drafts instead.</li>
<li><strong>Marks AI-generated text clearly</strong> in the final record, at least in metadata, so later readers know which parts started as a draft.</li>
<li><strong>Is fast with a keyboard.</strong> Queue-heavy roles review dozens of items per shift.</li>
</ul>
<p>In a Django app, HTMX works well here: the queue is a server-rendered list, approve/reject are <code>hx-post</code> buttons that swap the row, and you avoid shipping a SPA just to click "approve."</p>
<p>We built this exact flow into <strong>LyCare</strong>, our care-operations platform. Staff can report an incident with AI help, and the platform also handles medications, medical visits, logbooks, staff compliance, and sharing records with lawyers, all places where "who signed this?" matters. You can see the walkthroughs, including the AI incident reporting flow, at <a href="https://lycare.lycore.com/how-to">lycare.lycore.com/how-to</a>.</p>
<h2>Step 7: Turn reviewer edits into evals</h2>
<p>This part pays for everything else. Every approved proposal gives you a triple: input, model draft, and human-corrected final. That's a labeled dataset you didn't have to pay anyone to build.</p>
<pre><code class="language-python"># evals/export.py
def export_golden(kind: str, since):
    qs = (Proposal.objects
          .filter(kind=kind, status="applied", decided_at__gte=since)
          .exclude(final_payload=None))
    for p in qs.iterator():
        yield {
            "id": str(p.id),
            "input_ref": p.input_hash,   # resolve to a de-identified fixture
            "expected": p.final_payload,
            "model_draft": p.payload,
            "edited": p.payload != p.final_payload,
        }
</code></pre>
<p>Two metrics are cheap to track from day one:</p>
<ul>
<li><strong>Edit rate</strong>: share of approved proposals that a human changed. Rising edit rate after a prompt change means you made it worse, even if nobody complained.</li>
<li><strong>Field-level edit rate</strong>: which fields get changed. If <code>severity</code> is edited far more often than <code>location</code>, you know where to put prompt and validation effort.</li>
</ul>
<p>Be careful with personal data here. Turn real cases into de-identified fixtures before they go anywhere near a repo or a third-party eval tool. In care and health contexts that isn't optional.</p>
<h3>Evals in CI with a zero-regression rule</h3>
<p>Once you have a golden set, gate pull requests on it. Google's developer team describes a sensible pair of rules: a global pass threshold, plus zero regressions on any previously passing metric (<a href="https://developers.googleblog.com/en/evaluating-ai-agents-moving-past-the-vibe-check/">Google Developers Blog</a>).</p>
<pre><code class="language-python"># tests/evals/test_incident_drafts.py
import json, pytest
from proposals.schemas import IncidentDraft

CASES = [json.loads(l) for l in open("evals/incident_golden.jsonl")]
BASELINE = json.load(open("evals/incident_baseline.json"))


@pytest.mark.eval
@pytest.mark.parametrize("case", CASES, ids=lambda c: c["id"])
def test_incident_case(case, llm_recorder):
    raw = llm_recorder.complete_json(prompt="incident-v3", input=case["input"])
    draft = IncidentDraft.model_validate(raw)       # shape must always pass
    exp = case["expected"]
    assert draft.category == exp["category"]
    assert draft.severity == exp["severity"]
    # summary judged separately (LLM judge or rubric), not exact match


def test_no_regressions(eval_results):
    for metric, prev in BASELINE.items():
        assert eval_results[metric] &gt;= prev, f"{metric} regressed"
</code></pre>
<p>Run the eval suite on PRs that touch prompts, schemas, model config, or retrieval, not on every commit. Real model calls cost money and take time. Cache responses keyed by <code>(prompt_version, model, input_hash)</code> so unchanged cases don't hit the API again.</p>
<h2>Pitfalls we've hit (so you don't have to)</h2>
<p><strong>Rubber-stamping.</strong> If 98% of proposals are approved untouched, reviewers stop reading. Watch time-to-approve; a two-second approval on a 400-word report isn't a review. Some teams occasionally seed a known-bad draft to check attention. If you do that, be open with staff about it.</p>
<p><strong>Queue rot.</strong> Proposals pile up, nobody owns the queue, and the feature quietly becomes slower than doing it by hand. Give every queue an owner, an SLA, and expiry.</p>
<p><strong>Prompt drift without versioning.</strong> Someone "just tweaks the wording" in production. Prompts belong in the repo with a version string, and that version gets written onto every proposal.</p>
<p><strong>Model upgrades as silent changes.</strong> A provider's new model version can change behavior even when your prompt didn't change. Pin model versions and treat upgrades like dependency bumps: run the evals, compare, then ship.</p>
<p><strong>Mixing tenants in the review queue.</strong> One missing <code>.filter(tenant=...)</code> and a reviewer sees another organization's incident. Use a tenant-scoped manager by default and test for this explicitly.</p>
<p><strong>Treating approval as a liability shield.</strong> A human clicking "approve" doesn't make a bad system safe. If the review UI hides the source or the queue is impossibly long, the approval is theater. Design for real review or don't claim it.</p>
<h2>When you <em>can</em> skip the human</h2>
<p>Human review costs time. It's worth relaxing when all of these hold:</p>
<ul>
<li>the action is reversible (tagging, summarizing, sorting),</li>
<li>errors are cheap and visible quickly,</li>
<li>your edit rate for that kind has been consistently low over a meaningful sample,</li>
<li>and there's an easy undo.</li>
</ul>
<p>Even then, keep the proposal row and the audit event. Auto-apply is a policy decision about <em>who</em> approves (the policy instead of a person), not a reason to stop recording.</p>
<h2>How this maps to agents and MCP</h2>
<p>Everything above works with a single LLM call, and it works the same way with multi-step agents. If your agent calls tools over MCP or a function-calling API, make the write tools produce proposals instead of mutations:</p>
<pre><code class="language-python">@tool("create_incident_report")
def create_incident_report(args: dict, ctx) -&gt; dict:
    p = draft_from_agent(args, tenant=ctx.tenant, user=ctx.user)
    return {"status": "pending_review", "proposal_id": str(p.id)}
</code></pre>
<p>The agent gets a truthful answer ("this is pending review"), can tell the user so, and moves on. Read tools stay direct; write tools go through the gate. That one rule is most of what "least privilege for agents" means in practice.</p>
<h2>Conclusion</h2>
<p>The question that decides whether an AI feature survives contact with a real organization isn't "how smart is the model?" It's "can we explain every change it made, and who agreed to it?"</p>
<p>The pattern is short enough to remember: <strong>the model proposes, code validates, policy routes, a person (or an explicit rule) decides, and the database remembers.</strong> Build the <code>Proposal</code> table first, before the prompt. The rest — the review UI, the risk tiers, the eval set built from reviewer edits — follows from that table.</p>
<p>Do this and you get something rare: an AI feature your compliance team argues <em>for</em>.</p>
<p>If you're building AI into a workflow where mistakes matter and want a second opinion on the architecture, that's the kind of work we do at <a href="https://lycore.com">Lycore</a>. Drop a comment with how you handle approvals today; I read and reply to all of them.</p>
]]></content:encoded></item><item><title><![CDATA[Multi-Tenant SaaS Architecture on Django: Row-Level Security, Tenant Isolation, and the Patterns That Scale
]]></title><description><![CDATA[Building a SaaS application on Django starts simply. One database, one set of models, users who belong to an organisation. The first 100 customers are…]]></description><link>https://aidevpro.hashnode.dev/multi-tenant-saas-architecture-on-django-row-level-security-tenant-isolation-and-the-patterns-that-scale</link><guid isPermaLink="true">https://aidevpro.hashnode.dev/multi-tenant-saas-architecture-on-django-row-level-security-tenant-isolation-and-the-patterns-that-scale</guid><dc:creator><![CDATA[AI Developer]]></dc:creator><pubDate>Mon, 04 May 2026 08:28:41 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/69dfa7eb70e165e0d8bf3a3c/8d932543-f8c8-4488-aea4-602f49c50195.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Building a SaaS application on Django starts simply. One database, one set of models, users who belong to an organisation. The first 100 customers are straightforward. Then a customer asks whether their data is isolated from other customers. Then another asks for a custom subdomain. Then someone needs tenant-specific configuration. Then you need to debug a performance issue and realise you have no idea which tenant is responsible for the slow queries.</p>
<p>Multi-tenancy is one of the decisions that is hardest to change after the fact, and most teams do not make it deliberately at the start. This post covers the approach we use on production Django SaaS applications: how to choose the right isolation model, how to implement tenant context safely, how to handle tenant-specific configuration, and how to instrument the system so you can actually operate it at scale.</p>
<hr />
<h2>Choosing Your Isolation Model</h2>
<p>There are three approaches to multi-tenancy in a relational database. The choice you make determines your data model, your migration strategy, your backup approach, and your compliance posture for the lifetime of the application.</p>
<h3>Approach 1: Separate Databases Per Tenant</h3>
<p>Each tenant gets their own database. Complete data isolation at the infrastructure level. Simple query logic — no risk of cross-tenant data leakage in queries because queries physically cannot reach another tenant's data.</p>
<p><strong>When to choose this:</strong> Regulated industries (healthcare, finance, legal) where data residency requirements demand physical separation. Large enterprise customers who contractually require database isolation. Multi-region deployments where different tenants have different data residency requirements.</p>
<p><strong>When not to choose this:</strong> Most SaaS applications. The operational overhead — separate database servers, separate migration runs, separate backup policies, separate connection pools — scales linearly with tenant count. At 100 tenants this is manageable with automation. At 10,000 tenants it is a significant operational burden.</p>
<h3>Approach 2: Shared Database, Separate Schemas</h3>
<p>One database, one schema per tenant. PostgreSQL schemas provide namespace separation — <code>tenant_a.users</code>, <code>tenant_b.users</code> — with shared infrastructure. The <code>django-tenant-schemas</code> and <code>django-tenants</code> packages implement this pattern for Django.</p>
<p><strong>When to choose this:</strong> Good isolation with lower operational overhead than separate databases. Works well for mid-market SaaS with tenants that have significantly different data volumes. Schema-level separation makes it natural to give large tenants dedicated resources.</p>
<p><strong>When not to choose this:</strong> Schema proliferation at large tenant counts creates migration complexity. Running a migration across 5,000 schemas sequentially takes time. Cross-tenant analytics (for the platform operator) requires either cross-schema queries or a separate analytics database.</p>
<h3>Approach 3: Shared Database, Row-Level Isolation</h3>
<p>One database, one schema, all tenant data in shared tables with a <code>tenant_id</code> foreign key. The simplest infrastructure, the lowest operational overhead, the most natural Django data model.</p>
<p><strong>When to choose this:</strong> Most SaaS applications, particularly early-stage through mid-scale. Migrations are simple — one migration, all tenants. Cross-tenant analytics is straightforward. Operational overhead is minimal.</p>
<p><strong>When not to choose this:</strong> Regulated industries requiring physical isolation. Customers who contractually require it. Applications where the query complexity of always including <code>tenant_id</code> filters creates unacceptable risk of cross-tenant data leakage.</p>
<hr />
<img src="https://cdn.hashnode.com/uploads/covers/69dfa7eb70e165e0d8bf3a3c/3a2fc1f9-1b07-41d6-9163-9aadb9511bc5.jpg" alt="" style="display:block;margin:0 auto" />

<p>The rest of this post covers the row-level isolation approach in depth, as it is the most common choice for Django SaaS applications and the one with the most implementation nuance.</p>
<hr />
<h2>The Tenant Model</h2>
<p>Start with a clean Tenant model that serves as the root of all tenant-specific data:</p>
<pre><code class="language-python"># tenants/models.py
import uuid
from django.db import models
from django.contrib.auth.models import AbstractUser
 
 
class Tenant(models.Model):
    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
    name = models.CharField(max_length=255)
    slug = models.SlugField(unique=True)
    subdomain = models.CharField(max_length=100, unique=True)
    is_active = models.BooleanField(default=True)
    plan = models.CharField(
        max_length=50,
        choices=[("starter", "Starter"), ("growth", "Growth"), ("enterprise", "Enterprise")],
        default="starter"
    )
    created_at = models.DateTimeField(auto_now_add=True)
    settings = models.JSONField(default=dict)  # Tenant-specific configuration
 
    class Meta:
        indexes = [
            models.Index(fields=["subdomain"]),
            models.Index(fields=["slug"]),
        ]
 
    def __str__(self):
        return self.name
 
 
class TenantUser(AbstractUser):
    """User model that always belongs to a tenant."""
    tenant = models.ForeignKey(
        Tenant,
        on_delete=models.CASCADE,
        related_name="users"
    )
 
    class Meta:
        indexes = [
            models.Index(fields=["tenant", "email"]),
        ]
        # Enforce email uniqueness per tenant, not globally
        unique_together = [("tenant", "email")]
</code></pre>
<p>Note the <code>unique_together</code> on <code>TenantUser</code>: email uniqueness is scoped to the tenant, not global. The same email address can belong to different tenants. This is almost always the right behaviour for SaaS — a user can have accounts with multiple customers on your platform.</p>
<hr />
<h2>Tenant Context: Thread-Local Storage Done Right</h2>
<p>The central challenge in row-level multi-tenancy is ensuring that every database query automatically filters by the current tenant. The naive approach — pass <code>tenant</code> as an argument to every service function — works but creates enormous boilerplate and is easy to forget.</p>
<p>The standard Django approach uses thread-local storage to hold the current tenant context, set by middleware on every request:</p>
<pre><code class="language-python"># tenants/context.py
import threading
from typing import Optional
from .models import Tenant
 
_local = threading.local()
 
 
def set_current_tenant(tenant: Optional[Tenant]) -&gt; None:
    _local.tenant = tenant
 
 
def get_current_tenant() -&gt; Optional[Tenant]:
    return getattr(_local, "tenant", None)
 
 
def clear_current_tenant() -&gt; None:
    if hasattr(_local, "tenant"):
        del _local.tenant
</code></pre>
<pre><code class="language-python"># tenants/middleware.py
from django.http import Http404
from django.core.cache import cache
from .models import Tenant
from .context import set_current_tenant, clear_current_tenant
import logging
 
logger = logging.getLogger(__name__)
 
 
class TenantMiddleware:
    """Identifies the current tenant from the request and sets the thread-local context."""
 
    CACHE_TTL = 300  # 5 minutes
 
    def __init__(self, get_response):
        self.get_response = get_response
 
    def __call__(self, request):
        tenant = self._identify_tenant(request)
 
        if tenant is None:
            # No tenant found — could be the marketing site, admin, or invalid subdomain
            set_current_tenant(None)
        elif not tenant.is_active:
            raise Http404("Tenant not found")
        else:
            set_current_tenant(tenant)
 
        try:
            response = self.get_response(request)
        finally:
            # Always clear — never leave tenant context set between requests
            clear_current_tenant()
 
        return response
 
    def _identify_tenant(self, request) -&gt; Optional["Tenant"]:
        host = request.get_host().lower()
 
        # Check cache first
        cache_key = f"tenant:host:{host}"
        cached = cache.get(cache_key)
        if cached is not None:
            try:
                return Tenant.objects.get(id=cached)
            except Tenant.DoesNotExist:
                cache.delete(cache_key)
 
        # Resolve from subdomain
        subdomain = self._extract_subdomain(host)
        if not subdomain:
            return None
 
        try:
            tenant = Tenant.objects.get(subdomain=subdomain, is_active=True)
            cache.set(cache_key, str(tenant.id), self.CACHE_TTL)
            return tenant
        except Tenant.DoesNotExist:
            logger.warning("Unknown subdomain", extra={"subdomain": subdomain, "host": host})
            return None
 
    def _extract_subdomain(self, host: str) -&gt; Optional[str]:
        # Assumes format: subdomain.yourdomain.com
        # Adjust for your domain structure
        parts = host.split(".")
        if len(parts) &gt;= 3:  # subdomain.domain.tld
            return parts[0]
        return None
</code></pre>
<p>The <code>finally: clear_current_tenant()</code> is not optional. Without it, if an exception occurs during request processing, the tenant context remains set and could leak into the next request processed by the same thread. In a threaded server like Gunicorn, threads are reused across requests.</p>
<hr />
<img src="https://cdn.hashnode.com/uploads/covers/69dfa7eb70e165e0d8bf3a3c/9cee5614-1e7e-40c4-9a11-a2149709618a.jpg" alt="" style="display:block;margin:0 auto" />

<h2>The TenantQuerySet: Automatic Filtering at the Model Layer</h2>
<p>Thread-local context is only useful if models actually use it. A custom manager enforces tenant scoping at the ORM level:</p>
<pre><code class="language-python"># tenants/managers.py
from django.db import models
from .context import get_current_tenant
 
 
class TenantQuerySet(models.QuerySet):
    def for_current_tenant(self):
        tenant = get_current_tenant()
        if tenant is None:
            raise ValueError(
                "No tenant in context. Ensure TenantMiddleware is active "
                "or set the tenant context explicitly."
            )
        return self.filter(tenant=tenant)
 
 
class TenantManager(models.Manager):
    def get_queryset(self):
        return TenantQuerySet(self.model, using=self._db)
 
    def for_current_tenant(self):
        return self.get_queryset().for_current_tenant()
</code></pre>
<pre><code class="language-python"># Example tenant-scoped model
# myapp/models.py
from django.db import models
from tenants.models import Tenant
from tenants.managers import TenantManager
 
 
class Project(models.Model):
    tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, related_name="projects")
    name = models.CharField(max_length=255)
    description = models.TextField(blank=True)
    created_at = models.DateTimeField(auto_now_add=True)
 
    # Custom manager — always filter by tenant
    objects = TenantManager()
 
    # Unscoped manager for admin and cross-tenant operations
    all_objects = models.Manager()
 
    class Meta:
        indexes = [
            models.Index(fields=["tenant", "created_at"]),
            models.Index(fields=["tenant", "name"]),
        ]
        # Name uniqueness scoped to tenant
        unique_together = [("tenant", "name")]
 
    def __str__(self):
        return f"{self.tenant.slug}/{self.name}"
</code></pre>
<p>Usage in views and services is clean:</p>
<pre><code class="language-python"># In a view or service — automatically scoped to current tenant
projects = Project.objects.for_current_tenant().order_by("-created_at")
 
# Cross-tenant (admin, analytics) — explicit opt-in
all_projects = Project.all_objects.all()
</code></pre>
<p>The <code>all_objects</code> manager is the escape hatch for legitimate cross-tenant operations — admin views, platform-level analytics, data exports. Making it explicit creates a natural audit trail in code review: any use of <code>all_objects</code> should be reviewed to ensure cross-tenant access is intentional.</p>
<hr />
<h2>Tenant-Specific Configuration</h2>
<p>SaaS applications almost always need tenant-specific configuration: feature flags, branding, integration credentials, plan limits. The <code>settings</code> JSONField on the Tenant model handles most of this, but you need a safe accessor:</p>
<pre><code class="language-python"># tenants/config.py
from typing import Any
from .context import get_current_tenant
 
# Default configuration — applies to all tenants unless overridden
TENANT_DEFAULTS = {
    "max_projects": 10,
    "max_users": 5,
    "ai_features_enabled": False,
    "custom_branding": False,
    "sso_enabled": False,
    "api_rate_limit_per_minute": 60,
    "data_retention_days": 365,
}
 
 
def get_tenant_config(key: str, default: Any = None) -&gt; Any:
    """
    Get a configuration value for the current tenant.
    Falls back to TENANT_DEFAULTS, then to the provided default.
    """
    tenant = get_current_tenant()
    if tenant is None:
        return TENANT_DEFAULTS.get(key, default)
 
    # Tenant settings override defaults
    if key in tenant.settings:
        return tenant.settings[key]
 
    return TENANT_DEFAULTS.get(key, default)
 
 
def get_plan_limits(tenant=None) -&gt; dict:
    """Get the usage limits for the current or specified tenant's plan."""
    t = tenant or get_current_tenant()
    if t is None:
        return PLAN_LIMITS["starter"]
    return PLAN_LIMITS.get(t.plan, PLAN_LIMITS["starter"])
 
 
PLAN_LIMITS = {
    "starter":    {"max_projects": 5,   "max_users": 3,   "api_calls_per_day": 1000},
    "growth":     {"max_projects": 50,  "max_users": 25,  "api_calls_per_day": 10000},
    "enterprise": {"max_projects": 500, "max_users": 250, "api_calls_per_day": 100000},
}
</code></pre>
<pre><code class="language-python"># Usage in a view
from tenants.config import get_tenant_config, get_plan_limits
 
def create_project(request):
    limits = get_plan_limits()
    current_count = Project.objects.for_current_tenant().count()
 
    if current_count &gt;= limits["max_projects"]:
        return JsonResponse(
            {"error": f"Plan limit reached: {limits['max_projects']} projects maximum"},
            status=403
        )
 
    # Proceed with creation
</code></pre>
<hr />
<h2>Testing Multi-Tenant Applications</h2>
<p>Testing requires explicit tenant context management. We use a pytest fixture:</p>
<pre><code class="language-python"># conftest.py
import pytest
from tenants.models import Tenant
from tenants.context import set_current_tenant, clear_current_tenant
 
 
@pytest.fixture
def tenant(db):
    return Tenant.objects.create(
        name="Test Tenant",
        slug="test-tenant",
        subdomain="test",
        plan="growth"
    )
 
 
@pytest.fixture
def tenant_context(tenant):
    """Sets and clears tenant context for the duration of a test."""
    set_current_tenant(tenant)
    yield tenant
    clear_current_tenant()
 
 
@pytest.fixture
def another_tenant(db):
    return Tenant.objects.create(
        name="Another Tenant",
        slug="another-tenant",
        subdomain="another",
        plan="starter"
    )
</code></pre>
<pre><code class="language-python"># test_projects.py
import pytest
from myapp.models import Project
 
 
def test_project_isolation(tenant_context, another_tenant, db):
    """Projects from one tenant must not be visible to another."""
    # Create project for current tenant
    project = Project.objects.create(
        tenant=tenant_context,
        name="Tenant A Project"
    )
 
    # Create project for another tenant
    other_project = Project.objects.create(
        tenant=another_tenant,
        name="Tenant B Project"
    )
 
    # Query with tenant context active — should only see current tenant's project
    results = Project.objects.for_current_tenant()
    assert results.count() == 1
    assert results.first().id == project.id
    assert other_project.id not in results.values_list("id", flat=True)
</code></pre>
<p>Cross-tenant isolation tests are the most important tests in a multi-tenant application. Run them on every relevant model. A single missing <code>filter(tenant=tenant)</code> anywhere in your codebase is a data leak.</p>
<hr />
<img src="https://cdn.hashnode.com/uploads/covers/69dfa7eb70e165e0d8bf3a3c/f191e21e-0b81-411d-a09d-4374d49e0087.jpg" alt="" style="display:block;margin:0 auto" />

<h2>Observability at the Tenant Level</h2>
<p>Standard application monitoring is not enough for a multi-tenant SaaS. You need visibility at the tenant level to identify which tenants are responsible for performance issues, which are approaching plan limits, and which are at risk of churning based on usage patterns.</p>
<p>Add tenant context to structured logs:</p>
<pre><code class="language-python"># middleware — extend TenantMiddleware to add tenant to log context
import structlog
 
def __call__(self, request):
    tenant = self._identify_tenant(request)
    set_current_tenant(tenant)
 
    # Add tenant to structlog context for all logs in this request
    structlog.contextvars.bind_contextvars(
        tenant_id=str(tenant.id) if tenant else None,
        tenant_slug=tenant.slug if tenant else None,
    )
 
    try:
        response = self.get_response(request)
    finally:
        clear_current_tenant()
        structlog.contextvars.unbind_contextvars("tenant_id", "tenant_slug")
 
    return response
</code></pre>
<p>With tenant context in every log line, you can query your log aggregation system for all slow queries from a specific tenant, all errors affecting a specific tenant, or the request volume breakdown by tenant — without adding explicit tenant filtering to every log statement.</p>
<p>For a complete guide to production-ready Django deployment covering ASGI, horizontal scaling, database pooling, and full observability, read <a href="https://www.lycore.com/blog/production-ready-django-deployment-scaling/">Lycore's production Django guide</a>.</p>
<hr />
<p><em>Lycore builds production Django SaaS applications for businesses that need multi-tenant architecture that scales. We architect, develop, and deliver custom backend systems, AI integrations, and full-stack applications.</em> <a href="https://www.lycore.com/contact-us/"><em>Get in touch</em></a><em>.</em></p>
]]></content:encoded></item><item><title><![CDATA[From Prototype to Production: What Actually Changes When You Ship an AI Feature
]]></title><description><![CDATA[When you build an AI prototype, the goal is to make it work. When you ship an AI feature to production, the goal changes entirely — now it has to keep…]]></description><link>https://aidevpro.hashnode.dev/from-prototype-to-production-what-actually-changes-when-you-ship-an-ai-feature</link><guid isPermaLink="true">https://aidevpro.hashnode.dev/from-prototype-to-production-what-actually-changes-when-you-ship-an-ai-feature</guid><dc:creator><![CDATA[AI Developer]]></dc:creator><pubDate>Sat, 18 Apr 2026 05:30:00 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/69dfa7eb70e165e0d8bf3a3c/f622895d-a0c3-422a-9870-ddfedb1da30c.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>When you build an AI prototype, the goal is to make it work. When you ship an AI feature to production, the goal changes entirely — now it has to keep working, for real users, on data you did not anticipate, at a cost you can sustain, indefinitely.</p>
<p>That gap between prototype and production is where most AI projects quietly fail. Not with a dramatic error, but with a slow erosion of trust as outputs drift, edge cases multiply, and monitoring gaps leave you discovering problems from user complaints rather than your own systems.</p>
<p>This post is about what we have learned from taking AI features through that transition. The things that matter in a demo, the things that matter in production, and why they are different.</p>
<hr />
<h2>What a prototype optimises for</h2>
<p>A prototype has one job: demonstrate that the AI behaviour is achievable. It answers the question "can we do this?" not "can we run this?"</p>
<p>In prototype mode you accept a lot of shortcuts that feel harmless but accumulate into problems:</p>
<ul>
<li><p>Prompts are tuned on a small, representative sample of inputs</p>
</li>
<li><p>Error handling is minimal because the demo path always succeeds</p>
</li>
<li><p>Latency is acceptable because you are running it manually</p>
</li>
<li><p>Cost is invisible because you are not paying per request at scale</p>
</li>
<li><p>Evaluation is subjective — it looks right to the person who built it</p>
</li>
</ul>
<p>None of these are problems for a prototype. All of them are problems for a production system.</p>
<hr />
<h2>What changes when you ship</h2>
<h3>Your input distribution shifts immediately</h3>
<p>The inputs you designed for are not the inputs you will receive. Users rephrase, abbreviate, use terminology you did not expect, submit edge cases your prompts were not written to handle, and occasionally try to break the system deliberately.</p>
<p>The first week after launch typically surfaces more edge cases than the entire prototype phase. A document summariser built on clean PDFs encounters scanned images, password-protected files, and 400-page legal documents. A support classifier tested on English tickets receives tickets in three languages. An onboarding chatbot encounters users who ask questions about features that do not exist yet.</p>
<p>The fix is not to handle every edge case before launch — that is impossible. The fix is to have the infrastructure to detect them quickly and iterate on prompts and logic without redeploying the application.</p>
<h3>Latency becomes a user experience problem</h3>
<p>A five-second AI response is fine in a demo. It is painful in a product. Users lose context, perceive the application as broken, and abandon tasks.</p>
<p>For synchronous AI features visible to end users, we target under two seconds for the full round trip. Achieving this requires:</p>
<p><strong>Streaming responses.</strong> Return tokens as they are generated rather than waiting for the full completion. Users perceive streaming output as fast even when total generation time is similar.</p>
<pre><code class="language-python"># services/ai/summariser.py
def summarise_streaming(self, title: str, content: str):
    """Generator that yields summary tokens as they arrive."""
    messages = self._build_messages(title, content)

    with self.client.chat.completions.stream(
        model=self.MODEL,
        messages=messages,
        max_tokens=self.MAX_TOKENS,
    ) as stream:
        for chunk in stream:
            delta = chunk.choices[0].delta.content
            if delta:
                yield delta
</code></pre>
<pre><code class="language-python"># views.py
from django.http import StreamingHttpResponse

def summarise_document_stream(request, doc_id):
    doc = Document.objects.get(id=doc_id)
    summariser = get_summariser()

    def generate():
        for token in summariser.summarise_streaming(doc.title, doc.content):
            yield f"data: {token}\n\n"
        yield "data: [DONE]\n\n"

    return StreamingHttpResponse(generate(), content_type="text/event-stream")
</code></pre>
<p><strong>Async for non-blocking features.</strong> AI calls that do not need to block the UI — generating a report, processing a batch of records, enriching a newly created object — should run as background Celery tasks. The user gets an immediate response and the result appears when it is ready.</p>
<p><strong>Caching for repeated queries.</strong> As covered in detail in our <a href="https://www.lycore.com/ai-development-services/">post on cutting LLM costs</a>, exact and semantic caching can eliminate API calls for common inputs entirely.</p>
<h3>Evaluation becomes an engineering problem</h3>
<p>In prototype mode, evaluation is a human looking at outputs and deciding if they are good. This does not scale.</p>
<p>In production, you need automated evaluation. Not perfect automated evaluation — that is not achievable for open-ended generation tasks — but enough signal to know when quality has degraded.</p>
<p>For classification tasks, this is straightforward: log predictions with a confidence flag and periodically sample low-confidence outputs for human review.</p>
<pre><code class="language-python">def classify_with_evaluation(self, subject: str, body: str) -&gt; dict:
    result = self.classify(subject, body)

    if not result["confident"]:
        EvaluationQueue.objects.create(
            feature="ticket_classification",
            input={"subject": subject, "body": body[:500]},
            output=result,
            reason="low_confidence"
        )

    return result
</code></pre>
<p>For generation tasks, you can use a second LLM call as an evaluator:</p>
<pre><code class="language-python"># services/ai/evaluator.py
def evaluate_summary(original: str, summary: str) -&gt; dict:
    """Use a cheap model to spot-check summary quality."""
    response = get_openai_client().chat.completions.create(
        model="gpt-4o-mini",
        messages=[{
            "role": "user",
            "content": f"""Rate this summary on two dimensions (1-5 each):
1. Accuracy: Does it correctly represent the original?
2. Completeness: Does it cover the key points?

Original (first 1000 chars): {original[:1000]}

Summary: {summary}

Respond only with JSON: {{"accuracy": N, "completeness": N, "flag": true/false}}
Flag true if either score is below 3."""
        }],
        max_tokens=60,
        temperature=0.0
    )
    import json
    return json.loads(response.choices[0].message.content)
</code></pre>
<p>Run this evaluation on a sampled subset (5–10%) of production outputs. It adds cost but provides the quality signal you cannot get any other way.</p>
<h3>Prompt management becomes version control</h3>
<p>In prototype mode, prompts live in code as strings. When you change a prompt, you commit it and redeploy. This works until you need to:</p>
<ul>
<li><p>Roll back a prompt change that degraded quality</p>
</li>
<li><p>A/B test two prompt variants on live traffic</p>
</li>
<li><p>Change a prompt without a code deployment</p>
</li>
<li><p>Know exactly which prompt version generated a specific output</p>
</li>
</ul>
<p>We store prompts in the database for production features, with versioning:</p>
<pre><code class="language-python"># models.py
class PromptVersion(models.Model):
    feature = models.CharField(max_length=100)
    version = models.PositiveIntegerField()
    system_prompt = models.TextField()
    user_template = models.TextField()
    is_active = models.BooleanField(default=False)
    created_at = models.DateTimeField(auto_now_add=True)
    notes = models.TextField(blank=True)

    class Meta:
        unique_together = ('feature', 'version')

    @classmethod
    def get_active(cls, feature: str) -&gt; 'PromptVersion':
        return cls.objects.get(feature=feature, is_active=True)
</code></pre>
<p>With this model, a prompt change is a database write, not a code deployment. You can roll back by flipping <code>is_active</code>. You can log <code>prompt_version_id</code> alongside every AI output for complete traceability.</p>
<h3>Observability is not optional</h3>
<p>A production AI feature that you cannot observe is a liability. You need to know:</p>
<ul>
<li><p>How many requests are succeeding vs. failing vs. hitting fallback paths?</p>
</li>
<li><p>What is the p50, p95, and p99 latency?</p>
</li>
<li><p>What is the token count distribution, and is it drifting?</p>
</li>
<li><p>What is the cache hit rate?</p>
</li>
<li><p>Which inputs are being flagged for review?</p>
</li>
</ul>
<p>We emit a structured log event on every AI call:</p>
<pre><code class="language-python">import structlog

log = structlog.get_logger()

def log_ai_call(feature, model, input_tokens, output_tokens,
                latency_ms, cached, flagged, prompt_version):
    log.info(
        "ai_call_complete",
        feature=feature,
        model=model,
        input_tokens=input_tokens,
        output_tokens=output_tokens,
        estimated_cost_usd=calculate_cost(model, input_tokens, output_tokens),
        latency_ms=latency_ms,
        cached=cached,
        flagged=flagged,
        prompt_version=prompt_version,
    )
</code></pre>
<p>These events flow into whatever monitoring stack the client uses. Over time they build a picture of system health that makes degradation visible before users notice it.</p>
<hr />
<h2>The deployment checklist we use</h2>
<p>Before any AI feature goes to production, we work through the following:</p>
<p><strong>Inputs and edge cases</strong></p>
<ul>
<li><p>What is the maximum input size, and what happens when it is exceeded?</p>
</li>
<li><p>What happens with empty or malformed input?</p>
</li>
<li><p>What languages or input formats might appear that were not in the test set?</p>
</li>
</ul>
<p><strong>Fallback and graceful degradation</strong></p>
<ul>
<li><p>What does the feature do when the LLM API is unavailable?</p>
</li>
<li><p>What does it do when response quality is below the confidence threshold?</p>
</li>
<li><p>Is there a human fallback path for edge cases?</p>
</li>
</ul>
<p><strong>Performance</strong></p>
<ul>
<li><p>Has the feature been tested at expected peak load?</p>
</li>
<li><p>Are streaming or async patterns in place for user-facing latency?</p>
</li>
<li><p>Is caching implemented and tested?</p>
</li>
</ul>
<p><strong>Evaluation</strong></p>
<ul>
<li><p>Is there automated quality sampling in place?</p>
</li>
<li><p>Are low-confidence outputs being routed to a review queue?</p>
</li>
<li><p>Is there a baseline quality measurement to compare against after prompt changes?</p>
</li>
</ul>
<p><strong>Observability</strong></p>
<ul>
<li><p>Are structured logs emitting on every call?</p>
</li>
<li><p>Are cost, latency, and token count being tracked?</p>
</li>
<li><p>Is there an alert for error rate spikes?</p>
</li>
</ul>
<p><strong>Prompt management</strong></p>
<ul>
<li><p>Is the active prompt version stored and logged with each output?</p>
</li>
<li><p>Is there a rollback mechanism that does not require a code deployment?</p>
</li>
</ul>
<hr />
<h2>What this looks like in practice</h2>
<p>Most of the above is not complex engineering — it is disciplined engineering. A streaming view, a Celery task, a database model for prompt versions, a structured log event. None of these take long to build. The teams that skip them are not saving meaningful time in the short term. They are creating a maintenance burden that compounds for as long as the feature runs.</p>
<p>The AI features that stay valuable over time are the ones that were built to be observed, evaluated, and iterated on from day one — not retrofitted with monitoring after something went wrong.</p>
<p>If you are building trading systems, fintech tools, or other high-stakes AI applications where reliability and auditability are non-negotiable, take a look at <a href="https://www.lycore.com/ai-for-industries/ai-for-trading/">how Lycore approaches AI for trading and financial services</a>. The same production discipline described in this post applies — with even stricter requirements around audit trails and risk controls.</p>
<hr />
<p><a href="https://www.lycore.com/ai-development-services/"><em>Lycore builds production AI systems</em></a> <em>for businesses — LLM integrations, RAG pipelines, agents, and custom AI applications on Django, React, Flutter, and .NET.</em> <a href="https://www.lycore.com/contact-us/"><em>Get in touch</em></a> <em>if you want to talk through your use case.</em></p>
]]></content:encoded></item><item><title><![CDATA[Cutting Our LLM API Costs by 60%: The Optimisations That Actually Worked
]]></title><description><![CDATA[When you first integrate an LLM into a product, cost is not the priority. Getting it working is. Then you ship it, usage grows, and the OpenAI invoice…]]></description><link>https://aidevpro.hashnode.dev/cutting-our-llm-api-costs-by-60-the-optimisations-that-actually-worked</link><guid isPermaLink="true">https://aidevpro.hashnode.dev/cutting-our-llm-api-costs-by-60-the-optimisations-that-actually-worked</guid><dc:creator><![CDATA[AI Developer]]></dc:creator><pubDate>Fri, 17 Apr 2026 05:30:00 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/69dfa7eb70e165e0d8bf3a3c/5a60e95c-d4b8-4906-a78f-7b7307a3c51f.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<hr />
<p>When you first integrate an LLM into a product, cost is not the priority. Getting it working is. Then you ship it, usage grows, and the OpenAI invoice arrives. Suddenly cost matters a lot.</p>
<p>We have been through this cycle enough times to know where the money goes and how to stop it leaking. This post covers the specific optimisations that cut our clients' LLM API costs significantly — with the techniques ranked by impact.</p>
<hr />
<h2>Where the money actually goes</h2>
<p>Before optimising, you need to understand the cost model. Most LLM providers charge per token — input tokens and output tokens, usually at different rates. GPT-4o (as of early 2026) charges roughly 4–5x more for output tokens than input tokens depending on the tier.</p>
<p>This means:</p>
<ul>
<li><p>Sending large prompts costs less than generating large responses</p>
</li>
<li><p>Verbose system prompts are cheaper than verbose completions</p>
</li>
<li><p>Asking the model to "think step by step" in the output is expensive — you are paying for every reasoning token</p>
</li>
</ul>
<p>With that in mind, here is what we changed.</p>
<hr />
<h2>1. Model routing — highest impact</h2>
<p>The single biggest lever. Not every task needs GPT-4o.</p>
<p>We classify LLM tasks into three tiers:</p>
<p><strong>Tier 1 — Simple extraction and classification</strong> Single-label classification, yes/no decisions, structured data extraction from short documents, sentiment analysis. These tasks do not require reasoning depth. <code>gpt-4o-mini</code> handles them at 95%+ of GPT-4o quality for about 15x less cost.</p>
<p><strong>Tier 2 — Standard generation</strong> Summarisation, drafting, moderate-complexity Q&amp;A, RAG responses. <code>gpt-4o</code> is appropriate here when quality matters, but <code>gpt-4o-mini</code> is worth testing first — it often performs comparably on well-scoped tasks.</p>
<p><strong>Tier 3 — Complex reasoning</strong> Multi-step agent decisions, novel problem solving, tasks where accuracy has high business cost (legal, financial, medical contexts). Use the best available model. Do not try to save money here.</p>
<p>Here is the routing logic we use:</p>
<pre><code class="language-python"># services/ai/model_router.py
from enum import Enum
from dataclasses import dataclass

class TaskComplexity(Enum):
    SIMPLE = "simple"       # Classification, extraction, yes/no
    STANDARD = "standard"   # Summarisation, generation, RAG
    COMPLEX = "complex"     # Agents, reasoning, high-stakes decisions

@dataclass
class ModelConfig:
    model: str
    max_tokens: int
    temperature: float

MODEL_CONFIGS = {
    TaskComplexity.SIMPLE: ModelConfig(
        model="gpt-4o-mini",
        max_tokens=100,
        temperature=0.0
    ),
    TaskComplexity.STANDARD: ModelConfig(
        model="gpt-4o",
        max_tokens=800,
        temperature=0.2
    ),
    TaskComplexity.COMPLEX: ModelConfig(
        model="gpt-4o",
        max_tokens=2000,
        temperature=0.2
    ),
}

def get_model_config(complexity: TaskComplexity) -&gt; ModelConfig:
    return MODEL_CONFIGS[complexity]
</code></pre>
<p>On one e-commerce client, routing support ticket classification (Tier 1) away from GPT-4o to GPT-4o-mini reduced that feature's monthly cost by 87% with no measurable quality degradation.</p>
<hr />
<h2>2. Aggressive caching — second highest impact</h2>
<p>LLM calls are expensive. Identical or near-identical inputs should never hit the API twice.</p>
<p><strong>Exact cache</strong> — for deterministic outputs:</p>
<pre><code class="language-python"># services/ai/cache.py
import hashlib
import json
from django.core.cache import cache

def cache_key(model: str, messages: list, **kwargs) -&gt; str:
    payload = json.dumps({"model": model, "messages": messages, **kwargs},
                          sort_keys=True)
    return f"llm:{hashlib.sha256(payload.encode()).hexdigest()}"

def get_cached_response(model: str, messages: list, **kwargs):
    key = cache_key(model, messages, **kwargs)
    return cache.get(key)

def set_cached_response(model: str, messages: list, response: dict,
                         ttl: int = 3600, **kwargs):
    key = cache_key(model, messages, **kwargs)
    cache.set(key, response, timeout=ttl)
</code></pre>
<pre><code class="language-python"># In your AI module
def classify(self, subject: str, body: str) -&gt; dict:
    messages = self._build_messages(subject, body)
    cached = get_cached_response(self.MODEL, messages, temperature=0.0)
    if cached:
        return {**cached, "cached": True}

    response = self.client.chat.completions.create(
        model=self.MODEL,
        messages=messages,
        temperature=0.0,
        max_tokens=20
    )
    result = self._parse_response(response)
    set_cached_response(self.MODEL, messages, result, ttl=86400)
    return {**result, "cached": False}
</code></pre>
<p>For classification tasks with <code>temperature=0.0</code>, caching is safe — the same input will always produce the same output. On support ticket classification, we saw a 40–60% cache hit rate within 30 days of deployment because support teams ask similar questions repeatedly.</p>
<p><strong>Semantic cache</strong> — for near-identical inputs:</p>
<p>For RAG queries where users ask similar but not identical questions, exact hashing misses most cache opportunities. Semantic caching stores embeddings of previous queries and returns cached responses when a new query is sufficiently similar.</p>
<pre><code class="language-python"># services/ai/semantic_cache.py
from pgvector.django import CosineDistance
from .embeddings import get_embedding
from ..models import CachedLLMResponse

SIMILARITY_THRESHOLD = 0.05  # Cosine distance — lower = more similar

def get_semantic_cache(query: str):
    query_embedding = get_embedding(query)
    match = (
        CachedLLMResponse.objects
        .annotate(distance=CosineDistance('query_embedding', query_embedding))
        .filter(distance__lt=SIMILARITY_THRESHOLD)
        .order_by('distance')
        .first()
    )
    return match.response if match else None

def set_semantic_cache(query: str, response: dict):
    CachedLLMResponse.objects.create(
        query=query,
        query_embedding=get_embedding(query),
        response=response
    )
</code></pre>
<p>On a document Q&amp;A feature, semantic caching reduced API calls by a further 30% on top of the exact cache.</p>
<hr />
<h2>3. Prompt compression — steady ongoing savings</h2>
<p>System prompts accumulate. A prompt that started as three sentences grows to fifteen as edge cases are handled, examples are added, and requirements evolve. Every token in that prompt is charged on every single API call.</p>
<p>We audit system prompts quarterly and apply the following:</p>
<p><strong>Remove redundant instructions.</strong> "Please", "thank you", and elaborately polite phrasing add tokens without improving outputs. "Return only the category name, lowercase." is better than "Could you please provide just the category name in lowercase letters? Thank you."</p>
<p><strong>Remove examples that are no longer needed.</strong> Few-shot examples are expensive — typically 50–200 tokens each. Once a model is performing well on a task, test whether the examples are still necessary. Often they are not.</p>
<p><strong>Use structured output instead of prose instructions.</strong> Telling the model to return JSON removes the need for lengthy instructions about response format.</p>
<p>Before (148 tokens):</p>
<pre><code class="language-plaintext">You are a helpful customer support assistant. Your job is to classify 
incoming support tickets into one of the following categories: billing 
issues, technical problems, account management, feature requests, or 
general enquiries. Please read the ticket carefully and select the most 
appropriate category. Return only the category name. Be as accurate as 
possible. If you are unsure, select general enquiries.
</code></pre>
<p>After (41 tokens):</p>
<pre><code class="language-plaintext">Classify the ticket into one category: billing, technical, account, 
feature_request, general. Return only the category name.
</code></pre>
<p>Same output quality. 72% fewer tokens per call.</p>
<hr />
<h2>4. Output token control</h2>
<p>Most LLM calls do not need long responses. Setting <code>max_tokens</code> appropriately prevents the model from generating verbose output you are not going to use.</p>
<pre><code class="language-python"># Bad — allows up to 4096 tokens for a single-word classification
response = client.chat.completions.create(
    model="gpt-4o-mini",
    messages=messages
)

# Good — classify tasks need at most 10-20 tokens
response = client.chat.completions.create(
    model="gpt-4o-mini",
    messages=messages,
    max_tokens=20
)
</code></pre>
<p>For structured JSON outputs, estimate the maximum token count of the JSON you expect and set <code>max_tokens</code> to 120–150% of that. For summarisation tasks, match <code>max_tokens</code> to your actual display limit — if you show summaries in a 300-word UI component, there is no value in generating 600-word summaries.</p>
<hr />
<h2>5. Batching async requests</h2>
<p>For non-real-time workloads — nightly document processing, batch classification, scheduled report generation — the OpenAI Batch API offers 50% cost reduction with a 24-hour turnaround window.</p>
<pre><code class="language-python"># services/ai/batch_processor.py
import json
from openai import OpenAI
from django.conf import settings

client = OpenAI(api_key=settings.OPENAI_API_KEY)

def submit_batch_classification(tickets: list[dict]) -&gt; str:
    """
    Submit a batch of tickets for classification.
    Returns a batch_id to poll for results.
    """
    requests = []
    for ticket in tickets:
        requests.append({
            "custom_id": str(ticket["id"]),
            "method": "POST",
            "url": "/v1/chat/completions",
            "body": {
                "model": "gpt-4o-mini",
                "messages": [
                    {"role": "system", "content": "Classify the ticket: billing, technical, account, feature_request, general. Return only the category."},
                    {"role": "user", "content": f"{ticket['subject']}\n\n{ticket['body'][:500]}"}
                ],
                "max_tokens": 20,
                "temperature": 0.0
            }
        })

    # Write to JSONL
    jsonl_content = "\n".join(json.dumps(r) for r in requests)
    batch_file = client.files.create(
        file=("batch.jsonl", jsonl_content.encode(), "application/jsonl"),
        purpose="batch"
    )

    batch = client.batches.create(
        input_file_id=batch_file.id,
        endpoint="/v1/chat/completions",
        completion_window="24h"
    )

    return batch.id


def retrieve_batch_results(batch_id: str) -&gt; list[dict]:
    batch = client.batches.retrieve(batch_id)
    if batch.status != "completed":
        return []

    content = client.files.content(batch.output_file_id).text
    results = []
    for line in content.strip().split("\n"):
        item = json.loads(line)
        results.append({
            "ticket_id": item["custom_id"],
            "category": item["response"]["body"]["choices"][0]["message"]["content"].strip()
        })
    return results
</code></pre>
<p>For a client processing 10,000 support tickets per day, switching nightly batch classification to the Batch API saved $180/month on that feature alone.</p>
<hr />
<h2>6. Monitoring cost per feature</h2>
<p>None of the above optimisations are durable without visibility into what each feature is spending. We add cost tracking to every AI call:</p>
<pre><code class="language-python"># Approximate cost calculation (update rates periodically)
GPT4O_INPUT_COST_PER_1K = 0.0025
GPT4O_OUTPUT_COST_PER_1K = 0.010
GPT4O_MINI_INPUT_COST_PER_1K = 0.00015
GPT4O_MINI_OUTPUT_COST_PER_1K = 0.0006

COST_TABLE = {
    "gpt-4o": (GPT4O_INPUT_COST_PER_1K, GPT4O_OUTPUT_COST_PER_1K),
    "gpt-4o-mini": (GPT4O_MINI_INPUT_COST_PER_1K, GPT4O_MINI_OUTPUT_COST_PER_1K),
}

def calculate_cost(model: str, input_tokens: int, output_tokens: int) -&gt; float:
    input_rate, output_rate = COST_TABLE.get(model, (0, 0))
    return (input_tokens / 1000 * input_rate) + (output_tokens / 1000 * output_rate)

def log_ai_call(feature: str, model: str, usage, cached: bool = False):
    cost = 0.0 if cached else calculate_cost(
        model, usage.prompt_tokens, usage.completion_tokens
    )
    logger.info("ai_call", extra={
        "feature": feature,
        "model": model,
        "input_tokens": usage.prompt_tokens,
        "output_tokens": usage.completion_tokens,
        "estimated_cost_usd": cost,
        "cached": cached
    })
</code></pre>
<p>With structured logs flowing into your monitoring stack, you can build a dashboard showing cost per feature per day. This makes it immediately visible when a new feature is unexpectedly expensive, when cache hit rates drop, or when a prompt change caused token count to spike.</p>
<hr />
<h2>The numbers in practice</h2>
<p>On a SaaS client with four AI-powered features (ticket classification, document summarisation, a RAG-based search, and an onboarding chatbot), applying all of the above over a two-month period:</p>
<table>
<thead>
<tr>
<th>Optimisation</th>
<th>Cost reduction</th>
</tr>
</thead>
<tbody><tr>
<td>Model routing (mini for classification)</td>
<td>-42%</td>
</tr>
<tr>
<td>Exact + semantic caching</td>
<td>-31%</td>
</tr>
<tr>
<td>Prompt compression</td>
<td>-14%</td>
</tr>
<tr>
<td>Output token limits</td>
<td>-8%</td>
</tr>
<tr>
<td>Batch API for nightly jobs</td>
<td>-11%</td>
</tr>
</tbody></table>
<p>These overlap — total reduction was approximately 61% of baseline monthly spend, not the sum of the above. The model routing and caching changes delivered most of the savings. Prompt compression and token limits are lower effort and worth doing, but do not expect them to move the needle as dramatically.</p>
<hr />
<h2>What not to do</h2>
<p><strong>Do not sacrifice quality to save tokens on high-stakes tasks.</strong> If a classification error costs a customer service team 20 minutes of rework, the cost of that error vastly exceeds any token savings from using a cheaper model.</p>
<p><strong>Do not cache with long TTLs on tasks where freshness matters.</strong> A cached RAG response from six months ago may be confidently wrong about something that changed. Match TTL to the staleness tolerance of the use case.</p>
<p><strong>Do not optimise before you measure.</strong> Know your baseline cost per feature before you start. Otherwise you cannot quantify what worked and you will not notice if a change made things worse.</p>
<hr />
<p><a href="https://www.lycore.com/ai-development-services/"><em>Lycore builds production AI systems</em></a> <em>for businesses — LLM integrations, RAG pipelines, agents, and custom AI applications on Django, React, Flutter, and .NET.</em> <a href="https://www.lycore.com/contact-us/"><em>Get in touch</em></a> <em>if you want to talk through your use case.</em></p>
]]></content:encoded></item><item><title><![CDATA[How We Structure Django Projects for AI Features: Folder Layout, Service Layer, and Keeping LLM Logic Out of Views]]></title><description><![CDATA[Adding AI to a Django app is easy. Adding it in a way that doesn't turn into a maintenance nightmare six months later is harder. We have integrated LLMs…]]></description><link>https://aidevpro.hashnode.dev/how-we-structure-django-projects-for-ai-features-folder-layout-service-layer-and-keeping-llm-logic-out-of-views</link><guid isPermaLink="true">https://aidevpro.hashnode.dev/how-we-structure-django-projects-for-ai-features-folder-layout-service-layer-and-keeping-llm-logic-out-of-views</guid><dc:creator><![CDATA[AI Developer]]></dc:creator><pubDate>Thu, 16 Apr 2026 07:58:17 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/69dfa7eb70e165e0d8bf3a3c/0a27b97f-a89c-462b-9fb3-a9c0718d1d6a.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<hr />
<p>Adding AI to a Django app is easy. Adding it in a way that doesn't turn into a maintenance nightmare six months later is harder.</p>
<p>We have integrated LLMs, RAG pipelines, and AI agents into a range of Django applications over the past year and a half. Early on, we made the same mistakes teams typically make: AI calls scattered across views, prompts hardcoded as strings in random places, no clear boundary between AI logic and application logic. The codebase worked. It was not pleasant to work in.</p>
<p>This post is about the structure we have converged on — how we organise Django projects that include AI features, where each piece lives, and why.</p>
<hr />
<h2>The problem with putting AI in views</h2>
<p>The fastest way to add an AI feature to a Django app is to drop an OpenAI call directly into a view:</p>
<pre><code class="language-python"># views.py — the fast way, not the right way
from openai import OpenAI
from django.http import JsonResponse

client = OpenAI()

def summarise_document(request, doc_id):
    doc = Document.objects.get(id=doc_id)
    response = client.chat.completions.create(
        model="gpt-4o",
        messages=[
            {"role": "system", "content": "Summarise this document concisely."},
            {"role": "user", "content": doc.content}
        ]
    )
    return JsonResponse({"summary": response.choices[0].message.content})
</code></pre>
<p>This works fine until:</p>
<ul>
<li><p>You need to call the same AI logic from a Celery task</p>
</li>
<li><p>You want to write a test without hitting the OpenAI API</p>
</li>
<li><p>You need to swap GPT-4o for Claude on one feature</p>
</li>
<li><p>You want to log every AI call with input/output for debugging</p>
</li>
<li><p>Your prompt needs to evolve independently of your request handling</p>
</li>
</ul>
<p>None of these are hypothetical — all of them come up in production within the first few months. The view has become a load-bearing wall for logic that should not live there.</p>
<hr />
<h2>The structure we use</h2>
<p>Here is the folder layout for a Django app that includes AI features:</p>
<pre><code class="language-plaintext">myapp/
├── models.py
├── views.py
├── urls.py
├── serializers.py
├── tasks.py                  # Celery async tasks
├── services/
│   ├── __init__.py
│   ├── document_service.py   # Business logic
│   └── ai/
│       ├── __init__.py
│       ├── client.py         # LLM client setup
│       ├── prompts.py        # Prompt templates
│       ├── summariser.py     # Summarisation feature
│       ├── classifier.py     # Classification feature
│       └── rag.py            # RAG pipeline
└── tests/
    ├── test_views.py
    ├── test_services.py
    └── test_ai/
        ├── test_summariser.py
        └── test_classifier.py
</code></pre>
<p>The key principle is that <code>services/ai/</code> is a self-contained module. Views call services. Services call AI modules. AI modules talk to LLM providers. Nothing leaks sideways.</p>
<hr />
<h2>The client layer</h2>
<p>A single place to configure and access LLM clients:</p>
<pre><code class="language-python"># services/ai/client.py
from openai import OpenAI
from anthropic import Anthropic
from django.conf import settings
import logging

logger = logging.getLogger(__name__)

_openai_client = None
_anthropic_client = None

def get_openai_client() -&gt; OpenAI:
    global _openai_client
    if _openai_client is None:
        _openai_client = OpenAI(api_key=settings.OPENAI_API_KEY)
    return _openai_client

def get_anthropic_client() -&gt; Anthropic:
    global _anthropic_client
    if _anthropic_client is None:
        _anthropic_client = Anthropic(api_key=settings.ANTHROPIC_API_KEY)
    return _anthropic_client
</code></pre>
<p>Lazy singletons mean the client is only instantiated when first used, not at import time. This matters for tests — you can patch <code>get_openai_client</code> at the module boundary without the client being created during import.</p>
<hr />
<h2>The prompts layer</h2>
<p>Prompts are first-class citizens, not inline strings:</p>
<pre><code class="language-python"># services/ai/prompts.py
from dataclasses import dataclass
from typing import Optional

@dataclass
class PromptTemplate:
    system: str
    user_template: str

    def render_user(self, **kwargs) -&gt; str:
        return self.user_template.format(**kwargs)


DOCUMENT_SUMMARISER = PromptTemplate(
    system="""You are a precise document summariser. Produce a concise summary
of the key points in the document provided. Use plain language.
Return only the summary — no preamble, no meta-commentary.""",
    user_template="Document title: {title}\n\nContent:\n{content}"
)

SUPPORT_TICKET_CLASSIFIER = PromptTemplate(
    system="""Classify the support ticket into exactly one of these categories:
billing, technical, account, feature_request, other.
Respond with only the category name, lowercase.""",
    user_template="Ticket subject: {subject}\n\nTicket body:\n{body}"
)

CONTRACT_RISK_ANALYSER = PromptTemplate(
    system="""You are a contract risk analyst. Review the contract excerpt and
identify any clauses that represent unusual risk. For each risk found, return:
- clause_reference: the section or clause number
- risk_level: low / medium / high
- description: plain language explanation of the risk

Return a JSON array. If no risks are found, return an empty array [].""",
    user_template="Contract excerpt:\n\n{content}"
)
</code></pre>
<p>Treating prompts as structured objects — not bare strings — makes them easier to version, test, and review. When a prompt needs to change, the change is in one place and the diff is clean.</p>
<hr />
<h2>The feature modules</h2>
<p>Each AI capability gets its own module. Here is the summariser:</p>
<pre><code class="language-python"># services/ai/summariser.py
import logging
from .client import get_openai_client
from .prompts import DOCUMENT_SUMMARISER

logger = logging.getLogger(__name__)

class DocumentSummariser:
    MODEL = "gpt-4o"
    MAX_TOKENS = 500
    TEMPERATURE = 0.2

    def __init__(self):
        self.client = get_openai_client()

    def summarise(self, title: str, content: str) -&gt; dict:
        if not content.strip():
            raise ValueError("Cannot summarise empty content")

        truncated = content[:12000]  # ~3000 tokens, leave room for response
        user_message = DOCUMENT_SUMMARISER.render_user(
            title=title,
            content=truncated
        )

        logger.info(
            "AI summarise request",
            extra={"title": title, "content_length": len(content)}
        )

        response = self.client.chat.completions.create(
            model=self.MODEL,
            messages=[
                {"role": "system", "content": DOCUMENT_SUMMARISER.system},
                {"role": "user", "content": user_message}
            ],
            max_tokens=self.MAX_TOKENS,
            temperature=self.TEMPERATURE
        )

        summary = response.choices[0].message.content.strip()
        tokens_used = response.usage.total_tokens

        logger.info(
            "AI summarise complete",
            extra={"title": title, "tokens_used": tokens_used}
        )

        return {
            "summary": summary,
            "tokens_used": tokens_used,
            "model": self.MODEL,
            "truncated": len(content) &gt; 12000
        }
</code></pre>
<p>And the classifier:</p>
<pre><code class="language-python"># services/ai/classifier.py
import logging
from .client import get_openai_client
from .prompts import SUPPORT_TICKET_CLASSIFIER

logger = logging.getLogger(__name__)

VALID_CATEGORIES = {"billing", "technical", "account", "feature_request", "other"}

class TicketClassifier:
    MODEL = "gpt-4o-mini"  # Simpler task, cheaper model
    TEMPERATURE = 0.0       # Zero temp for classification — we want determinism

    def __init__(self):
        self.client = get_openai_client()

    def classify(self, subject: str, body: str) -&gt; dict:
        user_message = SUPPORT_TICKET_CLASSIFIER.render_user(
            subject=subject,
            body=body[:3000]
        )

        response = self.client.chat.completions.create(
            model=self.MODEL,
            messages=[
                {"role": "system", "content": SUPPORT_TICKET_CLASSIFIER.system},
                {"role": "user", "content": user_message}
            ],
            max_tokens=20,
            temperature=self.TEMPERATURE
        )

        raw = response.choices[0].message.content.strip().lower()
        category = raw if raw in VALID_CATEGORIES else "other"

        if raw not in VALID_CATEGORIES:
            logger.warning(
                "Unexpected classifier output",
                extra={"raw_output": raw, "subject": subject}
            )

        return {
            "category": category,
            "raw_output": raw,
            "confident": raw in VALID_CATEGORIES,
            "model": self.MODEL
        }
</code></pre>
<p>Two things worth noting here: <code>TicketClassifier</code> uses <code>gpt-4o-mini</code> instead of <code>gpt-4o</code>. Classification is a simple task — using the full model wastes money and adds latency. Choose the model per task, not per project. Also, temperature is 0.0 for classification — we want the most deterministic output the model will give, not creative variation.</p>
<hr />
<h2>The service layer</h2>
<p>Business logic lives here. AI modules are called here, not in views:</p>
<pre><code class="language-python"># services/document_service.py
from ..models import Document, DocumentSummary
from .ai.summariser import DocumentSummariser
import logging

logger = logging.getLogger(__name__)

_summariser = None

def get_summariser() -&gt; DocumentSummariser:
    global _summariser
    if _summariser is None:
        _summariser = DocumentSummariser()
    return _summariser


def summarise_document(document_id: int) -&gt; DocumentSummary:
    doc = Document.objects.select_related('owner').get(id=document_id)

    # Check if we already have a fresh summary
    existing = DocumentSummary.objects.filter(
        document=doc,
        model=DocumentSummariser.MODEL
    ).order_by('-created_at').first()

    if existing and not doc.content_changed_since(existing.created_at):
        logger.info(f"Returning cached summary for document {document_id}")
        return existing

    # Generate new summary
    result = get_summariser().summarise(
        title=doc.title,
        content=doc.content
    )

    summary = DocumentSummary.objects.create(
        document=doc,
        summary=result["summary"],
        model=result["model"],
        tokens_used=result["tokens_used"],
        was_truncated=result["truncated"]
    )

    return summary
</code></pre>
<p>The service layer owns caching logic, model selection fallbacks, and the boundary between AI output and Django models. Views and tasks call this — they do not touch AI modules directly.</p>
<hr />
<h2>The view</h2>
<p>Clean:</p>
<pre><code class="language-python"># views.py
from django.http import JsonResponse
from django.views.decorators.http import require_POST
from .services import document_service
import logging

logger = logging.getLogger(__name__)

@require_POST
def summarise_document_view(request, doc_id):
    try:
        summary = document_service.summarise_document(doc_id)
        return JsonResponse({
            "summary": summary.summary,
            "model": summary.model,
            "cached": False
        })
    except Document.DoesNotExist:
        return JsonResponse({"error": "Document not found"}, status=404)
    except Exception as e:
        logger.error(f"Summarise failed for doc {doc_id}: {e}")
        return JsonResponse({"error": "Summarisation failed"}, status=500)
</code></pre>
<p>The view has no knowledge of OpenAI, prompts, or token counts. It handles HTTP concerns and delegates everything else.</p>
<hr />
<h2>Testing without hitting the API</h2>
<p>Because AI logic is isolated in modules with injected clients, mocking is clean:</p>
<pre><code class="language-python"># tests/test_ai/test_summariser.py
from unittest.mock import MagicMock, patch
from myapp.services.ai.summariser import DocumentSummariser

def test_summarise_returns_expected_structure():
    mock_response = MagicMock()
    mock_response.choices[0].message.content = "This is a test summary."
    mock_response.usage.total_tokens = 150

    with patch("myapp.services.ai.summariser.get_openai_client") as mock_client_fn:
        mock_client = MagicMock()
        mock_client.chat.completions.create.return_value = mock_response
        mock_client_fn.return_value = mock_client

        summariser = DocumentSummariser()
        result = summariser.summarise(title="Test Doc", content="Some content here.")

    assert result["summary"] == "This is a test summary."
    assert result["tokens_used"] == 150
    assert result["truncated"] is False


def test_summarise_raises_on_empty_content():
    summariser = DocumentSummariser()
    with pytest.raises(ValueError, match="empty content"):
        summariser.summarise(title="Test", content="   ")
</code></pre>
<p>No API calls, no credentials needed in CI. The mock patches at the client boundary, so the prompt rendering, truncation logic, and response parsing all get tested for real.</p>
<hr />
<h2>A few rules we follow</h2>
<p><strong>One AI module per feature, not per model.</strong> <code>summariser.py</code> is about summarisation. If you ever swap from GPT-4o to Claude for summarisation, the change is contained to one file. If you named the file <code>openai_summariser.py</code>, you have already made the architecture brittle.</p>
<p><strong>Log inputs and outputs at INFO level.</strong> AI system behaviour drifts. Without logs, you cannot diagnose why a summary or classification started producing unexpected results. We log the content length in, the token count out, and the model used on every call.</p>
<p><strong>Store the model name alongside every AI output.</strong> When GPT-4o gets updated or you change models, you need to know which outputs were generated by which version. <code>tokens_used</code> and <code>model</code> fields on your AI-derived models are not optional.</p>
<p><strong>Never interpolate user input directly into system prompts.</strong> User content goes in the <code>user</code> message role only. System prompts are controlled strings that define behaviour — mixing user input into them opens injection risks.</p>
<hr />
<h2>What this gets you</h2>
<p>A codebase where:</p>
<ul>
<li><p>AI features are testable without API calls</p>
</li>
<li><p>Prompts are versioned and reviewable as first-class code</p>
</li>
<li><p>Swapping providers or models is a one-file change</p>
</li>
<li><p>Logging and observability is consistent across all AI features</p>
</li>
<li><p>Views stay thin and focused on HTTP</p>
</li>
</ul>
<p>The structure adds maybe thirty minutes of setup at the start of a project. It saves hours of untangling per month once the project is in production.</p>
<hr />
<p><a href="https://www.lycore.com/ai-development-services/"><em>Lycore builds production AI systems</em></a> <em>for businesses — LLM integrations, RAG pipelines, agents, and custom AI applications on Django, React, Flutter, and .NET.</em> <a href="https://www.lycore.com/contact-us/"><em>Get in touch</em></a> <em>if you want to talk through your use case.</em></p>
]]></content:encoded></item></channel></rss>