Skip to content

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 stack

Module types โ€‹

Module type is a database enum. Type dictates when a module loads, not what it contains.

TypeCan have multiplePurposeMembers
baseYesFoundation: always included, or conditionally always-onbase_core, confidence_signalling, tools_awareness, discovery
roleNo (exclusive)Persona-specific communication stylerole_parent, role_professional
taskNo (one per intent)Intent-specific scaffoldingtask_letter_writing, task_document_review, task_nd_referral, task_ehcp_writing
crisisNoCrisis override; displaces role plus taskcrisis_response
domainYesOptional topic-specific layersdomain_social_services

Enum definition, packages/api/src/services/prompt-module.service.ts:12:

typescript
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

  1. user.persona if the user set one explicitly (currently unused in the UI).
  2. Fall back to mapUserRoleToPersona(user.role):
    • basic_user โ†’ PARENT
    • teacher_senco โ†’ PROFESSIONAL
    • admin โ†’ PROFESSIONAL
    • unknown โ†’ PARENT (safer default)
  3. PARENT loads role_parent. PROFESSIONAL loads role_professional. Exactly one role module loads.

Intent detection โ†’ task module โ€‹

persona-mapping.service.ts:306โ€“347. Hierarchical, deterministic, no LLM call at this step:

PriorityIntentSignal
1CRISISSelf-harm, suicidal ideation, or acute distress regex
2LETTER_WRITINGDraft-offer confirmation (bot asked "shall I draft this?" and user said "yes please")
3DOCUMENT_REVIEWPasted-document detection (structural signals plus length)
4ND_REFERRALND-referral pattern match (autism, ADHD, assessment, Right to Choose)
5SCHOOL_SEARCHSchool-lookup keywords
6GREETINGGreeting regex
7GENERAL_QUERYDefault

The intent-to-task-module mapping (prompt-module.service.ts:242โ€“252):

typescript
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:

  1. Loads base_core + confidence_signalling + tools_awareness + crisis_response.
  2. Returns immediately. No role. No task. No domain.
  3. 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):

  1. On first load, compare DB module names against the file manifest.
  2. For any names in the manifest but missing from the database, INSERT ... ON CONFLICT DO NOTHING.
  3. 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 INSERT per missing module.
  • Re-running seed with all modules present performs no writes.
  • The SQL is ON CONFLICT DO NOTHING, never DO UPDATE. Admin edits are sacred.

Admin management โ€‹

All routes under /api/admin/prompts (packages/api/src/prompts/routes.ts). authenticate + authorize('admin') middleware:

EndpointMethodPurpose
/admin/prompts/modulesGETList all modules
/admin/prompts/modules/:idGETFetch one module
/admin/prompts/modules/:idPATCHUpdate content / description / isActive (version auto-increments, cache invalidates)
/admin/prompts/modules/:id/togglePOSTFlip isActive (blocks toggling base_core off)
/admin/prompts/modules/previewPOSTPreview 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_core is 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: default gpt-4o in seed, current prod is gpt-4.1
  • temperature: default 0.0 (deterministic)
  • topP: default 1.0 (must be 1.0 when temperature = 0; validated at prompts/service.ts:178โ€“212)
  • topK: optional
  • maxTokens: default 2048
  • streaming: 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 โ€‹

Confidential ยท Spectrum Dynamics CIC