Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"$schema": "https://raw.githubusercontent.com/anthropics/claude-code/main/schemas/settings.json",
"permissions": {
"ask": [
"Bash(git push*)"
],
"deny": [
"Bash(git reset --hard*)",
"Bash(cat .env*)",
"Read(//**/.env*)",
"Read(secrets/*)",
"Read(.aws/*)",
"Read(.ssh/*)",
"Bash(docker rm*)",
"Bash(docker rmi*)",
"Read(*prod*.config*)"
]
}
}
97 changes: 97 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
# experience-api

## Service overview
The Experience API sits between the OpenAgriNet web client and the Decision
Support System (DSS). The client sends a chat turn; the API checks it, builds a
DSS `/v1/turns` request, and streams the answer back in the client's shape. It
stores nothing.

## Architecture and contract
Do not repeat architecture or contract detail here; it goes stale. The sources:
- **`docs/api-contracts/api-contract.md`**: what the API does on the wire. §3–§7
bind this repo. The web client builds against the same file, so a change is
made there first, then in code.
- **`docs/ADR/`**: accepted decisions. ADR-0001 is the architecture: features at
the top, ports and adapters inside each feature.

## Tech stack
- Python 3.13, FastAPI on uvicorn (ADR-0001).
- httpx and httpx-sse for the DSS call; pydantic-settings for config, env prefix
`EXPERIENCE_API_`.
- uv, ruff, pyright, vulture, pytest, pre-commit.

## Build and run
Install: `uv sync`, then `uv run pre-commit install --hook-type pre-commit --hook-type pre-push`
Check: `uv run ruff check . && uv run ruff format --check . && uv run pyright && uv run vulture && uv run pytest`
Run: `uv run uvicorn --factory experience_api.app:create_app --port 8078`

## Domain language
The full shapes live in `chat/domain.py`. The words to know:
- **Turn** (`ChatTurn`): one user message plus the history before it.
- **Answer**: outcome, content blocks (text or refusal), sources, optional error.
- **Citation**: points a source at a span of a text block (code-point offsets).
- **Events**: the DSS yields `DssStarted`, `DssDelta`, `DssFinished`; the service
yields `Started`, `Delta`, `Completed`. `Completed` is the authoritative
answer; it replaces what the deltas built up.
- **Trace id**: the DSS's name for one attempt; the client shows it on failure.

## What this service provides
- `POST /v1/chat`: one chat turn. `Accept: text/event-stream` streams it;
anything else returns one JSON answer. Contract §4–§5.
- `GET /healthz`: the process is up. It does not probe the DSS.

## What this service depends on
- **DSS**, `POST /v1/turns` over SSE, at `EXPERIENCE_API_DSS_BASE_URL`.
`EXPERIENCE_API_DSS_MODE=fake` (the default) answers from memory instead.
- No database, no messaging.

## Conventions
Naming, commits, PRs, logging and linting are in [`CONVENTIONS.md`](./CONVENTIONS.md).
Read it before naming anything or writing a commit.

## Writing style
For ADRs, PR descriptions, comments and docs: simple words, short sentences,
nothing that is not needed. Write for a reader without a software background.

## Folder structure
Features at the top; each keeps its own layers. `tests/test_boundaries.py`
enforces who may import what (ADR-0001 §4.1).

```
src/experience_api/
├── app.py # create_app() and lifespan: the composition root; the only reader of Settings
├── settings.py # pydantic-settings; each setting arrives with its first reader
├── shared/ # cross-cutting only: the Error body
└── <feature>/ # chat/ today
├── domain.py # plain types and errors; stdlib and pydantic only
├── service.py # the feature's rules; takes config as plain values
├── ports.py # Protocols for what the feature needs from outside
└── adapters/
├── http/ # inbound: routes, wire schemas (camelCase), mapping
└── <name>/ # outbound: implements a port (dss/ today, with a fake)
```

## Testing
TDD: write the failing test first. Every commit is green.

| Tier | Path | What | Uses |
|---|---|---|---|
| Unit | `tests/unit/<feature>/` | mapping, the service, SSE framing | the fake DSS; fixed clock and ids |
| API | `tests/api/` | the whole app through httpx's ASGI transport | the fake DSS |
| Integration | `tests/integration/` | `HttpDssClient` against a local HTTP server | pytest-httpserver |
| E2E | `tests/e2e/` | one turn against a real DSS | marker `e2e`, not run by default |
| Boundaries | `tests/test_boundaries.py` | the dependency rule | the AST |

## Known gotchas
- httpx's ASGI transport does not run lifespan events. Use the `running`
fixture in `tests/conftest.py`, which does.
- vulture cannot see a route FastAPI calls. Route decorators are ignored in
`pyproject.toml`; any other false positive goes in `vulture_whitelist.py`.
- Imports are absolute only (ruff bans relative ones). A relative import hides
which layer it reaches into.
- A DSS stream that ends before `DssFinished` is not handled yet
(`TODO(phase 4)` in `chat/service.py`).

## Component specs

Behaviour and guarantees per component live in `docs/spec/` (start at `docs/spec/README.md`). Before starting new work, if `docs/.agent/specs/` has story specs, run `/update-component-spec` to fold them in.
73 changes: 0 additions & 73 deletions CLAUDE.md

This file was deleted.

1 change: 1 addition & 0 deletions CLAUDE.md
17 changes: 17 additions & 0 deletions docs/spec/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# experience-api — component specs

The Experience API sits between the OpenAgriNet web client and the Decision
Support System (DSS). The client sends a farmer's question; the API checks it,
asks the DSS, and returns the answer in the client's shape. It stores nothing.
It also has `GET /healthz`, which says the process is up. It does not check
the DSS, so a DSS outage never takes the API down with it.

## Components

| Component | Covers | Scope |
|---|---|---|
| chat | `src/experience_api/chat/**`, `src/experience_api/settings.py`, `src/experience_api/shared/**` | Answers one chat turn: checks it, asks the DSS, streams or returns the answer |

## How to read this book

One page per component. Each page says what the component exposes, what it depends on, how it behaves, what it guarantees, and how it fails. Decisions are dated. Anything marked `<!-- inferred — verify -->` was read from code, not confirmed by a person.
5 changes: 5 additions & 0 deletions docs/spec/SUMMARY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Summary

* [Overview](README.md)
* [chat](chat.md)
* [Glossary](glossary.md)
73 changes: 73 additions & 0 deletions docs/spec/chat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# chat

## Overview
A farmer asks a question in the web client. The client sends it to chat, with
the conversation so far, the languages, and maybe a location. Chat checks it,
asks the DSS, and passes the answer back, either as a live stream or as one
complete answer. It stores nothing. The wire shapes are in the
[API contract](../api-contracts/api-contract.md) §3–§7.

## Interfaces
**Exposes:** `POST /v1/chat`.
**Depends on:** the DSS, through its `POST /v1/turns` event stream.

## Behaviour
### Checking the request
Chat is strict. It turns away a request with a missing or unknown field, an id
that is not a UUID, empty text, a language code that is not BCP 47, or a
location out of range. The answer is a `422`, and the DSS is never called. It checks every rule the DSS checks, so a request the
DSS would reject never reaches it. The user's words are passed on unchanged.

### Answering
If the client asks for `text/event-stream`, chat streams: a `started` event,
the answer's pieces as they come, then one `completed` event that holds the
whole answer. Otherwise it waits for the end and returns that same answer as
JSON. Either way, the client's ids come back exactly as sent.

When the DSS cannot answer because something it needs is down, that is still
an answer. It arrives as `completed`, with an error inside.

### Reading the DSS
Chat is lenient here. New DSS fields, events and content types are ignored, so
a DSS release cannot break a turn.

### Without a DSS
With `DSS_MODE=fake`, chat plays one canned weather answer for every turn.

## Key flows
A streamed turn: client → chat → DSS, then `started`, `delta`…, `completed`
back. A JSON turn makes the same DSS call and returns only the end. If the
browser leaves mid-stream, chat closes the DSS call and the DSS stops.

## Non-functional
No storage, no retries, no sign-in (every turn is `anonymous`). One connection
pool to the DSS. Errors about a bad DSS frame never include its text, since it
can echo the user's words.

## Failure modes
| Scenario | Today | Recovery |
|---|---|---|
| Bad request | `422`, FastAPI's body | Client fixes it |
| DSS down or returns an error | `500`, plain text `Internal Server Error`; a stream never starts | User retries |
| DSS stream breaks or ends early | Stream stops after the pieces so far, with no `error` or `completed` event. JSON: `500` | User retries |
| Browser leaves | DSS call closed | None |

## Configuration and limits
| Setting | Default | Notes |
|---|---|---|
| `EXPERIENCE_API_DSS_MODE` | `fake` | `fake` or `http` |
| `EXPERIENCE_API_DSS_BASE_URL` | `http://localhost:8077` | Checked on the first turn, not at start |
| `EXPERIENCE_API_CHANNEL` | `web` | Sent to the DSS |
| `EXPERIENCE_API_MAX_CHARACTERS` | `1200` | Sent to the DSS |
| DSS timeouts | 5 s connect, 120 s read | In code; a stopgap |

## Decisions
- 2026-09-23 Ports and adapters per feature (ADR-0001).
- 2026-09-23 FastAPI's default `422` for now; decide at peer review.
- 2026-09-24 Always stream from the DSS, even for a JSON client.
- 2026-09-24 Strict with the client, lenient with the DSS.

## Known gaps
DSS failures do not yet map to the contract's codes (`503`, `429`, `413`,
`502`, `504`). A broken stream sends no `error` event. The contract's two
timeouts are not built. No end-to-end test against a real DSS.
9 changes: 9 additions & 0 deletions docs/spec/glossary.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Glossary

- **Turn**: one user message plus the conversation before it.
- **Answer**: the outcome, the content blocks (text or refusal), the sources, and maybe an error.
- **Citation**: links a source to a span of a text block.
- **Delta**: one piece of answer text, sent as it is written.
- **Completed**: the final event; it holds the whole answer and replaces the deltas.
- **Trace id**: the DSS's name for one attempt; the client shows it when something goes wrong.
- **DSS**: the Decision Support System, which writes the answers.
20 changes: 20 additions & 0 deletions service-meta.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
schema_version: 1

name: experience-api
description: The HTTP service between the OpenAgriNet web client and the DSS
owner: "# fill in — no owner detected"
language: python
framework: fastapi
repo_type: service

provides:
apis:
- type: rest
path: /v1/chat
description: One chat turn, streamed as SSE or returned as one JSON answer # inferred — verify

depends-on:
services:
- name: dss # inferred — verify
protocol: rest
reason: Answers each chat turn over POST /v1/turns (SSE) # inferred — verify
Loading