LNA AI API โ Documentation
A RAG-powered book question-answering API built with FastAPI, pydantic-ai, Supabase (pgvector), and an LLM backend (DeepSeek or Ollama).
Table of Contents
- Architecture Overview
- Technology Stack
- Environment Variables
- Running the Server
- API Endpoints
- GET /health
- GET /test
- GET /books
- POST /answer
- POST /answer/stream โญ primary
- POST /stream
- SSE Streaming Protocol
- Database Schema
- Agent Tools
- LLM Provider Configuration
- Adapting to a New Project
Architecture Overview
Client
โ
โโ GET /books โ List available books from Supabase
โ
โโ POST /answer โ Full agent run (RAG + optional page fetch) โ JSON
โ
โโ POST /answer/stream โ Two-phase streaming response:
Phase 1: RAG vector search (Supabase, ~200ms)
Phase 2: Token streaming (DeepSeek / Ollama)
Two-phase streaming (recommended endpoint)
1. Embed query โโโบ Ollama (nomic-embed-text)
2. Vector search โโโบ Supabase RPC match_book_vector_pages
3. Build context โโโบ top-5 chunks injected into prompt
4. Stream LLM โโโโโโโบ DeepSeek / Ollama (token-by-token SSE)
Full agent run (/answer)
pydantic-ai agent loop:
โโโบ Tool: retrieve_relevant_documentation (RAG)
โโโบ Tool: list_book_pages (if RAG insufficient)
โโโบ Tool: get_book_pages_content (per page, on demand)
โโโบ Final LLM call โ JSON response
Technology Stack
| Layer | Library | Version |
|---|---|---|
| Web framework | fastapi |
latest |
| ASGI server | uvicorn |
latest |
| Agent framework | pydantic-ai |
latest |
| LLM provider | openai (async) |
latest |
| Local LLM / embeddings | ollama |
0.5.x |
| Vector database | supabase-py (async) |
2.x |
| Env config | python-dotenv |
latest |
Environment Variables
Create a .env file at the project root. All variables below are read at startup.
# --- Supabase (required) ---
SUPABASE_URL=https://<project>.supabase.co
SUPABASE_KEY=<anon-or-service-role-key>
# --- LLM Provider (required) ---
# Choose: "ollama" or "deepseek"
LNA_LLM_PROVIDER=deepseek
# --- DeepSeek (required when LNA_LLM_PROVIDER=deepseek) ---
DEEPSEEK_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxx
DEEPSEEK_BASE_URL=https://api.deepseek.com # optional, this is the default
DEEPSEEK_MODEL=deepseek-chat # optional, this is the default
# --- Ollama (required when LNA_LLM_PROVIDER=ollama) ---
# IMPORTANT: use the /v1 suffix for the OpenAI-compatible API
OLLAMA_HOST=http://localhost:11434/v1
OLLAMA_MODEL=qwen3:8b
OLLAMA_API_KEY=ollama # any non-empty string
# --- Embeddings (always Ollama, no /v1 suffix needed) ---
# Defaults to the base of OLLAMA_HOST if not set
# OLLAMA_EMBEDDING_HOST=http://localhost:11434
# --- Server ---
PORT=8001 # optional, default 8001
Key rule for
OLLAMA_HOST: Set it to the OpenAI-compatible endpoint (http://host:11434/v1). The code automatically strips/v1when constructing native Ollama API calls (embeddings).
Running the Server
The server initialises an async Supabase client during startup via a FastAPI
lifespan handler. No blocking I/O happens at the module level.
API Endpoints
GET /health
Health check. Returns immediately; no database call.
Response
GET /test
CORS smoke test. Verifies the server is reachable from a browser.
Response
GET /books
Returns the list of distinct books stored in Supabase.
Response 200 OK โ array of book objects
[
{
"id": "978-2-917591-70-3",
"title": "Charte Africaine de l'Entrepreneuriat Social",
"author": "Unknown Author",
"description": "Book content from Charte Africaine de l'Entrepreneuriat Social"
}
]
| Field | Type | Description |
|---|---|---|
id |
string |
The url column value from book_vector_pages โ used as source in query requests |
title |
string |
Value of metadata.book_title |
author |
string |
Always "Unknown Author" (not stored in current schema) |
description |
string |
Auto-generated from title |
Error 500 if Supabase is unreachable.
POST /answer
Full agent run. Executes the pydantic-ai agent loop: RAG search, optional page listing and content retrieval, then a final LLM synthesis. Returns a single JSON response after all processing is complete.
Use when: you don't need streaming and want the most thorough answer (agent may call multiple tools).
Request body
| Field | Type | Required | Description |
|---|---|---|---|
query |
string |
โ | The user's question (any language) |
source |
string |
โ | Book identifier โ the id returned by GET /books |
Response 200 OK
Error 500 with detail string on agent failure.
POST /answer/stream
โญ Primary streaming endpoint. True token-by-token streaming using a two-phase approach:
- RAG phase (~200โ400 ms, silent): embeds the query, fetches the 5 most relevant book chunks from Supabase via vector similarity search.
- LLM phase: streams the answer token-by-token from DeepSeek or Ollama using the OpenAI streaming API.
Use when: you want a responsive UI where text appears as it is generated.
Request body โ same as /answer
Response โ text/event-stream (SSE). See SSE Streaming Protocol.
Example (curl)
curl -N -X POST http://localhost:8001/answer/stream \
-H "Content-Type: application/json" \
-d '{"query": "What is this book about?", "source": "978-2-917591-70-3"}'
POST /stream
Alternative streaming endpoint. Uses the pydantic-ai run_stream +
stream_text() approach instead of direct LLM streaming.
โ ๏ธ Note: This endpoint may hang when the LLM provider does not properly support streaming during tool-call phases (known issue with some Ollama models). Prefer
/answer/streamfor production.
Request / Response โ same format as /answer/stream.
SSE Streaming Protocol
All streaming endpoints use Server-Sent Events.
Each event is a JSON object on a data: line followed by two newlines.
Event types
chunk โ a new token arrived
| Field | Description |
|---|---|
content |
The new token(s) just generated |
partial_text |
Full response text accumulated so far |
done |
Always false for chunk events |
done โ stream complete
{
"type": "done",
"content": "",
"partial_text": "This book is the African Charter...",
"done": true
}
partial_text contains the full final answer.
error โ an error occurred
JavaScript client example
const response = await fetch('http://localhost:8001/answer/stream', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: 'What is this book about?', source: '978-2-917591-70-3' })
});
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n\n');
buffer = lines.pop(); // keep incomplete last chunk
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
const event = JSON.parse(line.slice(6));
if (event.type === 'chunk') {
process.stdout.write(event.content); // or append to DOM
} else if (event.type === 'done') {
console.log('\nDone. Full answer:', event.partial_text);
} else if (event.type === 'error') {
console.error('Stream error:', event.content);
}
}
}
Database Schema
The API expects a Supabase table book_vector_pages with the following shape:
CREATE TABLE book_vector_pages (
id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
url text, -- book identifier (used as "source")
title text, -- page / section title
content text, -- chunk text
chunk_number integer, -- order within the page
metadata jsonb, -- { "source": "...", "book_title": "..." }
embedding vector(768) -- nomic-embed-text embedding
);
Required Supabase RPC function
The vector search tool calls a PostgreSQL function:
CREATE OR REPLACE FUNCTION match_book_vector_pages(
query_embedding vector(768),
match_count int,
filter jsonb DEFAULT '{}'
)
RETURNS TABLE (
id bigint,
url text,
title text,
content text,
metadata jsonb,
similarity float
)
LANGUAGE plpgsql
AS $$
BEGIN
RETURN QUERY
SELECT
b.id, b.url, b.title, b.content, b.metadata,
1 - (b.embedding <=> query_embedding) AS similarity
FROM book_vector_pages b
WHERE b.metadata @> filter
ORDER BY b.embedding <=> query_embedding
LIMIT match_count;
END;
$$;
Recommended indexes
-- Speed up metadata-based filtering (used by list_book_pages, get_book_pages_content)
CREATE INDEX ON book_vector_pages ((metadata->>'source'));
-- pgvector IVFFlat index for faster similarity search
CREATE INDEX ON book_vector_pages USING ivfflat (embedding vector_cosine_ops);
Agent Tools
The pydantic-ai agent (lna_agent) has three tools available. They are only
used by the /answer (non-streaming) endpoint.
retrieve_relevant_documentation
Performs a vector similarity search and returns the top 5 most relevant chunks.
Input: user_query (str)
Output: formatted string with chunk titles and content
Calls: get_embedding() โ Supabase RPC match_book_vector_pages
list_book_pages
Returns all unique page titles for the current book source. Called only if RAG is insufficient.
Input: (none โ uses ctx.deps.config.source)
Output: sorted list of unique title strings
Calls: Supabase SELECT title FROM book_vector_pages WHERE metadata->>'source' = ?
LIMIT 2000
get_book_pages_content
Returns the full content of a specific page, assembled from all its ordered chunks.
Input: title (str)
Output: combined page text
Calls: Supabase SELECT title, content, chunk_number ... ORDER BY chunk_number
LLM Provider Configuration
The LNA_LLM_PROVIDER environment variable selects the provider at startup.
DeepSeek (LNA_LLM_PROVIDER=deepseek)
| Variable | Default | Notes |
|---|---|---|
DEEPSEEK_API_KEY |
โ | Required |
DEEPSEEK_BASE_URL |
https://api.deepseek.com |
Optional override |
DEEPSEEK_MODEL |
deepseek-chat |
Optional override |
Streaming uses AsyncOpenAI(base_url=DEEPSEEK_BASE_URL) with the standard
/v1/chat/completions streaming API.
Ollama (LNA_LLM_PROVIDER=ollama)
| Variable | Default | Notes |
|---|---|---|
OLLAMA_HOST |
http://localhost:11434/v1 |
Must include /v1 for pydantic-ai |
OLLAMA_MODEL |
qwen3:8b |
Model name as listed in ollama list |
OLLAMA_API_KEY |
ollama |
Any non-empty string |
OLLAMA_EMBEDDING_HOST |
derived from OLLAMA_HOST |
Base URL without /v1 |
The code automatically strips
/v1when constructing native Ollama client URLs (used fornomic-embed-textembeddings).
Embeddings
Embeddings always use Ollama's native API regardless of LNA_LLM_PROVIDER:
Model: nomic-embed-text:latest
Endpoint: {OLLAMA_EMBEDDING_HOST}/api/embeddings
Output: 768-dimensional float vector
Install the model if not already present:
Adapting to a New Project
To reuse this architecture for a different domain (e.g. product manuals, legal documents, internal wikis), follow these steps:
1. Database
Create the same book_vector_pages table and match_book_vector_pages RPC
(rename as needed). Populate it with your documents chunked and embedded with
nomic-embed-text.
2. Agent (lna_ai_agent.py)
- Change the
system_promptto describe your new domain. - Rename
sourceinConfigto whatever your top-level grouping is (e.g.document_id,category). - The three tools work without modification as long as the table schema matches.
3. API (main.py)
- Update the system prompt string in the
/answer/streammessageslist to match your domain. - Update the service name in
/health. - No other changes needed.
4. Environment
Copy .env, update SUPABASE_URL, SUPABASE_KEY, and choose your LLM
provider.
5. Checklist
- [ ] Supabase table created and populated
- [ ]
match_book_vector_pagesRPC function created - [ ]
nomic-embed-text:latestpulled in Ollama (for embeddings) - [ ]
.envconfigured with correct keys - [ ] LLM model available (
ollama pull <model>or DeepSeek API key valid) - [ ] Server starts with
Application startup completein logs
Generated March 2026 โ lna-ai-api