Inside Sheria: A Tour of the Codebase, From Scraper to Response

The last two posts here were about specific pieces of Sheria: the agentic RAG pipeline and citation graph, then the semantic cache sitting in front of them. This one zooms out — a tour of the actual repository, module by module, from the scraper that pulls in case law to the two different ways the platform gets deployed.

The shape of the repo

The codebase splits into a handful of top-level directories, each with one job:

sheria_platform_mvp/
├── services/          services/api — the one real backend service: FastAPI
│                       orchestrating the LangGraph agents
├── pipelines/          pipelines/ingestion — Ray Data ETL: load, chunk,
│                       embed, and graph-extract raw documents
├── data_scrapper/      standalone, independently-dockerized crawler that
│                       feeds pipelines/ its raw material
├── libs/               shared Python: observability, retry/backoff,
│                       schemas, ID generation — used by services/api and
│                       pipelines/ingestion
├── deploy/             Docker Compose topology, including the dedicated
│                       multi-node Ollama cluster
├── user_interface/     the Next.js frontend
├── docs/               architecture, API, and data-model documentation
└── tests/              unit/ and integration/ pytest suites

Data lineage: from scraper to searchable case law

Before any of the retrieval machinery from the first two posts can run, the case law has to get in. data_scrapper/ is a standalone crawler — its own Dockerfile, its own docker-compose.yml — built around a KenyaLawCrawler that hits kenyalaw.org in either court mode (paging through a named court's judgments) or search mode (keyword queries), with a de-dup registry so it doesn't re-scrape URLs it's already pulled. Scraped documents land in MinIO.

From there, pipelines/ingestion picks them up — a pipeline that loads documents (PDF, DOCX, HTML, or plain text, dispatched by file type), splits them into 512-token chunks with 50-token overlap, then does two things with each chunk: embeds it with Ollama's nomic-embed-text, and runs an LLM extraction pass that pulls out entities and relationships for the citation graph.

KenyaLawCrawler (kenyalaw.org)
        │
        ▼
      MinIO
        │
        ▼
load → chunk (512 tok / 50 overlap)
        │
        ├──▶ embed (nomic-embed-text)   ──▶ Qdrant `kenya_law_reports`
        │
        └──▶ graph extraction (LLM)     ──▶ Neo4j (Case, Judge, LegalPrinciple;
                                              CITES, OVERRULES edges)

This is the piece neither of the first two posts covered directly — it's where the citation graph from the first post and the retrieval collection from the second actually come from.

Inside the API: three graphs, one shared state

services/api is built on LangGraph, and there isn't just one graph — there are three, all built on the same AgentState TypedDict (messages, documents, current query, the reused query vector, jurisdiction filter, citations, and a couple of routing fields).

The main chat graph is a StateGraph with conditional branching after the planner: it reads whatever action the planner decided on — direct_answer, tool_use, or retrieve — and routes to the responder, the tool node, or the retriever accordingly, with everything funneling back through the responder before the graph ends.

# services/api/app/agents/graph.py
workflow = StateGraph(AgentState)

workflow.add_node("planner", planner_node)
workflow.add_node("retriever", retrieve_node)
workflow.add_node("tool", tool_node)
workflow.add_node("responder", generate_node)

workflow.add_edge(START, "planner")
workflow.add_conditional_edges(
    "planner",
    _route_after_planner,
    {"retriever": "retriever", "tool": "tool", "responder": "responder"},
)
workflow.add_edge("retriever", "responder")
workflow.add_edge("tool", "responder")
workflow.add_edge("responder", END)

agent_app = workflow.compile()

The legal-research graph is a deliberately narrower StateGraph: it skips the planner entirely — retriever straight to responder — so a legal-research query can never get short-circuited into a direct answer without being grounded in retrieved case law. That's a product decision showing up as a structural one: two different graphs for two different tolerances for the model answering from its own head.

Main chat graph:
[ query ] ──▶ ( planner ) ──▶ ( retriever | tool ) ──▶ ( responder ) ──▶ [ answer ]

Legal-research graph:
[ query ] ─────────────────▶ ( retriever ) ─────────▶ ( responder ) ──▶ [ answer ]

The tool node uses a registry pattern: a dict mapping tool names to callables (calculator, graph search, case-duration prediction, document verification, web search, workload management), so adding a seventh tool means adding one entry, not touching the dispatch logic.

Everything around the graph

Around the graphs sits fairly thin API scaffolding: route modules for chat, legal-research, auth, uploads, verification, prediction, history, and health. Auth is JWT-based with a role-permission matrix (judge, magistrate, registrar, clerk, admin, each mapped to which features they can hit) and a Redis-backed blacklist, so a suspended user is rejected immediately rather than waiting out an 8-hour token expiry. Thin async client wrappers sit in front of Neo4j, Qdrant, and Ollama. The semantic cache lives here too — exactly as described in the last post, so I won't re-explain it.

The frontend's proxy pattern

user_interface/ is a Next.js App Router app. The interesting structural choice is that the browser never calls the FastAPI backend directly — every request goes through a Next.js API route first, which then calls the real backend server-side. That trades a little indirection for keeping auth as an httpOnly cookie instead of a token the browser's JS has to hold, and it sidesteps CORS configuration between two origins entirely.

[ Browser ] ──(httpOnly cookie)──▶ [ Next.js API route ] ──(JWT header)──▶ [ FastAPI ]

Deployment: two different Ollama topologies

There are two separate ways this runs. The root docker-compose.yml is the single-machine dev stack — Postgres, Redis, Qdrant, Neo4j, MinIO, one Ollama instance, the API, and a couple of dev conveniences.

Dev stack (docker-compose.yml):
[ API + Postgres + Qdrant + Neo4j + MinIO ] ──▶ [ single Ollama instance ]

deploy/local_dev/ cluster:
                        ┌──▶ [ LLM node 1 ]
                        ├──▶ [ LLM node 2 ]   (round-robin)
[ nginx proxy ] ────────┼──▶ [ LLM node 3 ]
                        ├──▶ [ embed node ]
                        └──▶ [ rerank node ]

The actual multi-node setup — the one the caching post's "3-node Ollama cluster" line refers to — lives in deploy/local_dev/. It's five Ollama roles, each with a CPU and GPU profile variant: three LLM nodes load-balanced round-robin (the graph-extraction step in ingestion fires parallel LLM and embed calls that would otherwise queue on a single node), one dedicated embedding node, and one dedicated reranking node, all behind an nginx proxy. A model-puller container blocks startup until every node's /api/tags endpoint actually reports its models loaded rather than just the HTTP server being up — the setup notes that a model like qwen3:8b (~5GB) can take over 20 minutes to pull on a slow link, so the default wait budget is 40 minutes before it gives up.

Where this leaves things

Three posts in, the shape of the whole system is on the record: how a query gets answered, how repeated queries get answered cheaply, and now, where all of it actually lives and how the case law gets in in the first place. What's still open from the earlier posts — cache invalidation tied to citation-graph updates, wiring the prediction graph to a real route — is probably where the next one goes.