Skip to content

Commit 9518f3a

Browse files
authored
Merge pull request #47 from tsuz/feat/user-settings
feat: per-user system prompt via compacted user-settings topic
2 parents 0838dc1 + e061d61 commit 9518f3a

17 files changed

Lines changed: 1064 additions & 25 deletions

File tree

‎README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ All configuration is done via environment variables in the `.env` file. See [`.e
9999
| `MEMOIR_ENABLED` | `true` | Enable per-user long-term memory across sessions. Set to `false` to disable. |
100100
| `MEMOIR_SESSION_INACTIVITY_THRESHOLD_SECONDS` | `20` | Seconds of inactivity before a session ends and memoir is saved. Only applies when `MEMOIR_ENABLED=true`. |
101101
| `MEMOIR_SESSION_PUNCTUATE_INTERVAL_SECONDS` | `5` | How often (in seconds) to check for inactive sessions. Only applies when `MEMOIR_ENABLED=true`. |
102+
| `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). |
102103
| `INPUT_TOKEN_PRICE` | *(optional)* | Price per 1M input tokens (e.g. `3` for $3/MTok). If not set, cost tracking is disabled. |
103104
| `OUTPUT_TOKEN_PRICE` | *(optional)* | Price per 1M output tokens (e.g. `15` for $15/MTok). If not set, cost tracking is disabled. |
104105
| `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. |
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# Required — your Anthropic API key
2+
CLAUDE_API_KEY=your-api-key-here
3+
4+
# Optional — override the default Claude model
5+
# CLAUDE_MODEL=claude-sonnet-5
6+
7+
# Optional — topic prefix for this agent (also used by the seed job)
8+
# AGENT_NAME=per-user-prompt
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
# Per-User System Prompt
2+
3+
One agent, one deployment — but every user gets their own system prompt, keyed
4+
by `user_id` in a compacted Kafka topic. A user with no record falls back to the
5+
agent's default prompt, which here is deliberately useless: it answers
6+
`I don't know.` to everything.
7+
8+
## What it does
9+
10+
Two users are seeded into `{AGENT_NAME}-user-settings`, a third is left out:
11+
12+
| user_id | system prompt | answer to *"my orders table is slow, what should I do?"* |
13+
|---------|---------------|-----------------------------------------------------|
14+
| `user_alice` | SQL tutor persona (seeded) | `[SQL-TUTOR] Add an index on …` |
15+
| `user_bob` | haiku poet persona (seeded) | `[HAIKU]` + a three-line haiku |
16+
| `user_carol` | *(no record)* | `I don't know.` |
17+
18+
Same agent, same question, same session pipeline — the only difference is which
19+
system prompt the think-consumer resolves for that user.
20+
21+
## How it works
22+
23+
```
24+
POST /api/chat {user_id: "user_alice", ...}
25+
│
26+
▼
27+
message-input ──► processing (Kafka Streams)
28+
│
29+
│ re-key by user_id
30+
│ left-join {AGENT_NAME}-user-settings (KTable, compacted)
31+
│ re-key back to session_id
32+
▼
33+
enriched-message-input { …, "system_prompt": "You are Ada …" }
34+
│
35+
▼
36+
think-consumer
37+
system_prompt present ? use it : use SYSTEM_PROMPT_FILE
38+
```
39+
40+
**The topic.** `{AGENT_NAME}-user-settings` is compacted and keyed by `user_id`:
41+
42+
```
43+
key: user_alice
44+
value: {"system_prompt":"You are Ada, a senior database engineer …","updated_at":"2026-08-23T00:00:00Z"}
45+
```
46+
47+
Compaction means the topic *is* the current settings table: the latest record
48+
per user is retained forever, and a tombstone (null value) deletes a user's
49+
override so they revert to the default prompt.
50+
51+
**The join.** With `ENABLE_USER_SETTING=true`, the processing app creates the
52+
topic if missing, materializes it as a `KTable`, and left-joins it into
53+
`FullSessionContext` during enrichment. Left-join is the point: a user with no
54+
record still flows through, just with `system_prompt: null`.
55+
56+
**The resolution.** In the think-consumer, a non-blank `system_prompt` from the
57+
context *replaces* the base prompt (`SYSTEM_PROMPT_FILE`, here
58+
[`system-prompt.txt`](./system-prompt.txt)) entirely — it is not appended to it.
59+
Null or blank falls back to the file.
60+
61+
**Timing.** The KTable is read at message-flow time, so a settings update takes
62+
effect on the user's **next** turn, not the one already in flight.
63+
64+
## Configuration
65+
66+
| Parameter | Service | Value | Why |
67+
|-----------|---------|-------|-----|
68+
| `ENABLE_USER_SETTING` | `processing` | `true` | Creates the topic, materializes the KTable, joins it into the session context |
69+
| `MEMOIR_ENABLED` | `processing` | `false` | Off, so the only per-user difference is the system prompt |
70+
| `SYSTEM_PROMPT_FILE` | `think-consumer` | `/app/system-prompt.txt` | The default for users with no settings record |
71+
72+
`ENABLE_USER_SETTING` defaults to `false`; with it off, the topology never
73+
references the topic at all and `system_prompt` stays null for everyone.
74+
75+
## Services
76+
77+
`chat-api`, `processing` and `think-consumer` are **built from source** — the
78+
per-user system prompt is not in the published images yet, and all three must
79+
share one build. Only the frontend uses a published image.
80+
81+
| Service | Description |
82+
|---------|-------------|
83+
| `api` | Chat API (REST + WebSocket) |
84+
| `processing` | Kafka Streams pipeline with `ENABLE_USER_SETTING=true` |
85+
| `think-consumer` | Claude API caller; resolves the per-user prompt |
86+
| `user-settings-seed` | One-shot job that writes the two seeded users, then exits |
87+
| `frontend` | Web UI |
88+
89+
## Run
90+
91+
```bash
92+
cp .env.example .env
93+
# Edit .env and add your CLAUDE_API_KEY
94+
95+
docker compose up --build
96+
```
97+
98+
Ask the same question as each user:
99+
100+
```bash
101+
curl -s localhost:8000/api/chat -H 'Content-Type: application/json' -d '{
102+
"session_id": "s-alice-1", "user_id": "user_alice",
103+
"content": "My orders table has 50 million rows and one query over it is slow. What should I do?"}'
104+
105+
curl -s localhost:8000/api/chat -H 'Content-Type: application/json' -d '{
106+
"session_id": "s-bob-1", "user_id": "user_bob",
107+
"content": "My orders table has 50 million rows and one query over it is slow. What should I do?"}'
108+
109+
curl -s localhost:8000/api/chat -H 'Content-Type: application/json' -d '{
110+
"session_id": "s-carol-1", "user_id": "user_carol",
111+
"content": "My orders table has 50 million rows and one query over it is slow. What should I do?"}'
112+
```
113+
114+
Read the answers off the output topic:
115+
116+
```bash
117+
docker compose exec kafka /opt/kafka/bin/kafka-console-consumer.sh \
118+
--bootstrap-server localhost:9092 \
119+
--topic per-user-prompt-message-output --from-beginning --timeout-ms 5000
120+
```
121+
122+
Alice gets a terse SQL answer tagged `[SQL-TUTOR]`, Bob gets a haiku tagged
123+
`[HAIKU]`, and Carol — who has no settings record — gets `I don't know.`
124+
125+
The compose file defaults to `claude-opus-5`; override with `CLAUDE_MODEL` in
126+
`.env` (and adjust `INPUT_TOKEN_PRICE` / `OUTPUT_TOKEN_PRICE`, which are per 1M
127+
tokens, to match).
128+
129+
### From the browser
130+
131+
The web UI at [http://localhost](http://localhost) does not send a `user_id`, so
132+
chat-api defaults it to `user_42`, which is unseeded — the UI will answer
133+
`I don't know.` until you give that user a prompt:
134+
135+
```bash
136+
docker compose exec -T kafka /opt/kafka/bin/kafka-console-producer.sh \
137+
--bootstrap-server localhost:9092 \
138+
--topic per-user-prompt-user-settings --property parse.key=true <<'REC'
139+
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"}
140+
REC
141+
```
142+
143+
(The key and the JSON value are separated by a literal **tab**.) Send another
144+
message from the UI and the pirate answers.
145+
146+
### Reverting a user
147+
148+
A tombstone — a record with a null value — drops the override, and that user
149+
goes back to the default prompt:
150+
151+
```bash
152+
docker compose exec -T kafka /opt/kafka/bin/kafka-console-producer.sh \
153+
--bootstrap-server localhost:9092 \
154+
--topic per-user-prompt-user-settings \
155+
--property parse.key=true --property null.marker=NULL <<'REC'
156+
user_42 NULL
157+
REC
158+
```
159+
160+
### Inspecting the settings table
161+
162+
```bash
163+
docker compose exec kafka /opt/kafka/bin/kafka-console-consumer.sh \
164+
--bootstrap-server localhost:9092 \
165+
--topic per-user-prompt-user-settings --from-beginning \
166+
--property print.key=true --timeout-ms 5000
167+
```
168+
169+
## Seeding your own users
170+
171+
[`seed-data/seed-user-settings.sh`](./seed-data/seed-user-settings.sh) is the
172+
one-shot seed job: it waits for the compacted topic to exist, then produces one
173+
keyed record per user. Add a user by adding a prompt variable and a `printf`
174+
line — prompts are JSON string bodies, so use `\n` for line breaks and avoid raw
175+
double quotes.
176+
177+
In a real deployment this topic is written by whatever owns user preferences —
178+
an admin UI, a settings service, a CDC stream off your users table — not by a
179+
shell script.
180+
181+
## Automated test
182+
183+
```bash
184+
CLAUDE_API_KEY=sk-ant-... ./integration-test.sh
185+
```
186+
187+
Brings the stack up, waits for the seed, asks all three users the same question,
188+
and asserts each answered under its own prompt (and that Carol fell back to
189+
`I don't know.`). Tears the stack down on exit.
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
# Per-user system prompt — one system prompt per user id, delivered through the
2+
# compacted {AGENT_NAME}-user-settings topic.
3+
#
4+
# user_alice → [SQL-TUTOR] persona (seeded)
5+
# user_bob → [HAIKU] persona (seeded)
6+
# user_carol → no record → default prompt: "I don't know."
7+
#
8+
# chat-api, processing AND think-consumer are BUILT FROM SOURCE because the
9+
# per-user system prompt (ENABLE_USER_SETTING) is not in the published images
10+
# yet. All three must share the same build — mixing a source-built processing
11+
# with a published think-consumer drops the system_prompt field on the way
12+
# through. Only the frontend uses a published image.
13+
14+
services:
15+
16+
# ─── Infrastructure ──────────────────────────────────────────────────────────
17+
18+
kafka:
19+
image: apache/kafka:4.1.1
20+
hostname: kafka
21+
ports:
22+
- "9092:9092"
23+
environment:
24+
KAFKA_NODE_ID: 1
25+
KAFKA_PROCESS_ROLES: broker,controller
26+
KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: CONTROLLER:PLAINTEXT,INTERNAL:PLAINTEXT,EXTERNAL:PLAINTEXT
27+
KAFKA_LISTENERS: INTERNAL://kafka:29092,EXTERNAL://0.0.0.0:9092,CONTROLLER://kafka:9093
28+
KAFKA_ADVERTISED_LISTENERS: INTERNAL://kafka:29092,EXTERNAL://localhost:9092
29+
KAFKA_INTER_BROKER_LISTENER_NAME: INTERNAL
30+
KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER
31+
KAFKA_CONTROLLER_QUORUM_VOTERS: 1@kafka:9093
32+
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
33+
KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1
34+
KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1
35+
KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS: 0
36+
KAFKA_AUTO_CREATE_TOPICS_ENABLE: "true"
37+
KAFKA_LOG_DIRS: /var/lib/kafka/data
38+
CLUSTER_ID: MkU3OEVBNTcwNTJENDM2Qk
39+
healthcheck:
40+
test: ["CMD", "/opt/kafka/bin/kafka-topics.sh", "--bootstrap-server", "localhost:9092", "--list"]
41+
interval: 10s
42+
timeout: 10s
43+
retries: 10
44+
start_period: 30s
45+
46+
# ─── Application Services ───────────────────────────────────────────────────
47+
48+
api:
49+
build:
50+
context: ../../api/chat-api
51+
image: flightdeck-usersettings/chat-api:local
52+
ports:
53+
- "8000:8000"
54+
- "8001:8001"
55+
environment:
56+
KAFKA_BOOTSTRAP_SERVERS: kafka:29092
57+
AGENT_NAME: ${AGENT_NAME:-per-user-prompt}
58+
PORT: 8000
59+
WS_PORT: 8001
60+
depends_on:
61+
kafka:
62+
condition: service_healthy
63+
64+
processing:
65+
build:
66+
context: ../../processor-apps/processing
67+
image: flightdeck-usersettings/processing:local
68+
environment:
69+
KAFKA_BOOTSTRAP_SERVERS: kafka:29092
70+
AGENT_NAME: ${AGENT_NAME:-per-user-prompt}
71+
# The feature under demo: materialize {AGENT_NAME}-user-settings as a
72+
# KTable keyed by user_id and left-join it into the enriched context.
73+
ENABLE_USER_SETTING: "true"
74+
# Long-term memory is orthogonal to this example — keep it off so the
75+
# only thing that varies between users is their system prompt.
76+
MEMOIR_ENABLED: "false"
77+
depends_on:
78+
kafka:
79+
condition: service_healthy
80+
81+
think-consumer:
82+
build:
83+
context: ../../think/think-consumer
84+
image: flightdeck-usersettings/think-consumer:local
85+
volumes:
86+
- ./system-prompt.txt:/app/system-prompt.txt:ro
87+
environment:
88+
KAFKA_BOOTSTRAP_SERVERS: kafka:29092
89+
AGENT_NAME: ${AGENT_NAME:-per-user-prompt}
90+
CLAUDE_API_KEY: ${CLAUDE_API_KEY}
91+
CLAUDE_MODEL: ${CLAUDE_MODEL:-claude-opus-5}
92+
CLAUDE_MAX_TOKENS: ${CLAUDE_MAX_TOKENS:-2048}
93+
# Prices are per 1M tokens, matching the model above.
94+
INPUT_TOKEN_PRICE: ${INPUT_TOKEN_PRICE:-5}
95+
OUTPUT_TOKEN_PRICE: ${OUTPUT_TOKEN_PRICE:-25}
96+
BUDGET_PRICE_PER_SESSION: ${BUDGET_PRICE_PER_SESSION:-0.5}
97+
# The fallback used for any user with no user-settings record. A user's
98+
# own system_prompt REPLACES this file entirely.
99+
SYSTEM_PROMPT_FILE: /app/system-prompt.txt
100+
depends_on:
101+
kafka:
102+
condition: service_healthy
103+
104+
# ─── Seed: one system prompt per user ───────────────────────────────────────
105+
# Waits for processing to create the compacted user-settings topic, then
106+
# produces one keyed record per configured user and exits.
107+
user-settings-seed:
108+
image: apache/kafka:4.1.1
109+
entrypoint: ["/bin/bash", "/seed/seed-user-settings.sh"]
110+
volumes:
111+
- ./seed-data:/seed:ro
112+
environment:
113+
KAFKA_BOOTSTRAP_SERVERS: kafka:29092
114+
AGENT_NAME: ${AGENT_NAME:-per-user-prompt}
115+
restart: "no"
116+
depends_on:
117+
kafka:
118+
condition: service_healthy
119+
processing:
120+
condition: service_started
121+
122+
# ─── Frontend ────────────────────────────────────────────────────────────────
123+
# Note: the web UI does not send a user_id, so chat-api defaults it to
124+
# user_42 — an unseeded user, which is the "I don't know." path. Give user_42
125+
# a prompt (see the README) to drive a persona from the browser.
126+
127+
frontend:
128+
image: ghcr.io/tsuz/flightdeck/frontend:${FLIGHTDECK_VERSION:-latest}
129+
ports:
130+
- "80:80"
131+
depends_on:
132+
- api

0 commit comments

Comments
 (0)