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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ All configuration is done via environment variables in the `.env` file. See [`.e
| `MEMOIR_ENABLED` | `true` | Enable per-user long-term memory across sessions. Set to `false` to disable. |
| `MEMOIR_SESSION_INACTIVITY_THRESHOLD_SECONDS` | `20` | Seconds of inactivity before a session ends and memoir is saved. Only applies when `MEMOIR_ENABLED=true`. |
| `MEMOIR_SESSION_PUNCTUATE_INTERVAL_SECONDS` | `5` | How often (in seconds) to check for inactive sessions. Only applies when `MEMOIR_ENABLED=true`. |
| `ENABLE_USER_SETTING` | `false` | Enable per-user settings from the compacted `{AGENT_NAME}-user-settings` topic (keyed by `user_id`). When on, a user's `system_prompt` replaces the default prompt for that user. See [`examples/per-user-system-prompt`](examples/per-user-system-prompt). |
| `INPUT_TOKEN_PRICE` | *(optional)* | Price per 1M input tokens (e.g. `3` for $3/MTok). If not set, cost tracking is disabled. |
| `OUTPUT_TOKEN_PRICE` | *(optional)* | Price per 1M output tokens (e.g. `15` for $15/MTok). If not set, cost tracking is disabled. |
| `BUDGET_PRICE_PER_SESSION` | *(optional)* | Maximum dollar cost allowed per session. When the session cost goes over this limit, the agent stops processing on the next Think layer. Requires token prices to be set. |
Expand Down
8 changes: 8 additions & 0 deletions examples/per-user-system-prompt/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# Required — your Anthropic API key
CLAUDE_API_KEY=your-api-key-here

# Optional — override the default Claude model
# CLAUDE_MODEL=claude-sonnet-5

# Optional — topic prefix for this agent (also used by the seed job)
# AGENT_NAME=per-user-prompt
189 changes: 189 additions & 0 deletions examples/per-user-system-prompt/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
# Per-User System Prompt

One agent, one deployment — but every user gets their own system prompt, keyed
by `user_id` in a compacted Kafka topic. A user with no record falls back to the
agent's default prompt, which here is deliberately useless: it answers
`I don't know.` to everything.

## What it does

Two users are seeded into `{AGENT_NAME}-user-settings`, a third is left out:

| user_id | system prompt | answer to *"my orders table is slow, what should I do?"* |
|---------|---------------|-----------------------------------------------------|
| `user_alice` | SQL tutor persona (seeded) | `[SQL-TUTOR] Add an index on …` |
| `user_bob` | haiku poet persona (seeded) | `[HAIKU]` + a three-line haiku |
| `user_carol` | *(no record)* | `I don't know.` |

Same agent, same question, same session pipeline — the only difference is which
system prompt the think-consumer resolves for that user.

## How it works

```
POST /api/chat {user_id: "user_alice", ...}
message-input ──► processing (Kafka Streams)
│ re-key by user_id
│ left-join {AGENT_NAME}-user-settings (KTable, compacted)
│ re-key back to session_id
enriched-message-input { …, "system_prompt": "You are Ada …" }
think-consumer
system_prompt present ? use it : use SYSTEM_PROMPT_FILE
```

**The topic.** `{AGENT_NAME}-user-settings` is compacted and keyed by `user_id`:

```
key: user_alice
value: {"system_prompt":"You are Ada, a senior database engineer …","updated_at":"2026-08-23T00:00:00Z"}
```

Compaction means the topic *is* the current settings table: the latest record
per user is retained forever, and a tombstone (null value) deletes a user's
override so they revert to the default prompt.

**The join.** With `ENABLE_USER_SETTING=true`, the processing app creates the
topic if missing, materializes it as a `KTable`, and left-joins it into
`FullSessionContext` during enrichment. Left-join is the point: a user with no
record still flows through, just with `system_prompt: null`.

**The resolution.** In the think-consumer, a non-blank `system_prompt` from the
context *replaces* the base prompt (`SYSTEM_PROMPT_FILE`, here
[`system-prompt.txt`](./system-prompt.txt)) entirely — it is not appended to it.
Null or blank falls back to the file.

**Timing.** The KTable is read at message-flow time, so a settings update takes
effect on the user's **next** turn, not the one already in flight.

## Configuration

| Parameter | Service | Value | Why |
|-----------|---------|-------|-----|
| `ENABLE_USER_SETTING` | `processing` | `true` | Creates the topic, materializes the KTable, joins it into the session context |
| `MEMOIR_ENABLED` | `processing` | `false` | Off, so the only per-user difference is the system prompt |
| `SYSTEM_PROMPT_FILE` | `think-consumer` | `/app/system-prompt.txt` | The default for users with no settings record |

`ENABLE_USER_SETTING` defaults to `false`; with it off, the topology never
references the topic at all and `system_prompt` stays null for everyone.

## Services

`chat-api`, `processing` and `think-consumer` are **built from source** — the
per-user system prompt is not in the published images yet, and all three must
share one build. Only the frontend uses a published image.

| Service | Description |
|---------|-------------|
| `api` | Chat API (REST + WebSocket) |
| `processing` | Kafka Streams pipeline with `ENABLE_USER_SETTING=true` |
| `think-consumer` | Claude API caller; resolves the per-user prompt |
| `user-settings-seed` | One-shot job that writes the two seeded users, then exits |
| `frontend` | Web UI |

## Run

```bash
cp .env.example .env
# Edit .env and add your CLAUDE_API_KEY

docker compose up --build
```

Ask the same question as each user:

```bash
curl -s localhost:8000/api/chat -H 'Content-Type: application/json' -d '{
"session_id": "s-alice-1", "user_id": "user_alice",
"content": "My orders table has 50 million rows and one query over it is slow. What should I do?"}'

curl -s localhost:8000/api/chat -H 'Content-Type: application/json' -d '{
"session_id": "s-bob-1", "user_id": "user_bob",
"content": "My orders table has 50 million rows and one query over it is slow. What should I do?"}'

curl -s localhost:8000/api/chat -H 'Content-Type: application/json' -d '{
"session_id": "s-carol-1", "user_id": "user_carol",
"content": "My orders table has 50 million rows and one query over it is slow. What should I do?"}'
```

Read the answers off the output topic:

```bash
docker compose exec kafka /opt/kafka/bin/kafka-console-consumer.sh \
--bootstrap-server localhost:9092 \
--topic per-user-prompt-message-output --from-beginning --timeout-ms 5000
```

Alice gets a terse SQL answer tagged `[SQL-TUTOR]`, Bob gets a haiku tagged
`[HAIKU]`, and Carol — who has no settings record — gets `I don't know.`

The compose file defaults to `claude-opus-5`; override with `CLAUDE_MODEL` in
`.env` (and adjust `INPUT_TOKEN_PRICE` / `OUTPUT_TOKEN_PRICE`, which are per 1M
tokens, to match).

### From the browser

The web UI at [http://localhost](http://localhost) does not send a `user_id`, so
chat-api defaults it to `user_42`, which is unseeded — the UI will answer
`I don't know.` until you give that user a prompt:

```bash
docker compose exec -T kafka /opt/kafka/bin/kafka-console-producer.sh \
--bootstrap-server localhost:9092 \
--topic per-user-prompt-user-settings --property parse.key=true <<'REC'
user_42 {"system_prompt":"You are Rex, a pirate captain. Answer everything in pirate speak, in two sentences or less.","updated_at":"2026-08-23T00:00:00Z"}
REC
```

(The key and the JSON value are separated by a literal **tab**.) Send another
message from the UI and the pirate answers.

### Reverting a user

A tombstone — a record with a null value — drops the override, and that user
goes back to the default prompt:

```bash
docker compose exec -T kafka /opt/kafka/bin/kafka-console-producer.sh \
--bootstrap-server localhost:9092 \
--topic per-user-prompt-user-settings \
--property parse.key=true --property null.marker=NULL <<'REC'
user_42 NULL
REC
```

### Inspecting the settings table

```bash
docker compose exec kafka /opt/kafka/bin/kafka-console-consumer.sh \
--bootstrap-server localhost:9092 \
--topic per-user-prompt-user-settings --from-beginning \
--property print.key=true --timeout-ms 5000
```

## Seeding your own users

[`seed-data/seed-user-settings.sh`](./seed-data/seed-user-settings.sh) is the
one-shot seed job: it waits for the compacted topic to exist, then produces one
keyed record per user. Add a user by adding a prompt variable and a `printf`
line — prompts are JSON string bodies, so use `\n` for line breaks and avoid raw
double quotes.

In a real deployment this topic is written by whatever owns user preferences —
an admin UI, a settings service, a CDC stream off your users table — not by a
shell script.

## Automated test

```bash
CLAUDE_API_KEY=sk-ant-... ./integration-test.sh
```

Brings the stack up, waits for the seed, asks all three users the same question,
and asserts each answered under its own prompt (and that Carol fell back to
`I don't know.`). Tears the stack down on exit.
132 changes: 132 additions & 0 deletions examples/per-user-system-prompt/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
# Per-user system prompt — one system prompt per user id, delivered through the
# compacted {AGENT_NAME}-user-settings topic.
#
# user_alice → [SQL-TUTOR] persona (seeded)
# user_bob → [HAIKU] persona (seeded)
# user_carol → no record → default prompt: "I don't know."
#
# chat-api, processing AND think-consumer are BUILT FROM SOURCE because the
# per-user system prompt (ENABLE_USER_SETTING) is not in the published images
# yet. All three must share the same build — mixing a source-built processing
# with a published think-consumer drops the system_prompt field on the way
# through. Only the frontend uses a published image.

services:

# ─── Infrastructure ──────────────────────────────────────────────────────────

kafka:
image: apache/kafka:4.1.1
hostname: kafka
ports:
- "9092:9092"
environment:
KAFKA_NODE_ID: 1
KAFKA_PROCESS_ROLES: broker,controller
KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: CONTROLLER:PLAINTEXT,INTERNAL:PLAINTEXT,EXTERNAL:PLAINTEXT
KAFKA_LISTENERS: INTERNAL://kafka:29092,EXTERNAL://0.0.0.0:9092,CONTROLLER://kafka:9093
KAFKA_ADVERTISED_LISTENERS: INTERNAL://kafka:29092,EXTERNAL://localhost:9092
KAFKA_INTER_BROKER_LISTENER_NAME: INTERNAL
KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER
KAFKA_CONTROLLER_QUORUM_VOTERS: 1@kafka:9093
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1
KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1
KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS: 0
KAFKA_AUTO_CREATE_TOPICS_ENABLE: "true"
KAFKA_LOG_DIRS: /var/lib/kafka/data
CLUSTER_ID: MkU3OEVBNTcwNTJENDM2Qk
healthcheck:
test: ["CMD", "/opt/kafka/bin/kafka-topics.sh", "--bootstrap-server", "localhost:9092", "--list"]
interval: 10s
timeout: 10s
retries: 10
start_period: 30s

# ─── Application Services ───────────────────────────────────────────────────

api:
build:
context: ../../api/chat-api
image: flightdeck-usersettings/chat-api:local
ports:
- "8000:8000"
- "8001:8001"
environment:
KAFKA_BOOTSTRAP_SERVERS: kafka:29092
AGENT_NAME: ${AGENT_NAME:-per-user-prompt}
PORT: 8000
WS_PORT: 8001
depends_on:
kafka:
condition: service_healthy

processing:
build:
context: ../../processor-apps/processing
image: flightdeck-usersettings/processing:local
environment:
KAFKA_BOOTSTRAP_SERVERS: kafka:29092
AGENT_NAME: ${AGENT_NAME:-per-user-prompt}
# The feature under demo: materialize {AGENT_NAME}-user-settings as a
# KTable keyed by user_id and left-join it into the enriched context.
ENABLE_USER_SETTING: "true"
# Long-term memory is orthogonal to this example — keep it off so the
# only thing that varies between users is their system prompt.
MEMOIR_ENABLED: "false"
depends_on:
kafka:
condition: service_healthy

think-consumer:
build:
context: ../../think/think-consumer
image: flightdeck-usersettings/think-consumer:local
volumes:
- ./system-prompt.txt:/app/system-prompt.txt:ro
environment:
KAFKA_BOOTSTRAP_SERVERS: kafka:29092
AGENT_NAME: ${AGENT_NAME:-per-user-prompt}
CLAUDE_API_KEY: ${CLAUDE_API_KEY}
CLAUDE_MODEL: ${CLAUDE_MODEL:-claude-opus-5}
CLAUDE_MAX_TOKENS: ${CLAUDE_MAX_TOKENS:-2048}
# Prices are per 1M tokens, matching the model above.
INPUT_TOKEN_PRICE: ${INPUT_TOKEN_PRICE:-5}
OUTPUT_TOKEN_PRICE: ${OUTPUT_TOKEN_PRICE:-25}
BUDGET_PRICE_PER_SESSION: ${BUDGET_PRICE_PER_SESSION:-0.5}
# The fallback used for any user with no user-settings record. A user's
# own system_prompt REPLACES this file entirely.
SYSTEM_PROMPT_FILE: /app/system-prompt.txt
depends_on:
kafka:
condition: service_healthy

# ─── Seed: one system prompt per user ───────────────────────────────────────
# Waits for processing to create the compacted user-settings topic, then
# produces one keyed record per configured user and exits.
user-settings-seed:
image: apache/kafka:4.1.1
entrypoint: ["/bin/bash", "/seed/seed-user-settings.sh"]
volumes:
- ./seed-data:/seed:ro
environment:
KAFKA_BOOTSTRAP_SERVERS: kafka:29092
AGENT_NAME: ${AGENT_NAME:-per-user-prompt}
restart: "no"
depends_on:
kafka:
condition: service_healthy
processing:
condition: service_started

# ─── Frontend ────────────────────────────────────────────────────────────────
# Note: the web UI does not send a user_id, so chat-api defaults it to
# user_42 — an unseeded user, which is the "I don't know." path. Give user_42
# a prompt (see the README) to drive a persona from the browser.

frontend:
image: ghcr.io/tsuz/flightdeck/frontend:${FLIGHTDECK_VERSION:-latest}
ports:
- "80:80"
depends_on:
- api
Loading
Loading