Backend foundation
- Lesson 8
- 120 min
- Module 3: Build one complete user journey
Lesson 8 of 22 · Portfolio Capstone: Build, Evaluate and Ship an AI ProductBackend foundation
1. Select and bound the project
2. Prepare data and architecture
3. Build one complete user journey
4. Add reliability and access control
5. Evaluate and improve
6. Deploy and operate
7. Prepare the portfolio presentation
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 ready
What you need to know
- The design pack of your project: the contracts, the schema and the ingestion states, from Architecture and contracts.
- Request models and one error shape in FastAPI, from Validation and error responses.
- The adapter: the only code that talks to a model, with a mock behind it, from The application boundary.
- A queue, a worker and a lease, from Job lifecycle.
What you need on your computer
- Your project at the end of Module 2, in
~/projects/doc-assistant:python -m pytestprints23 passed, andpython -m doc_assistant.migrateapplied001_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 test evening with one dish
Section titled “A test evening with one dish”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.
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.
Two stand-ins: no model, no download
Section titled “Two stand-ins: no model, no download”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 brambleInes (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 v1Answer (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 da9d0f3916c83fd4b1ace0a89d593e32Read 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.
Validated endpoints and one error shape
Section titled “Validated endpoints and one error shape”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 Entitycontent-type: application/jsonx-request-id: 61bfb5970c5b495ccache-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.
Uploads are jobs with states
Section titled “Uploads are jobs with states”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.
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.
Sam builds the slice
Section titled “Sam builds the slice”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.
-
Commit your work first:
git add -Aandgit commit -m "End of Module 2". Then copy the snapshot’s files over your project. Your.envand your database stay.Terminal window cd ~/projects/doc-assistantcp -R ../nextia-learning-labs/C27/snapshots/end-of-m03/. .Terminal window cd ~\projects\doc-assistantCopy-Item -Recurse -Force ..\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 withgit checkout -- docs. TheREADME.mdis now the snapshot’s: it lists the commands of this stage. -
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 oftorchfirst, asrequirements.txtsays.Terminal window pip install -r requirements.txt -
Open
.env.exampleand.envside by side. Copy every line that your.envdoes not have yet into it: the settings of this module, fromANSWER_PROVIDERtoDEV_USER. Keep your password. Then choose the two stand-ins: add the lineEMBEDDER=hash, and changeANSWER_PROVIDERtomock. Your.envthen has these two lines:Terminal window EMBEDDER=hashANSWER_PROVIDER=mock -
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 dbpython -m pytestSam’s run printed:
.............................................................. [100%]62 passed in 3.14s -
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_packIdentity: 2 shops, 7 people, 6 membershipslarkfield: 36 documents uploaded, 0 already therebramble: 15 documents uploaded, 0 already thereIndexing with hash-384 ...Indexed 51 documents in 0.2 s (failed 0).bramble: 15 documents, 60 passages, 0 failedlarkfield: 36 documents, 185 passages, 0 failed -
Start the API in a second terminal, with the virtual environment active. Keep it running.
Terminal window fastapi dev doc_assistant/main.pyThe log has one warning on purpose:
AUTH_MODE=dev: every request without a token acts as usr-sam. Use it only on your computer. -
In the first terminal, check that the API runs and is ready. On Windows, type
curl.exeinstead ofcurl.Terminal window curl -s http://127.0.0.1:8000/healthcurl -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"}/healthsays that the process runs./readysays 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.
Upload, follow, ask
Section titled “Upload, follow, ask”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-hourstitle: Help desk holiday hoursversion: 1effective_from: 2026-10-11effective_to: ""owner: "Grace (support lead)"access: publiclanguage: endoc_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.-
Upload it to Larkfield:
Terminal window curl -s -i -F file=@holiday-hours.md http://127.0.0.1:8000/v1/tenants/larkfield/documentsHTTP/1.1 202 Acceptedcontent-type: application/jsonlocation: /v1/tenants/larkfield/documents/09fd15f3-f76c-4923-b419-f726b33839afx-request-id: 80fe4d52a9c9458acache-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. -
Two seconds later, ask for the
Location. Put your owndocument_idin 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"}}} -
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/answersIn PowerShell, put the body in a variable first:
$body = '{"question": "Is the help desk open on 26 December?"}'. Then send it withcurl.exe -s -H "Content-Type: application/json" -d $bodyand 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.
What the slice does not do yet
Section titled “What the slice does not do yet”Nobody signs in. Every request acts as Sam (AUTH_MODE=dev), and nothing checks that Sam belongs to the shop in the path:
curl -s http://127.0.0.1:8000/v1/tenants/bramble/documentsThe 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 mistakes
Section titled “Common mistakes”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 Conflictcontent-type: application/jsonx-request-id: 6cab6eac213b4d35cache-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.
Your project
Section titled “Your project”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 pytestpasses, 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_idthat is also in the log. - An upload answers 202 at once, and its status moves to
indexed, or tofailedwith 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”.
Knowledge check
Section titled “Knowledge check”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.
Does your answer have the main points of ours?
Sign in and enroll in the course to save your answers. Every check counts toward the certificate.
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.
Does your answer have the main points of ours?
Sign in and enroll in the course to save your answers. Every check counts toward the certificate.
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.
Does your answer have the main points of ours?
Sign in and enroll in the course to save your answers. Every check counts toward the certificate.
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, orfailedwith 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