feat: per-user system prompt via compacted user-settings topic - #47
Conversation
Gated by ENABLE_USER_SETTING (default false). When enabled, the streams
app creates the compacted {AGENT_NAME}-user-settings topic (keyed by
user_id), materializes it as a KTable, and left-joins it into
FullSessionContext during enrichment. The think consumer uses the
joined system_prompt as the base prompt in place of the default
(SYSTEM_PROMPT_FILE / built-in); memoir context is still appended on
top. A tombstone reverts the user to the default prompt.
The enrichment processor now re-keys to user_id once when either memoir
or user settings is enabled, so enabling both costs a single
repartition, and the settings join works with MEMOIR_ENABLED=false.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds an optional per-user system prompt override sourced from a new compacted Kafka topic, joins it into the session context during stream enrichment, and applies it in the think consumer as a full replacement for the default base prompt (while still appending memoir context).
Changes:
- Introduces a new compacted
*-user-settingstopic (Topics.USER_SETTINGS) and aUserSettingsmodel, with topology wiring gated byENABLE_USER_SETTING. - Updates the processing enrichment topology to (optionally) left-join user settings by
user_idand carrysystem_promptthroughFullSessionContext. - Updates the think consumer to resolve the base prompt from the per-user override (null/blank fallback) and adds/extends tests for the new behavior.
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| think/think-consumer/src/test/java/io/flightdeck/think/consumer/SystemPromptTest.java | Adds tests for per-user base prompt override resolution and composition with memoir. |
| think/think-consumer/src/main/java/io/flightdeck/think/model/FullSessionContext.java | Adds nullable system_prompt to think-side context plus a compatibility constructor. |
| think/think-consumer/src/main/java/io/flightdeck/think/consumer/ThinkConsumer.java | Applies system_prompt override via resolveBasePrompt() when building the system prompt. |
| processor-apps/processing/src/test/java/io/flightdeck/streams/UserSettingTopologyTest.java | New topology-level tests ensuring user-settings wiring is present/absent based on the flag. |
| processor-apps/processing/src/test/java/io/flightdeck/streams/processors/EnrichInputMessageProcessorTest.java | Adds join semantics tests (present/absent/tombstone/isolation/memoir+settings). |
| processor-apps/processing/src/main/java/io/flightdeck/streams/processors/EnrichInputMessageProcessor.java | Adds optional user-settings join keyed by user_id and enriches FullSessionContext.system_prompt. |
| processor-apps/processing/src/main/java/io/flightdeck/streams/model/UserSettings.java | New model for compacted per-user settings records. |
| processor-apps/processing/src/main/java/io/flightdeck/streams/model/FullSessionContext.java | Adds nullable system_prompt to processing-side context. |
| processor-apps/processing/src/main/java/io/flightdeck/streams/FlightDeckStreamsApp.java | Adds ENABLE_USER_SETTING gating, topic creation, and KTable materialization. |
| processor-apps/processing/src/main/java/io/flightdeck/streams/config/Topics.java | Adds the USER_SETTINGS topic constant and documentation. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| void resolveBasePrompt_nullOrBlank_usesDefault() { | ||
| assertThat(ThinkConsumer.resolveBasePrompt(null)).contains("intelligent AI assistant"); | ||
| assertThat(ThinkConsumer.resolveBasePrompt("")).contains("intelligent AI assistant"); | ||
| assertThat(ThinkConsumer.resolveBasePrompt(" ")).contains("intelligent AI assistant"); | ||
| } |
| full.history().size(), | ||
| full.memoirContext() != null, | ||
| full.systemPrompt() != null, |
Demonstrates ENABLE_USER_SETTING end to end: a compacted
{AGENT_NAME}-user-settings topic keyed by user_id gives each user their
own system prompt, and a user with no record falls back to the
deployment default.
- seed job writes two personas (SQL tutor, haiku poet); a third user is
deliberately left unseeded to exercise the left-join null path
- default system prompt answers "I don't know." so the fallback is
visible in the output
- chat-api/processing/think-consumer build from source, since the
feature is not in the published images yet
- integration-test.sh asserts each user answered under its own prompt
and that personas do not cross
- document ENABLE_USER_SETTING in the root README config table
Verified against a local broker: the topic is created compacted, the
enriched context carries the right system_prompt per user (null for the
unseeded one), and message-output shows the three distinct answers.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
🔵 Needs a closer look
The new user-keyed join path has a confirmed key-collision risk when userId is missing, and the join/logging currently treats blank prompts inconsistently with the think-consumer’s “blank means fallback” semantics.
Review details
Suppressed comments (4)
Previously missed (2) — in code that hasn't changed since the last review.
processor-apps/processing/src/main/java/io/flightdeck/streams/processors/EnrichInputMessageProcessor.java:84
- The user-keyed join falls back to using sessionId as the join key when userId is null, which can accidentally attach another user's settings/memoir if a sessionId ever equals a real user_id. For user-keyed tables, it's safer to ensure the fallback key can never collide with a user_id (or to skip user-keyed joins when userId is missing).
This issue also appears on line 183 of the same file.
KStream<String, FullSessionContext> byUser = enriched
.selectKey((sessionId, full) ->
full.userId() != null ? full.userId() : sessionId);
think/think-consumer/src/main/java/io/flightdeck/think/consumer/ThinkConsumer.java:357
- Javadoc references
FullSessionContext.system_prompt, but the Java accessor issystemPrompt()(the underscore name is only the JSON field). Using the correct Java symbol avoids confusion and broken IDE links.
* Resolves the base system prompt for a turn: the per-user override from
* the user-settings topic (carried in {@code FullSessionContext.system_prompt})
* replaces the default entirely; null/blank falls back to SYSTEM_PROMPT_BASE.
*/
processor-apps/processing/src/main/java/io/flightdeck/streams/processors/EnrichInputMessageProcessor.java:118
has_system_promptis computed assystemPrompt() != null, so a settings record with a blank system_prompt will be logged as "has_system_prompt=true" even though the think-consumer treats blank as fallback. This makes the log signal inaccurate; use a non-blank check instead.
log.info("[{}] Enriched — history_size={} has_memoir={} has_system_prompt={} first_turn={}",
sessionId,
full.history().size(),
full.memoirContext() != null,
full.systemPrompt() != null,
processor-apps/processing/src/main/java/io/flightdeck/streams/processors/EnrichInputMessageProcessor.java:192
- The join currently propagates a blank
system_promptstring into FullSessionContext. Since the think-consumer treats blank the same as missing (fallback), normalizing blank-to-null here keeps semantics consistent and avoids confusing downstream logs/metrics.
static FullSessionContext enrichWithUserSettings(FullSessionContext enriched, UserSettings settings) {
return new FullSessionContext(
enriched.sessionId(),
enriched.userId(),
enriched.cost(),
enriched.history(),
enriched.latestInput(),
enriched.memoirContext(),
settings != null ? settings.systemPrompt() : null,
enriched.timestamp()
- Files reviewed: 17/17 changed files
- Comments generated: 0 new
- Review effort level: Lite
Two fixes from review of the settings join: - When user_id is null the join key falls back to session_id, so a settings record whose user_id equals that session_id would attach another user's system prompt to the turn. A null user_id can happen in practice — AggregateToolExecutionResultProcessor leaves it unset when a tool result arrives before its seed. Require a non-null user_id before applying an override. - Normalize a blank system_prompt to null, matching the think-consumer, which already treats blank as "no override". This also makes the has_system_prompt enrichment log accurate. Also corrects a javadoc reference to the JSON field name rather than the Java accessor. Tests: two regression cases (blank normalization, session_id/user_id collision). Processing 109/109, think-consumer 40/40. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Addressed the Copilot review (e061d61):
Not changed: the same Suites green: processing 109/109, think-consumer 40/40. |
Summary
Adds a per-user system prompt override, delivered through a new compacted Kafka topic and joined into the session context — mirroring the existing memoir pattern.
{AGENT_NAME}-user-settings(compacted, keyed byuser_id), value:{"system_prompt": "...", "updated_at": "..."}. A tombstone reverts the user to the default prompt.ENABLE_USER_SETTING(defaultfalse):true→ the streams app creates the topic if missing, materializes it as a KTable, and left-joins it intoFullSessionContextduring enrichment.false→ the topology never references the topic andsystem_promptstays null.system_promptis present and non-blank it replaces the default base prompt (SYSTEM_PROMPT_FILE/ built-in) entirely; memoir context is still appended on top.user_idonce when either memoir or user settings is enabled, so the settings join works withMEMOIR_ENABLED=falseand enabling both costs a single repartition.FullSessionContextmodels gained a nullablesystem_promptfield (@JsonIgnoreProperties(ignoreUnknown=true)on both sides), so mixed old/new deployments are safe. The think-side record keeps a 7-arg compatibility constructor.Settings update semantics
A KTable join uses the table state at message-flow time, so a settings update takes effect from the user's next turn.
Tests
UserSettingTopologyTest(new): flag on / on-without-memoir / off, including topology-description assertions that the store and topic are absent when disabled.EnrichInputMessageProcessorTest: join, absent record, tombstone revert, per-user isolation, memoir+settings combined.SystemPromptTest: override replaces default, null/blank falls back, override composes with memoir.🤖 Generated with Claude Code