Skip to main content

Prompt Management

Atlas ships 31 named AI agent prompt templates (plus 5 reusable _-prefixed partials) in app/prompts/templates/, spanning ~25 distinct agents. The Prompt Management system centralizes these prompts with version control, DB-backed storage, and EU AI Act traceability — every AI output is linked to the exact prompt version that produced it.

Architecture Evolution

Prompt Registry

The PromptRegistry singleton (app/prompts/registry.py) manages all named prompts with a layered loading strategy. Filesystem .jinja2 templates form the baseline; active rows from the prompt_versions table are overlaid on top (DB-first, FS fallback) and refreshed on a 60-second TTL:

Prompt Inventory (31 named templates)

Each template's name and agent are declared in its YAML frontmatter. The name is the key passed to registry.render(name, ...). Files prefixed with _ are partials included via {% include %} and are not directly renderable.

Prompt NameAgentDomain
synthesissynthesis_agentInvestigation synthesis
task_generatortask_generatorFollow-up task creation
registry_investigationregistry_agentCompany registry OSINT
belgian_investigationbelgian_agentBelgian data collection
belgian_gazettebelgian_scraping_agentBelgian Gazette scrape
belgian_gazette_detailbelgian_scraping_agentGazette detail extraction
belgian_inhoudingsplichtbelgian_scraping_agentBelgian tax/social debt
adverse_mediaadverse_media_agentAdverse media scan
social_intelligencesocial_intelligence_agentSocial intelligence
sanctions_resolversanctions_resolver_agentSanctions FP resolution
person_validationperson_validation_agentNatural-person validation
mcc_classifiermcc_classifierMerchant categorization
document_extractordocument_extractorDocument field extraction
document_validatordocument_validatorDoc quality / validation
finding_debuggerfinding_debuggerSignal / finding analysis
case_intelligencecase_intelligence_agentDecision support
quality_scorerquality_scorerLLM-as-judge quality
scan_synthesisscan_synthesis_agentDocument scan synthesis
dashboarddashboard_agentCopilot dashboard tools
dashboard_statsdashboard_stats_agentDashboard statistics
memory_adminmemory_admin_agentCompliance memory admin
osint_legacyosint_agentLegacy OSINT prompt
precious_metals_risk_assessmentrisk_classifierHVG/precious-metals risk
customs_guarantee_extractionguarantee_validatorCustoms guarantee extraction
customs_poa_classificationpoa_classifierCustoms power-of-attorney
shipment_document_classifiershipment_classifierShipment doc classification
shipment_invoice_extractionshipment_extractorShipment invoice extraction
shipment_packing_list_extractionshipment_extractorPacking list extraction
shipment_bill_of_lading_extractionshipment_extractorBill-of-lading extraction
shipment_certificate_origin_extractionshipment_extractorCertificate-of-origin extraction
shipment_transit_document_extractionshipment_extractorTransit document extraction

Partials (5): _eea_registries, _guardrails, _minimum_sources, _regulatory_basis, _severity_matrix — shared fragments included by other templates; they carry no frontmatter.

Database Model

Key constraints:

  • UNIQUE(name, version_number) — no duplicate versions
  • Partial unique index: only one active version per prompt name
  • status IN ('active', 'draft', 'archived') — check constraint
  • Immutable rows — once created, template_body never changes (append-only pattern)

EU AI Act Traceability

Every AI agent execution now links to the exact prompt version that produced it:

Since #369 the contextvar (_current_prompt_provenance) carries a full frozen PromptProvenance record — prompt_name, version_number, version_id, content_hash, source ("db" | "filesystem") — not a bare version id. Evidence bundles (EvidenceBundleService.build_bundle) stamp the same record onto prompt_name/prompt_version/prompt_version_id/prompt_content_hash/ prompt_source. In filesystem-fallback mode version_id stays None (a DB version is never fabricated); the content hash carries reproducibility. The sync DB load requires psycopg2-binary (declared in pyproject.toml) — its absence silently killed the DB-first registry (regression guarded by tests/test_prompt_provenance.py::test_registry_loads_from_db_over_sync_engine).

Provenance is carried, never reconstructed

The contextvar records the LAST render in a task's context, so it is only a fallback — and one that stops at a task boundary. The OSINT phase-3 agents run inside asyncio.gather / asyncio.wait_for child tasks, so a record they set there never reaches the recorder in the parent. Each runner therefore carries its own record out explicitly: run_registry_agent, run_person_validation_agent, run_social_intelligence_agent and run_synthesis_agent take a provenance_sink list and append the PromptProvenance of the render that produced the output they return.

A runner that returns without rendering — person validation with no directors or no BrightData token, social intelligence with an unreachable provider, the registry router falling through to the NorthData scraper after discarding the MCP agent's result — appends nothing. The recorder is then told so explicitly via update_status(..., prompt_provenance=None, prompt_provenance_resolved=True), which suppresses the ambient fallback so a matching record from an unrelated render can never be attributed to that execution, and writes the column to NULL — the write is an upsert, so a version stamped by an earlier attempt on the same (case_id, iteration, agent_name) row would otherwise survive and be presented as this output's provenance.

Three rules make "the render that produced the output" precise:

  1. A render is not an output. The record is published at the return of the accepted model output, never at render time. A BrightData hard-timeout, an MCP transport failure, a retry-exhausted run and an inbound Model Envelope rejection each return a synthetic gap object the fallback code built — and the caller records those returns as a successful execution. Publishing at render time would credit the template for text the model never produced; for the envelope it would be flatly contradictory, since ADR-0145 discards a rejected response whole precisely so no part of it counts as the model's word.
  2. A retried run records the attempt that survived. Synthesis re-renders on the Letta-capture timeout and on run_with_validation's output-validation retries, so the sink holds the discarded attempts too. returned_render() takes the last record; the timeout handler additionally clears the sink before re-running.
  3. An aggregate row needs agreement. The person_validation row merges the output of N concurrent director batches, each rendering independently. uniform_render() returns the version only when every contributing render agrees; if a version was activated (or the cache reloaded) between two batch renders it records nothing and logs the divergence, because no single prompt produced the aggregate.

Both selectors live in app/prompts/provenance_sink.py; reading a sink by index is the round-2 defect in one expression and is pinned against by tests/test_prompt_provenance.py::test_no_agent_reads_a_provenance_sink_by_position.

Reconstructing provenance after the fact (looking up "which version is served for this template now") is deliberately not available: it cannot know whether that version rendered, whether a different template rendered instead, or whether the cache refreshed mid-run. Two recorded gaps follow from this rule:

Pathprompt_version_idWhy
adverse_medianoneThe phase-2 analysis LLM is built from the hard-coded ANALYSIS_PROMPT; the governed adverse_media template is a different, tool-driven search prompt it never renders.
registry on a country provider (BE, CZ, FR, …)noneThe provider renders its own template (e.g. belgian_investigation), not registry_investigation; carrying it would require threading the sink through the 14-provider interface.

An empty cell means "we did not record this". It never means "this ran under some prompt we guessed at".

What Article 12 coverage this actually gives

EU AI Act Article 12 (automatic logging) is satisfied for the covered paths, and the qualifier is load-bearing: on a covered path an auditor can follow an agent_executions row to the exact prompt text, version, model and input that produced it.

The two rows in the table above are not covered. For an adverse_media run and for a registry run on a country provider, prompt_version_id is empty, so an auditor cannot follow the execution row to the exact prompt — the prompt text that ran is recoverable only from the source at that commit, not from the record. Stating this as a limitation is the point: an empty cell means "we did not record this", and a page claiming blanket coverage over a table that documents two gaps would overstate the implemented posture (Codex #881).

Closing them is tracked work, not a documentation change: adverse_media needs its phase-2 analysis LLM moved off the hard-coded ANALYSIS_PROMPT and onto the governed template; registry needs the provenance sink threaded through the 14-provider interface.

Admin API

Implemented in app/api/admin_prompts.py, mounted at prefix /api/admin/prompts:

GET /api/admin/prompts # List all prompts with active versions
GET /api/admin/prompts/{name} # Get prompt (active version)
GET /api/admin/prompts/{name}/versions # List all versions
GET /api/admin/prompts/{name}/versions/{version} # Get a specific version
POST /api/admin/prompts/{name}/draft # Create a new draft version
POST /api/admin/prompts/{name}/activate/{version} # Activate a version (deactivates previous)
POST /api/admin/prompts/{name}/archive/{version} # Archive a version
GET /api/admin/prompts/{name}/diff # Diff two versions

Authorization: the entire router is guarded by Depends(require_role("super_admin"))super_admin role only.

Jinja2 Template Format

Templates use Jinja2 with YAML frontmatter:

---
name: synthesis
version: 3
agent: synthesis_agent
description: Main synthesis prompt for investigation analysis
variables:
- company_name
- country
- risk_context
- regulatory_context
---
You are a compliance investigation analyst for {{ company_name }}.
Country: {{ country }}

{% if risk_context %}
Risk context from prior investigations:
{{ risk_context }}
{% endif %}

{% if regulatory_context %}
Applicable regulations:
{{ regulatory_context | truncate_json }}
{% endif %}

Custom Jinja2 filters (registered in PromptRegistry.__init__): truncate_json (serialize + truncate large context dicts) and inhoudingsplicht_status (build Belgian tax/social debt status text). The tojson policy is also overridden with a datetime-aware serializer so KBO/NBB date objects render cleanly.

Parity Testing

Every prompt migration from inline strings to templates includes a parity test that proves zero behavioral change:

def test_synthesis_parity():
"""Template renders identically to the original inline prompt."""
original = _get_original_inline_prompt()
rendered = registry.render("synthesis", test_vars)
assert rendered.strip() == original.strip()

The parity suite (tests/test_prompts/test_prompt_parity.py) holds one test per prompt that was migrated from an inline string, ensuring the migration is invisible to the AI agents. Prompts authored as templates from the start (e.g. the customs/shipment extractors) have no inline original and so carry no parity test.