Prompt System: Assembly, Overrides, Admin โ
TL;DR: The system prompt is assembled fresh for every message from up to five of 12 modules. Base always loads. Persona adds a role module. Detected intent adds a task module. Crisis overrides everything. Modules live in Postgres and as markdown files. File changes auto-seed on deploy without overwriting admin edits.
Code:
prompt-module.service.ts,persona-mapping.service.ts,prompts/service.ts,prompts/routes.ts.
Module types โ
Module type is a database enum. Type dictates when a module loads, not what it contains.
| Type | Can have multiple | Purpose | Members |
|---|---|---|---|
base | Yes | Foundation: always included, or conditionally always-on | base_core, confidence_signalling, tools_awareness, discovery |
role | No (exclusive) | Persona-specific communication style | role_parent, role_professional |
task | No (one per intent) | Intent-specific scaffolding | task_letter_writing, task_document_review, task_nd_referral, task_ehcp_writing |
crisis | No | Crisis override; displaces role plus task | crisis_response |
domain | Yes | Optional topic-specific layers | domain_social_services |
Enum definition, packages/api/src/services/prompt-module.service.ts:12:
export type PromptModuleType = "base" | "role" | "task" | "crisis" | "domain";Assembly order โ
Given a PromptAssemblyConfig, modules are loaded and concatenated in this order (prompt-module.service.ts:264โ346):
1. base_core [required; fallback if missing]
2. confidence_signalling [if active]
3. tools_awareness [if active]
if includeCrisis === true:
4. crisis_response [override; RETURN EARLY]
else:
4. discovery [if response-mode is GATHER]
5. role_parent OR role_professional [based on persona]
6. One task_* module [based on detected intent]
7. domain_*, domain_* ... [optional, from config.domainModules]Modules are joined with \n\n---\n\n separators so the LLM sees them as clearly delineated sections. Crisis mode does not include role or task. A parent in distress doesn't need a letter-writing scaffold or a Section-F template.
Persona detection โ role module โ
packages/api/src/services/persona-mapping.service.ts:48โ74
user.personaif the user set one explicitly (currently unused in the UI).- Fall back to
mapUserRoleToPersona(user.role):basic_userโ PARENTteacher_sencoโ PROFESSIONALadminโ PROFESSIONAL- unknown โ PARENT (safer default)
- PARENT loads
role_parent. PROFESSIONAL loadsrole_professional. Exactly one role module loads.
Intent detection โ task module โ
persona-mapping.service.ts:306โ347. Hierarchical, deterministic, no LLM call at this step:
| Priority | Intent | Signal |
|---|---|---|
| 1 | CRISIS | Self-harm, suicidal ideation, or acute distress regex |
| 2 | LETTER_WRITING | Draft-offer confirmation (bot asked "shall I draft this?" and user said "yes please") |
| 3 | DOCUMENT_REVIEW | Pasted-document detection (structural signals plus length) |
| 4 | ND_REFERRAL | ND-referral pattern match (autism, ADHD, assessment, Right to Choose) |
| 5 | SCHOOL_SEARCH | School-lookup keywords |
| 6 | GREETING | Greeting regex |
| 7 | GENERAL_QUERY | Default |
The intent-to-task-module mapping (prompt-module.service.ts:242โ252):
const taskMap: Partial<Record<ConversationIntent, string>> = {
[ConversationIntent.LETTER_WRITING]: "task_letter_writing",
[ConversationIntent.DOCUMENT_REVIEW]: "task_document_review",
[ConversationIntent.ND_REFERRAL]: "task_nd_referral",
};task_ehcp_writing has no intent mapping in the current code. It's either triggered manually or reached via a path not covered by detectIntentFromMessage. Documented for completeness, but not currently auto-loaded.
Crisis override โ
Crisis is a hard override. When config.includeCrisis === true (set by the crisis detector in crisis-response.ts), the assembler:
- Loads
base_core + confidence_signalling + tools_awareness + crisis_response. - Returns immediately. No role. No task. No domain.
- Logs
"[SAFEGUARDING] Crisis indicators detected, loading crisis module".
This is deliberate. The crisis module's response template is trauma-informed, helpline-first, and steers away from SEND mechanics until the user is safe. A normal role or task layer would dilute that.
The helpline block itself is emitted deterministically from socket.ts and ai-service.ts. See Safety & Grounding. The crisis prompt module is a secondary layer. It shapes the LLM's reasoning about the crisis so that follow-up responses remain safe.
Storage: database and files โ
Both. Every module exists as a .md file in packages/api/src/prompts/modules/ and as a row in the PromptModule Postgres table. The file is the seed source. The database is the runtime source.
Self-healing seed (prompt-module.service.ts:148โ162):
- On first load, compare DB module names against the file manifest.
- For any names in the manifest but missing from the database,
INSERT ... ON CONFLICT DO NOTHING. - Never
UPDATE. Admin edits survive deploys.
This lets us ship a new module (say, confidence_signalling.md) in code and have every environment pick it up without manual seed runs, while preserving any edits admins have made to existing modules.
Tests in packages/api/src/services/__tests__/prompt-module.test.ts verify:
- Self-heal inserts missing modules, exactly one
INSERTper missing module. - Re-running seed with all modules present performs no writes.
- The SQL is
ON CONFLICT DO NOTHING, neverDO UPDATE. Admin edits are sacred.
Admin management โ
All routes under /api/admin/prompts (packages/api/src/prompts/routes.ts). authenticate + authorize('admin') middleware:
| Endpoint | Method | Purpose |
|---|---|---|
/admin/prompts/modules | GET | List all modules |
/admin/prompts/modules/:id | GET | Fetch one module |
/admin/prompts/modules/:id | PATCH | Update content / description / isActive (version auto-increments, cache invalidates) |
/admin/prompts/modules/:id/toggle | POST | Flip isActive (blocks toggling base_core off) |
/admin/prompts/modules/preview | POST | Preview the assembled prompt for given persona, intent, and crisis flags |
Admin UI: /admin/prompts in the web app.
- Browse the 12 modules with type, load order, and active status.
- Edit content inline as markdown. Saves increment
version, bust the cache, and take effect on the next message. - Toggle any non-core module inactive for A/B-style tests.
base_coreis protected. - Preview: pick persona, intent, crisis yes or no, and see the exact prompt that would be assembled, with character count.
PromptTemplate (legacy single-blob model) and LLMSettings (active LLM config) are managed from the same admin surface via /admin/prompts/templates and /admin/prompts/settings.
Active LLM settings โ
Alongside module management, admins control model parameters via the LLMSettings table. One row is isActive = true at any time. Fields:
model: defaultgpt-4oin seed, current prod isgpt-4.1temperature: default 0.0 (deterministic)topP: default 1.0 (must be 1.0 when temperature = 0; validated atprompts/service.ts:178โ212)topK: optionalmaxTokens: default 2048streaming: default true
LangChainService caches the active settings for 60 seconds, so changes propagate within a minute.
Tests โ
packages/api/src/services/__tests__/prompt-module.test.ts. Assembly and self-heal behaviour.packages/api/tests/unit/services/prompt-module-integrity.test.ts. Module content integrity.packages/api/tests/unit/services/prompt-module.service.test.ts. Loader service coverage.packages/api/tests/unit/prompts/service.test.ts. Template and settings CRUD.packages/api/tests/unit/prompts/routes.test.ts. Admin route contracts.
Further reading โ
- Prompt Modules. Per-module reference with excerpts.
- Agentic RAG. When this assembled prompt is actually used.
- Safety & Grounding. How crisis detection triggers the override.