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.