From 93cc4cd2137ab36855f970140df572f24965cb39 Mon Sep 17 00:00:00 2001 From: bhavesh-suvalaka Date: Thu, 15 Oct 2026 13:34:05 +0530 Subject: [PATCH 1/3] chore: initialise repo for AI-assisted development Co-Authored-By: Claude Opus 5.5 --- .claude/settings.json | 19 +++++++++ AGENTS.md | 93 +++++++++++++++++++++++++++++++++++++++++++ CLAUDE.md | 74 +--------------------------------- service-meta.yaml | 20 ++++++++++ 4 files changed, 133 insertions(+), 73 deletions(-) create mode 100644 .claude/settings.json create mode 100644 AGENTS.md mode change 100644 => 120000 CLAUDE.md create mode 100644 service-meta.yaml diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..9320b34 --- /dev/null +++ b/.claude/settings.json @@ -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*)" + ] + } +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..74d6df2 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,93 @@ +# 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 +└── / # 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 + └── / # 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//` | 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`). diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index e7cb57c..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,73 +0,0 @@ -# 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. - -**Write an ADR for a new tech-stack or design-direction decision**, in the -existing format (context, drivers, options, outcome, consequences). **Update the -tech stack below whenever an ADR is accepted.** - -## Tech stack -- Python 3.13, FastAPI on uvicorn (ADR-0001). -- httpx 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` - -## 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 -└── / # 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 - └── / # 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//` | 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`. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/service-meta.yaml b/service-meta.yaml new file mode 100644 index 0000000..39ad0f7 --- /dev/null +++ b/service-meta.yaml @@ -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 From 1cbc2cdf88ef6a20890081d252160ab77f726a30 Mon Sep 17 00:00:00 2001 From: bhavesh-suvalaka Date: Thu, 15 Oct 2026 14:12:48 +0530 Subject: [PATCH 2/3] docs: add the component spec book, starting with chat Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 4 +++ docs/spec/README.md | 17 ++++++++++ docs/spec/SUMMARY.md | 5 +++ docs/spec/chat.md | 73 +++++++++++++++++++++++++++++++++++++++++++ docs/spec/glossary.md | 9 ++++++ 5 files changed, 108 insertions(+) create mode 100644 docs/spec/README.md create mode 100644 docs/spec/SUMMARY.md create mode 100644 docs/spec/chat.md create mode 100644 docs/spec/glossary.md diff --git a/AGENTS.md b/AGENTS.md index 74d6df2..246a747 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -91,3 +91,7 @@ TDD: write the failing test first. Every commit is green. 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. diff --git a/docs/spec/README.md b/docs/spec/README.md new file mode 100644 index 0000000..dd13ea6 --- /dev/null +++ b/docs/spec/README.md @@ -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 `` was read from code, not confirmed by a person. diff --git a/docs/spec/SUMMARY.md b/docs/spec/SUMMARY.md new file mode 100644 index 0000000..4501136 --- /dev/null +++ b/docs/spec/SUMMARY.md @@ -0,0 +1,5 @@ +# Summary + +* [Overview](README.md) +* [chat](chat.md) +* [Glossary](glossary.md) diff --git a/docs/spec/chat.md b/docs/spec/chat.md new file mode 100644 index 0000000..3504f74 --- /dev/null +++ b/docs/spec/chat.md @@ -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` | User retries | +| DSS stream breaks or ends early | Stream stops, no `error` event | 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. diff --git a/docs/spec/glossary.md b/docs/spec/glossary.md new file mode 100644 index 0000000..a761ebc --- /dev/null +++ b/docs/spec/glossary.md @@ -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. From 1835903aa665898f54e95e542dabdc25744b86c5 Mon Sep 17 00:00:00 2001 From: bhavesh-suvalaka Date: Thu, 15 Oct 2026 14:17:41 +0530 Subject: [PATCH 3/3] docs: confirm how chat fails when the DSS is down or breaks Co-Authored-By: Claude Opus 5.5 --- docs/spec/chat.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/spec/chat.md b/docs/spec/chat.md index 3504f74..69179c9 100644 --- a/docs/spec/chat.md +++ b/docs/spec/chat.md @@ -48,8 +48,8 @@ can echo the user's words. | Scenario | Today | Recovery | |---|---|---| | Bad request | `422`, FastAPI's body | Client fixes it | -| DSS down or returns an error | `500` | User retries | -| DSS stream breaks or ends early | Stream stops, no `error` event | User retries | +| 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