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 Name | Agent | Domain |
|---|---|---|
synthesis | synthesis_agent | Investigation synthesis |
task_generator | task_generator | Follow-up task creation |
registry_investigation | registry_agent | Company registry OSINT |
belgian_investigation | belgian_agent | Belgian data collection |
belgian_gazette | belgian_scraping_agent | Belgian Gazette scrape |
belgian_gazette_detail | belgian_scraping_agent | Gazette detail extraction |
belgian_inhoudingsplicht | belgian_scraping_agent | Belgian tax/social debt |
adverse_media | adverse_media_agent | Adverse media scan |
social_intelligence | social_intelligence_agent | Social intelligence |
sanctions_resolver | sanctions_resolver_agent | Sanctions FP resolution |
person_validation | person_validation_agent | Natural-person validation |
mcc_classifier | mcc_classifier | Merchant categorization |
document_extractor | document_extractor | Document field extraction |
document_validator | document_validator | Doc quality / validation |
finding_debugger | finding_debugger | Signal / finding analysis |
case_intelligence | case_intelligence_agent | Decision support |
quality_scorer | quality_scorer | LLM-as-judge quality |
scan_synthesis | scan_synthesis_agent | Document scan synthesis |
dashboard | dashboard_agent | Copilot dashboard tools |
dashboard_stats | dashboard_stats_agent | Dashboard statistics |
memory_admin | memory_admin_agent | Compliance memory admin |
osint_legacy | osint_agent | Legacy OSINT prompt |
precious_metals_risk_assessment | risk_classifier | HVG/precious-metals risk |
customs_guarantee_extraction | guarantee_validator | Customs guarantee extraction |
customs_poa_classification | poa_classifier | Customs power-of-attorney |
shipment_document_classifier | shipment_classifier | Shipment doc classification |
shipment_invoice_extraction | shipment_extractor | Shipment invoice extraction |
shipment_packing_list_extraction | shipment_extractor | Packing list extraction |
shipment_bill_of_lading_extraction | shipment_extractor | Bill-of-lading extraction |
shipment_certificate_origin_extraction | shipment_extractor | Certificate-of-origin extraction |
shipment_transit_document_extraction | shipment_extractor | Transit 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
activeversion 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:
- A render is not an output. The record is published at the
returnof 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. - 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. - An aggregate row needs agreement. The
person_validationrow 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:
| Path | prompt_version_id | Why |
|---|---|---|
adverse_media | none | The 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, …) | none | The 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.