Skip to content

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

  1. Architecture Overview
  2. Technology Stack
  3. Environment Variables
  4. Running the Server
  5. API Endpoints
  6. GET /health
  7. GET /test
  8. GET /books
  9. POST /answer
  10. POST /answer/stream โญ primary
  11. POST /stream
  12. SSE Streaming Protocol
  13. Database Schema
  14. Agent Tools
  15. LLM Provider Configuration
  16. 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)
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 /v1 when constructing native Ollama API calls (embeddings).


Running the Server

cd backend/
source ../venv/bin/activate
uvicorn main:app --host 0.0.0.0 --reload --port 8001

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

{ "status": "healthy", "service": "lna-ai-api" }

GET /test

CORS smoke test. Verifies the server is reachable from a browser.

Response

{ "message": "CORS is working!" }

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

{
  "query": "What is the main purpose of this book?",
  "source": "978-2-917591-70-3"
}
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

{
  "response": "This book is the African Charter of Social Entrepreneurship..."
}

Error 500 with detail string on agent failure.


POST /answer/stream

โญ Primary streaming endpoint. True token-by-token streaming using a two-phase approach:

  1. RAG phase (~200โ€“400 ms, silent): embeds the query, fetches the 5 most relevant book chunks from Supabase via vector similarity search.
  2. 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

{
  "query": "Who is the author of this book?",
  "source": "978-2-917591-70-3"
}

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/stream for 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

{
  "type": "chunk",
  "content": "This",
  "partial_text": "This",
  "done": false
}
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

{
  "type": "error",
  "content": "Error: connection refused",
  "partial_text": "",
  "done": true
}

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;
$$;
-- 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 /v1 when constructing native Ollama client URLs (used for nomic-embed-text embeddings).

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:

ollama pull nomic-embed-text:latest


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_prompt to describe your new domain.
  • Rename source in Config to 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/stream messages list 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_pages RPC function created
  • [ ] nomic-embed-text:latest pulled in Ollama (for embeddings)
  • [ ] .env configured with correct keys
  • [ ] LLM model available (ollama pull <model> or DeepSeek API key valid)
  • [ ] Server starts with Application startup complete in logs

Generated March 2026 โ€” lna-ai-api