Skip to content

Backend foundation

  • Lesson 8
  • 120 min
  • Module 3: Build one complete user journey

What you will be able to do

Make one user journey work on the server first: upload a document, follow its status, ask a question and store the answer. Stand-ins replace the slow and costly parts, so every later piece plugs into something that already runs.

Before you startWhat to know and to have readyAbout 120 minutes

What you need to know

What you need on your computer

  • Your project at the end of Module 2, in ~/projects/doc-assistant: python -m pytest prints 23 passed, and python -m doc_assistant.migrate applied 001_core.
  • Docker Desktop running, and about 1 GB of free disk for this module’s packages.
  • The labs repository next to your project, in ~/projects/nextia-learning-labs, as in Set up the capstone repository.

A new restaurant does not open with a menu of forty dishes. First it has a test evening with one dish. A guest orders, the kitchen cooks, a waiter serves, and the guest pays. Every part works once, from the door to the bill.

The test evening finds the problems that no single part shows. The order slips are too small to read. The plates do not fit the hatch. The card reader is at the wrong end of the room. The cooks could cook perfectly, and the evening would still fail.

The problem: three weeks of parts, nothing to try

Section titled “The problem: three weeks of parts, nothing to try”

Sam has the design pack from Module 2. Tomás has a plan: “Make the search perfect first. Then add a reranker. Then the page.” It sounds careful. But after three weeks, Grace could still not upload one document or ask one question.

Worse, the parts would meet for the first time at the end. Two facts of this project show only when the parts connect. The answer step needs the shop’s name for its prompt, so the caller’s shop must travel from the URL to the model. And the first search or indexing after a start loads the embedding model: about 4 seconds in the course’s runs, before any work. An upload that waits for indexing makes the browser wait too, and a crash in the middle loses the upload. Each of these facts changes the design, and each costs more to fix late.

The idea: one thin path through every layer

Section titled “The idea: one thin path through every layer”

Build the thinnest version of every layer first, and connect them, so that one person can do the whole task once. This path is a vertical slice. Draw the layers of the product one above the other: the page, the API, ingestion, the answer step and the database. A slice cuts down through all of them, as a knife cuts a cake.

This module’s milestone, “One journey” in Sam’s brief, is the slice: a new user uploads a document, sees it indexed, asks a question and reads an answer with a citation.

Two panels with the same five rows: page, API, ingestion, answer step, database. Left, layer by layer: the database and the API are built, ingestion is part built, the answer step is a little built and the page is empty. After three weeks, nothing a person can try. Right, one vertical slice: one narrow column crosses all five rows: one plain page, ten endpoints with one error shape, jobs from uploaded to indexed, a mock answer with a citation, schema 001. After one week, a person can upload, ask and read an answer.
The same work in two orders. Layer by layer, nothing works until the end. One vertical slice works early, and each later lesson makes one layer better.
Text description of the diagram

Left: each layer is built to the end before the next one starts. After three weeks, the database and the API are complete, ingestion is half done, and there is no page. Nobody can try the product. Right: each layer gets its simplest version first: one plain page, ten endpoints with one error shape, jobs with states, a mock answer with a citation, and the first migration. After one week, a person can do the whole task once. The weeks are an illustration, not a measurement.

A slice is not a demo with fake screens. Every part is real code that the later modules keep. Only two parts are stand-ins: simple replacements for slow or costly parts, with the same interface as the real ones.

A film crew uses a stand-in during rehearsals. The stand-in has the actor’s height and walks the same marks, so the lights and the cameras get ready. The real actor arrives when everything else works.

The capstone has two parts that are slow or cost money, and each gets a stand-in. One setting in .env chooses each:

Part Real version (in “Integrate retrieval”) Stand-in (now) Setting
Embedding model e5: a 470 MB download; about 4 s to load, then about 6 s to index the pack hash: each word adds 1 to one of 384 numbers. No download, no meaning EMBEDDER
Answer model recorded real answers, or live calls with your own key mock: the first sentence of the best passage, cited ANSWER_PROVIDER

Both stand-ins have the same small interface as the real parts. The pipeline calls embedder.embed_queries([...]) and provider.complete(request), and it does not know which version answers. This is the adapter of The application boundary, now with three providers in doc_assistant/providers.py: mock, recorded and live.

The mock’s whole rule fits in one sentence: copy the first sentence of the best passage, and cite that passage. Here is the tiny example, from Sam’s run with both stand-ins:

$ python -m scripts.ask "How long does delivery to Ontario take?" --tenant bramble
Ines (staff) at Bramble Books asks: How long does delivery to Ontario take?
Passages found: e5e28bf8b6a1 faq v4, 20804b124392 delivery-policy v1, c230e31d26bd delivery-policy v1, 3716423a735e events-and-room-hire v1, 1c43933c27c8 returns-policy v1
Answer (answerable): How long does a special order take?
- How long does a special order take? [Frequently asked questions, How long does a special order take?, v4]
mock mock | 6 ms | tokens 0 in, 0 out | US$0.000000 | trace da9d0f3916c83fd4b1ace0a89d593e32

Read it line by line. The hash search ranked the FAQ first, because it shares the words “How long” and “take” with the question. It does not understand that the question is about delivery. The mock then copied the first sentence of that passage, which is a question heading. Both stand-ins did exactly their job, and the answer is useless. That is fine for a stand-in: the shape is right, the citation is real, the cost is zero, and every line says mock. With the real embedding model, in the last lesson of this module, the same mock copies “We ship within 2 business days.” from the delivery policy.

When to use stand-ins. Use them while you connect the parts, and in the tests: they are fast, free and give the same output every time. When not to. Never measure quality with them, and never show a mock answer as the product’s answer. What they cost. Two code paths to keep working, and a label on every output.

Every request passes a check before any code of the project runs. You met this in Validation and error responses. Here is why it matters more for an AI product: a request that reaches the model costs money. A two-letter question, or a field that the API does not know, should stop at the door.

doc_assistant/models.py says what a question may be:

class AskRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
question: str = Field(min_length=3, max_length=500, description="The question, in plain words.")
as_of: date | None = Field(default=None, description="The date the answer is for (default: today). ...")

doc_assistant/errors.py gives every failure one shape, {"code", "message", "request_id"}, plus details for a validation error. code is for programs and never changes; message is for people. The request_id is also in the X-Request-ID header and in the log line of the request. An unexpected error never shows its text or a stack trace: the log has it, under the same ID.

Sam’s run, a two-letter question:

HTTP/1.1 422 Unprocessable Entity
content-type: application/json
x-request-id: 61bfb5970c5b495c
cache-control: no-store
{"code":"validation_error","message":"The request is not valid.","request_id":"61bfb5970c5b495c","details":[{"field":"question","problem":"String should have at least 3 characters"}]}

Uploads are checked in ingest.create_document, before anything is saved. Sam tried one bad request of each kind. These are the real answers, shortened to the status and the body:

Request Status code message (and details)
A .docx file 415 unsupported_file Upload a .md, .txt, .html or .pdf file.
An empty file 422 empty_file The file is empty.
A file of 5,000,001 bytes 413 file_too_large The file is 5,000,001 bytes; the limit is 5,000,000 bytes.
An upload with access=everyone 422 validation_error The document’s metadata is not valid. access: must be public or staff
A question with an extra field, "shop": "bramble" 422 validation_error The request is not valid. shop: Extra inputs are not permitted
A shop that does not exist: /v1/tenants/acme/documents 404 not_found Not found.
A document ID that is not an ID: …/documents/42 404 not_found Not found.

The page in the next lesson reads code and message from every one of these in the same way. One shape means one piece of code to handle every error.

Indexing is slow: parse the file, cut it into passages, and compute a vector for each passage. So the upload does not wait for it. It is the dry cleaner’s ticket from Job lifecycle. The API checks the file, saves the bytes and a row with status = 'uploaded', and answers at once with 202 Accepted and a Location to follow. The worker does the slow part later.

Module 2 decided that the documents table is the queue, so no separate jobs table is needed. The worker in doc_assistant/worker.py claims the oldest waiting document with FOR UPDATE SKIP LOCKED, so two workers never take the same one. It sets parsing with a lease of 5 minutes. It ends at indexed, or at failed with a reason that a person can act on. By default the worker runs as a thread inside the API process (INGEST_WORKER=thread). The embedding model is then loaded once, and you need one terminal less.

A sequence diagram with three participants: the client, the API and the worker thread, and the database. The client posts the file. The API checks the size, the type and the metadata, saves the row with status uploaded and the bytes in one transaction, and answers 202 with a Location header. The worker, which looks for work every second, claims the document with FOR UPDATE SKIP LOCKED and sets parsing with a 5-minute lease. It parses, chunks and embeds, then saves the chunks and sets indexed in one transaction. The client asks for the Location and sees indexed with the number of passages.
One upload in Sam's run. The answer to the upload comes before the slow work starts.
Text description of the diagram

The client posts the file. The API checks its size, type and metadata, then saves the row with status uploaded and the file’s bytes in one transaction. It answers 202 with a Location header, in 12 ms in Sam’s run. The worker thread looks for work every second. It claims the oldest waiting row with FOR UPDATE SKIP LOCKED, sets parsing with a lease of 5 minutes, then parses, chunks and embeds the file: 2 passages. It saves the chunks and their vectors, and sets indexed, in one transaction: 0.26 seconds after the upload, with the hash stand-in. A GET on the Location then shows indexed and chunk_count 2.

All three lessons of this module share one snapshot, end-of-m03. This lesson reads and runs its backend, the next one its page, and the last one its retrieval. On the standard track, copy the snapshot over your project now. On the own-idea track, read along, and write the same parts for your product in Your project below.

  1. Commit your work first: git add -A and git commit -m "End of Module 2". Then copy the snapshot’s files over your project. Your .env and your database stay.

    Terminal window
    cd ~/projects/doc-assistant
    cp -R ../nextia-learning-labs/C27/snapshots/end-of-m03/. .

    The copy also writes Sam’s files of docs/ over your own brief, criteria, plan and design pack. Get yours back from your last commit with git checkout -- docs. The README.md is now the snapshot’s: it lists the commands of this stage.

  2. Install the new packages, with the virtual environment active. This module adds FastAPI, the model packages and torch, about 900 MB. On Linux without a GPU, install the CPU build of torch first, as requirements.txt says.

    Terminal window
    pip install -r requirements.txt
  3. Open .env.example and .env side by side. Copy every line that your .env does not have yet into it: the settings of this module, from ANSWER_PROVIDER to DEV_USER. Keep your password. Then choose the two stand-ins: add the line EMBEDDER=hash, and change ANSWER_PROVIDER to mock. Your .env then has these two lines:

    Terminal window
    EMBEDDER=hash
    ANSWER_PROVIDER=mock
  4. Start the database, and run the tests. The tests use the stand-ins too, and their own database.

    Terminal window
    docker compose up -d --wait db
    python -m pytest

    Sam’s run printed:

    .............................................................. [100%]
    62 passed in 3.14s
  5. Load the shops, the people and the 51 documents. The script uploads each file with the same code as the API. Then it runs the worker until the queue is empty.

    Terminal window
    python -m scripts.load_pack
    Identity: 2 shops, 7 people, 6 memberships
    larkfield: 36 documents uploaded, 0 already there
    bramble: 15 documents uploaded, 0 already there
    Indexing with hash-384 ...
    Indexed 51 documents in 0.2 s (failed 0).
    bramble: 15 documents, 60 passages, 0 failed
    larkfield: 36 documents, 185 passages, 0 failed
  6. Start the API in a second terminal, with the virtual environment active. Keep it running.

    Terminal window
    fastapi dev doc_assistant/main.py

    The log has one warning on purpose: AUTH_MODE=dev: every request without a token acts as usr-sam. Use it only on your computer.

  7. In the first terminal, check that the API runs and is ready. On Windows, type curl.exe instead of curl.

    Terminal window
    curl -s http://127.0.0.1:8000/health
    curl -s http://127.0.0.1:8000/ready
    {"status":"ok","version":"1.0.0"}
    {"status":"ready","version":"1.0.0","answer_provider":"mock","auth_mode":"dev"}

    /health says that the process runs. /ready says that it can serve: the database answers, and every migration is applied.

In the course’s run, the API used port 8313, because another program used port 8000. Your commands use 8000.

Grace asks Sam to add the help desk’s holiday hours. Sam writes a small document. It is made up for this lesson. Save it as holiday-hours.md in the project folder:

---
doc_id: holiday-hours
title: Help desk holiday hours
version: 1
effective_from: 2026-10-11
effective_to: ""
owner: "Grace (support lead)"
access: public
language: en
doc_type: policy
---
# Help desk holiday hours
## Closed days
The help desk is closed on 25 December, 26 December and 1 January.
## Short days
On 24 December and 31 December, the help desk is open from 9 am to 2 pm.
  1. Upload it to Larkfield:

    Terminal window
    curl -s -i -F file=@holiday-hours.md http://127.0.0.1:8000/v1/tenants/larkfield/documents
    HTTP/1.1 202 Accepted
    content-type: application/json
    location: /v1/tenants/larkfield/documents/09fd15f3-f76c-4923-b419-f726b33839af
    x-request-id: 80fe4d52a9c9458a
    cache-control: no-store
    {"document":{"document_id":"09fd15f3-f76c-4923-b419-f726b33839af","doc_key":"holiday-hours","version":"1","title":"Help desk holiday hours","doc_type":"policy","language":"en","access":"public","effective_from":"2026-10-11","effective_to":"","file_name":"holiday-hours.md","size_bytes":391,"sha256":"8c0cef055010b024f38d67267596d7b3428310e2ee6595441645b0d61db0091c","status":"uploaded","error":null,"attempts":0,"chunk_count":0,"uploaded_by":"usr-sam","uploaded_at":"2026-10-11T16:48:15.139950+00:00","indexed_at":null,"links":{"self":"/v1/tenants/larkfield/documents/09fd15f3-f76c-4923-b419-f726b33839af"}},"duplicate":false}

    The status is uploaded: the file is saved, and nothing is indexed yet. The metadata came from the file’s front matter. A form field, such as -F access=staff, wins over it.

  2. Two seconds later, ask for the Location. Put your own document_id in the URL:

    Terminal window
    curl -s http://127.0.0.1:8000/v1/tenants/larkfield/documents/<document_id>
    {"document":{"document_id":"09fd15f3-f76c-4923-b419-f726b33839af","doc_key":"holiday-hours","version":"1","title":"Help desk holiday hours","doc_type":"policy","language":"en","access":"public","effective_from":"2026-10-11","effective_to":"","file_name":"holiday-hours.md","size_bytes":391,"sha256":"8c0cef055010b024f38d67267596d7b3428310e2ee6595441645b0d61db0091c","status":"indexed","error":null,"attempts":1,"chunk_count":2,"uploaded_by":"usr-sam","uploaded_at":"2026-10-11T16:48:15.139950+00:00","indexed_at":"2026-10-11T16:48:15.404206+00:00","links":{"self":"/v1/tenants/larkfield/documents/09fd15f3-f76c-4923-b419-f726b33839af"}}}
  3. Ask a question:

    Terminal window
    curl -s -H "Content-Type: application/json" -d '{"question": "Is the help desk open on 26 December?"}' http://127.0.0.1:8000/v1/tenants/larkfield/answers

    In PowerShell, put the body in a variable first: $body = '{"question": "Is the help desk open on 26 December?"}'. Then send it with curl.exe -s -H "Content-Type: application/json" -d $body and the same URL. The answer, shortened:

    {
    "answer": {
    "answer_id": "d4b37692-24a8-42c8-9128-fefd36463883",
    "answerable": true,
    "answer": "On 24 December and 31 December, the help desk is open from 9 am to 2 pm.",
    "claims": [
    {
    "text": "On 24 December and 31 December, the help desk is open from 9 am to 2 pm.",
    "citations": [
    {
    "chunk_id": "935f8e210cba",
    "document_id": "09fd15f3-f76c-4923-b419-f726b33839af",
    "title": "Help desk holiday hours",
    "doc_key": "holiday-hours",
    "version": "1",
    "section": "Short days",
    "access": "public"
    }
    ],
    "problems": []
    }
    ],
    "passages": "(5 passages, the best first)",
    "needs_review": false,
    "note": "",
    "provider": "mock",
    "model": "mock",
    "input_tokens": 0,
    "output_tokens": 0,
    "cost_usd": 0.0,
    "latency_ms": 7,
    "trace_id": "5a62cdf3e53caf875cfd30b0dbbbcf04"
    }
    }

Read this answer as a record, not as an answer. The hash search ranked the two passages of the new document first, with the same score, 0.6565. The mock copied the first sentence of “Short days”, which does not answer the question about 26 December. The answer is in “Closed days”, the second passage. A real model reads all five passages; the mock reads one. The API stored it with its citations, its provider and its trace ID, and GET /v1/tenants/larkfield/answers lists it.

Nobody signs in. Every request acts as Sam (AUTH_MODE=dev), and nothing checks that Sam belongs to the shop in the path:

Terminal window
curl -s http://127.0.0.1:8000/v1/tenants/bramble/documents

The answer, shortened to the shop and the counts:

{"tenant": "bramble", "shop": "Bramble Books", "role": "staff", "counts": {"indexed": 15}}

Sam works at Larkfield, and the API just listed Bramble Books’ documents for Sam. Permission enforcement closes this gap. Sam’s threat review in the design pack already names the threat: “Ines changes larkfield to bramble in the URL”. It also names the control, a membership check, which the slice does not have yet. A slice may have known gaps. It may not hide them: the start-up warning and the README both say it.

Common mistake

The same file again gives 409 conflict

Symptom: you upload holiday-hours.md a second time, and the API answers:

HTTP/1.1 409 Conflict
content-type: application/json
x-request-id: 6cab6eac213b4d35
cache-control: no-store
{"code":"conflict","message":"Version 1 of holiday-hours already exists. Upload it with a new version number, or delete the old one first.","request_id":"6cab6eac213b4d35"}

Cause: the schema allows one row per shop, document key and version. The file says version: 1, and version 1 is already there.

Fix: if the document changed, raise version in its front matter, or send -F version=2. If it did not change, there is nothing to do. In Module 4, Failure handling makes the same file a harmless duplicate (200, "duplicate": true), as the contract says.

Common mistake

The search finds nothing after you change EMBEDDER

Symptom: you set EMBEDDER=e5, restart, and every answer is “The documents do not answer this question.”

Cause: the vectors of two models cannot be compared, even when they have the same length. Each document records the model that made its vectors, and the search reads only the chunks of the current model. Your chunks were made by hash-384.

Fix: index again with the new model: python -m scripts.load_pack --reset. The last lesson of this module does this, with the output.

Common mistake

curl prints nothing, or 'Connection refused'

Symptom: curl -s http://127.0.0.1:8000/health prints nothing. Without -s, curl says that it could not connect.

Cause: the API is not running, or it stopped at start-up. A common reason: the database is not running, so the API cannot connect to it.

Fix: look at the API’s terminal first. Start the database with docker compose up -d --wait db, then start the API again.

Do the same step for your own product: one slice of the backend, with stand-ins, before any real model.

  • Standard track. Run the steps above on your copy. Then upload one document that you wrote yourself, and ask one question that only your document answers.
  • Own-idea track. Write the backend of your one user journey, in the same order. First the request models and one error shape. Then the upload that returns 202 and a job with states. Then the question, through an adapter with a mock. Reuse the snapshot’s files where your domain fits. Each file says where it comes from and keeps its MIT notice.

Evidence to keep. Save a terminal transcript as docs/evidence/m03-backend.txt: pytest, /ready, one upload with its 202, the indexed status, one answer, and the error cases of the table above. Write your computer and your Python version at the top. Your README points to it in Module 7.

Checklist

  • python -m pytest passes, and the tests use the stand-ins, not a real model.
  • Every endpoint of your slice checks its input before it does any work.
  • Every error has the same shape, with a request_id that is also in the log.
  • An upload answers 202 at once, and its status moves to indexed, or to failed with a reason.
  • Every mock answer says mock, and you never report its quality.
  • The known gaps of the slice are written down, for example “nobody signs in yet”.

Check your understanding

Tomás says: 'A vertical slice is a waste. We will throw away the mock and the hash embedder anyway.' Give two reasons why Sam built them first.

Show the answer

First, the slice made every part meet early. The problems that only show when the parts connect came out in the first week: the prompt needs the shop’s name, and an upload must not wait for indexing. Found late, each would cost more to fix. Second, the stand-ins are not thrown away. The tests keep them, because they are fast, free and repeatable: 62 tests pass with no model and no key. Only a setting in .env changes for real use.

Check your understanding

Predict: you upload a PDF that was cut during the upload. What status code does the upload get, and what does GET on its Location show a few seconds later?

Show the answer

The upload gets 202 Accepted. The API checks only what it can check at once: the size, the file type and the metadata. A cut PDF passes those checks. The worker finds the problem when it parses the file. It sets failed at once, without a retry, because parsing again would fail in the same way. The status then shows failed with a reason, “This file is not a readable PDF (PdfStreamError).” The next lesson shows it on the page.

Check your understanding

Why does the API answer a two-letter question with 422 before any search runs, and not let the model decide?

Show the answer

A request that reaches the search and the model uses time and, with a real model, money, and it cannot give a useful answer. The request model (3 to 500 characters, no unknown fields) rejects it at the door. Its error has the one shape and names the wrong field. So the page and every script can show the problem in the same way.

Recap

  • A vertical slice is the thinnest version of every layer, connected, so one person can do the whole task once. It is this module’s milestone.
  • Stand-ins replace the slow and costly parts behind the same interface: the hash embedder and the mock provider. Use them to connect and test, never to measure quality.
  • Every request is checked before any work, and every error has one shape: code, message, request_id.
  • An upload is a job: 202 at once, then uploaded → parsing → indexed, or failed with a reason. The documents table is the queue.
  • The slice has a known gap: nobody signs in yet. Module 4 closes it.

Next, Grace gets a page, so she can use the slice without a terminal.

Next: Frontend foundation