DEV Community

Lenard Francis
Lenard Francis

Posted on

# I Built a Legal OS for a Zimbabwean Law Firm — Here's What AI-Assisted Legal Research Actually Looks Like

My wife is a lawyer and partner at a law firm in Harare, Zimbabwe. She has been practising for over two decades, juggles her caseload with speaking engagements, and runs legal education programs on two radio stations three days a week. She was tracking 118 active matters in a diary and a notepad.

That's not a criticism — she's been practising for over a decade and the system worked. But when I started building with AI tools, I kept thinking: what happens when she's sick? What happens when a junior associate needs to know the deadline on a matter she's handling? What happens when someone needs to research a legal question at 10pm before a morning hearing?

So I built MutemoOS. Here's what I learned.


The problem with generic legal tech

Most legal research tools are built for BigLaw in London or New York. They're expensive, bandwidth-heavy, and tuned for English common law or US federal jurisdiction. They don't know what a dies induciae is. They've never heard of the Labour Court of Zimbabwe.

The interesting engineering problems only show up when you're building for a specific jurisdiction:

  • Case law is scattered across ZimLII, Veritas, ZLHR, and Law Reports of Zimbabwe
  • Statutes get amended without warning — the Labour Amendment Act 2023 changed everything
  • Lawyers use colloquial terms that don't appear in legislation ("small houses", "lobola") but map to specific statutory provisions
  • Court deadlines are calculated in working days, excluding Zimbabwe's specific public holidays, and the High Court recess suspends dies induciae only for Heads of Argument — not for other steps

If you're building a legal AI for Zimbabwe, you can't just use a generic RAG pipeline and call it done.


Architecture overview

MutemoOS is a FastAPI backend with a React-style HTML frontend (no framework, just vanilla JS), deployed on Railway. The core components:

PostgreSQL          — matters, documents, chunks, calendar events
ChromaDB            — vector store for semantic search (firm collection)
Laws.Africa KB API  — live Zimbabwe legislation + judgments
sentence-transformers — all-MiniLM-L6-v2 embeddings
Anthropic Claude    — synthesis, grounding, document drafting
Cloudflare R2       — document storage
Enter fullscreen mode Exit fullscreen mode

The Legal Intelligence Feed is a separate FastAPI service that scrapes ZimLII, Veritas, ZLHR, and NewsDay daily and pushes content to MutemoOS via a multi-instance pusher — built for multiple law firm clients.


The grounded synthesis problem

The first version of search was straightforward RAG: embed query → retrieve chunks → synthesise answer. It worked well when the right content was indexed. But it failed in two important ways:

1. Hallucinated citations. The model would confidently cite "section 12(3) of the Labour Act" when the retrieved chunks didn't actually contain that section. Lawyers can't use hallucinated citations.

2. Silent failures. When no relevant content was indexed, the model would fall back to general knowledge and present it as if it came from the sources. A lawyer reading the output had no way to know which claims were grounded and which weren't.

The fix was a two-stage synthesis pipeline:

# Stage 1 — Ground check (Claude Haiku, fast and cheap)
grounding = ground_check_sync(query, context)
# Returns: sources_sufficient, source_gap, grounding_note

# Stage 2 — Constrained synthesis (Claude Sonnet)
# If sources insufficient, Sonnet is explicitly told what's missing
# and instructed to prefix unsupported claims with:
# "[General principle — verify in source]"
answer = synthesise_answer_sync(query, context, grounding)
Enter fullscreen mode Exit fullscreen mode

The frontend renders a ✓ green badge when sources are sufficient, and a ⚠ yellow badge with a specific gap description when they're not:

⚠ Partial sources — Missing: Insolvency Act, Companies Act, Labour Act 
provisions on employee rights during liquidation
Enter fullscreen mode Exit fullscreen mode

This single change transformed how lawyers interact with the system. They now know exactly which parts of an answer to verify before relying on it.


The query expansion problem

Zimbabwe legal terminology has a mismatch problem. A lawyer asks about "small houses" — a colloquial term for informal second relationships. The legislation calls them "civil partnerships" under section 41 of the Marriages Act [Chapter 5:15]. The embedding model (all-MiniLM-L6-v2) doesn't know this mapping.

The problem runs deeper with customary law. Zimbabwe operates a dual legal system — general (Roman-Dutch) law and customary law — and a significant portion of legal practice involves both simultaneously. A client walks in and says "we paid lobola but never registered the marriage." That's not just a cultural statement — it maps to a specific legal regime under the Customary Marriages Act [Chapter 5:07], with distinct inheritance rules, maintenance claims, and estate administration procedures under the Administration of Estates Act. The system needs to understand that "lobola" and "roora" and "unregistered customary union" are all entries into the same legal framework, and that the applicable law differs materially from a civil marriage registered under the Marriages Act.

The solution is a two-layer fallback:

Layer 1 — Similarity threshold filtering. ChromaDB returns results below a similarity threshold (0.35) which are likely noise. Filter them out. When ChromaDB returns nothing above threshold, the FTS fallback fires:

# FTS fallback — word overlap + exact phrase bonus
query_words = set(query_lower.split()) - STOPWORDS
for chunk in chunks:
    text_lower = chunk["text"].lower()
    word_score = len(query_words & set(text_lower.split())) / max(len(query_words), 1)
    phrase_bonus = 0.5 if any(w in text_lower for w in query_words if len(w) > 4) else 0
    score = word_score + phrase_bonus
Enter fullscreen mode Exit fullscreen mode

Layer 2 — Zimbabwe-specific query expansion. When both semantic and FTS fail, Claude Haiku expands the query with Zimbabwe legal synonyms:

QUERY_EXPANSION_PROMPT = """You are a Zimbabwean legal research assistant.
Key Zimbabwe-specific mappings:
- "civil partner" / "unmarried couple""section 41 Marriages Act Chapter 5:15 unregistered union"
- "Islamic marriage""qualified civil marriage section 44 Marriages Act polygamous union"
- "small houses""section 41 civil partnership unregistered union Marriages Act"
- "lobola" / "roora" / "customary marriage""Customary Marriages Act Chapter 5:07 unregistered union"
- "unfair dismissal""Labour Act Chapter 28:01 due inquiry section 12"
- "estate" / "deceased estate""Administration of Estates Act Chapter 6:01 Master of High Court"
...
Expand this query with Zimbabwe legal synonyms. Return ONE line only.
Query: {query}"""
Enter fullscreen mode Exit fullscreen mode

The result: searching "small houses" returns section 41 of the Marriages Act with a ✓ green badge and a complete answer about civil partnership protections under Zimbabwe law.


The dies induciae calculator

This is the feature that matters most to practitioners. Every Zimbabwe procedural step has a deadline in working days, excluding weekends, public holidays, and — for Heads of Argument only — High Court recess periods.

The High Court recess rule is specific: recess suspends the dies only for Heads of Argument. All other deadlines (NITDs, plea, discovery) run straight through recess. Building this as a hardcoded rule set:

class CourtProcedureEngine:
    FIXED_HOLIDAYS = {
        (1,1),(2,21),(4,18),(5,1),(5,25),
        (8,11),(8,12),(12,22),(12,25),(12,26)
    }

    @classmethod
    def _add_working_days(cls, start, days, recess_periods=None, suspend_in_recess=False):
        count = 0
        recess_hit = 0
        d = start + timedelta(days=1)
        while count < days:
            if cls._is_working_day(d):
                in_recess = suspend_in_recess and any(
                    r['start'] <= d <= r['end'] for r in (recess_periods or [])
                )
                if in_recess:
                    recess_hit += 1
                else:
                    count += 1
            if count < days:
                d += timedelta(days=1)
        return d, recess_hit
Enter fullscreen mode Exit fullscreen mode

The recess periods are user-supplied (the Registrar publishes the court calendar annually) rather than hardcoded — a law firm inputs the recess dates once at the start of the court year and they apply to all matters automatically.

When a lawyer selects "High Court Application" and enters a service date, the system calculates all deadlines, creates calendar events automatically, and flags critical deadlines in red:

  • Labour Court Heads of Argument: BAR RISK — failure to file = barred from oral submissions (Rule 19)
  • Magistrates Court plea: DEFAULT JUDGMENT RISK — 7 days after appearance to defend

The Laws.Africa integration

The most impactful single change was integrating the Laws.Africa Knowledge Base API. Instead of scraping Zimbabwe legislation and uploading PDFs manually, we now query their vector index directly:

async def search_laws_africa(query: str, top_k: int = 3) -> list:
    payload = {
        "text": query,
        "top_k": top_k,
        "filters": {"commenced": True, "repealed": False, "principal": True}
    }
    for kb_code in ["legislation-zw", "judgments-zw"]:
        resp = await http.post(
            f"{LAWS_AFRICA_API}/{kb_code}/retrieve",
            json=payload,
            headers={"Authorization": f"Bearer {token}"}
        )
        # Returns section-level chunks with source URL, Act title, section name
Enter fullscreen mode Exit fullscreen mode

Every search now queries three sources simultaneously: local ChromaDB (firm precedents + uploaded documents), Laws.Africa legislation-zw (all Zimbabwe Acts), and Laws.Africa judgments-zw (all Zimbabwe judgments). The grounding check runs across all three.


What surprised me

Colloquial language works. The query expansion approach means a lawyer can type exactly how they'd describe a problem to a colleague. "My client is a small house" gets the right legal framework. This was the biggest UX win — it turns out the barrier to using legal AI isn't the AI, it's the expectation that you need to speak like a statute.

Grounding is more valuable than accuracy. A system that gives you a 90% accurate answer with no indication of what it doesn't know is more dangerous than a system that gives you a 75% accurate answer with a clear ⚠ badge saying "I'm missing the Insolvency Act." Lawyers can work with uncertainty. They can't work with invisible uncertainty.

The embedding model is the ceiling. all-MiniLM-L6-v2 is a general-purpose model. It doesn't know that "qualified civil marriage" and "Islamic marriage" are the same thing in Zimbabwe law. The query expansion workaround helps, but the right long-term answer is a legal-domain embedding model. This is the next migration.

What's next

  • PostgreSQL migration for multi-tenancy (currently per-firm Railway instances)
  • Better embedding model (OpenAI text-embedding-3-small or legal-BERT)
  • LSZ Tariff integration — auto-suggest fees from the Law Society of Zimbabwe tariff
  • IECMS bridge — Zimbabwe's new electronic court filing system

The product is live. My wife uses it daily. A second firm is onboarding next month.

If you're building legal tech for Africa or any underserved legal market, I'd genuinely like to compare notes. The problems are interesting and the competition is thin.


Lenard Francis is the founder of Tofamba Technology, building MutemoOS (legal practice management) and AlertEngine (human-authorized incident recovery for FastAPI). Based in Harare, Zimbabwe. @leoofharare

Top comments (0)