fix(ai): full-sweep audit — placeholders only in system prompts + CI guardrail
All checks were successful
Mirror to GitHub / mirror (push) Successful in 10s

The "AI parrots example content from system prompt" bug bit us twice in
one day across two different prompt sites. Patching individual prompts
is treating the symptom; this commit makes the rule structural.

Audit + sanitize:
- assistant_chat_service.ASSISTANT_SYSTEM_PROMPT — already cleaned in
  prior commits, but the [FORK] schema still had literal "Brief reason"
  / "Short name" / "One sentence" placeholders. Replaced with
  <angle-bracket> placeholders. Anti-parrot rule itself rewritten to
  describe the failure mode abstractly instead of naming "jsmith" so
  the rule no longer trips the guardrail (and so the model doesn't
  see "jsmith" as a token at all).
- ai_chat_service.py — removed three concrete-example offenders:
  "Get-Service ADSync" command literal, the "DC01 server_name" intake
  form payload (in two places), and the inline interview demos using
  "Azure AD Sync failures" / "Exchange Online mailbox migration".
  Replaced with technology-neutral schema descriptions.
- ai_tree_generator_service.BRANCH_DETAIL_SYSTEM_PROMPT — replaced the
  fully-fleshed DNS troubleshooting tree (with literal Dnscache /
  ipconfig / google.com / Start-Service) with a placeholder schema
  showing only ID-linkage shape.
- kb_conversion_service.PROCEDURAL_SYSTEM_PROMPT — replaced the worked
  Server Manager + DC01 example payload with a placeholder schema.

Guardrail (tests/test_prompt_anti_parrot.py):
- Imports every module under app/services/ and app/core/ and walks
  every uppercase string constant ending in _PROMPT, _SCHEMA,
  _PROTOCOL, _FORMAT, or _CONTEXT.
- test 1: known-leaked-token list (jsmith, DC01, ADSync, Dnscache,
  google.com, "Outlook keeps", "Teams drops") must not appear in any
  prompt constant. Add to the list when a new leak shows up in prod —
  the list IS the audit trail.
- test 2: marker blocks ([QUESTIONS], [ACTIONS], [SUGGEST_FIX], etc.)
  must contain placeholders only. Distinguishes JSON keys (followed
  by ':', allowed) from JSON values (followed by ',' / ']' / '}',
  must be <placeholder>); allows pipe-separated enum types
  (text|password|select) and a small set of fixed enum values
  (question, diagnostic_check, decision, action, ...). Verified by
  feeding the test a known-bad block — caught it correctly.

Documented the rule in CLAUDE.md → AI / FlowPilot lessons, naming
the test as the enforcement point so future contributors know how to
extend it (add to the known-leaked list when a new leak surfaces).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-22 02:09:30 -04:00
parent 50215b9110
commit d0ebdef9e8
6 changed files with 223 additions and 54 deletions

View File

@@ -179,6 +179,7 @@ python -m scripts.seed_trees # seed (from
- **Model tier routing:** `settings.get_model_for_action(action_type)`. Always alias form (`claude-sonnet-4-6`).
- **FlowPilot must ask GUI-vs-script before suggesting either** when both are viable — see `FLOWPILOT_SYSTEM_PROMPT` in `flowpilot_engine.py`.
- **Telemetry events to grep:** `anthropic.cache` (prompt-cache hit/create), `mcp.turn` (per-turn MCP availability), `mcp.fallback` (MCP silent-retry fired).
- **Don't put literal payloads in system prompts.** Bit us twice in one day: a worked `[QUESTIONS]` example with literal "Outlook + jsmith" content, and a full DNS troubleshooting tree, both caused Claude to recite that content on unrelated tickets — the symptom looked like task-lane state leaking across chats. The fix is structural: every output example in a system prompt uses `<placeholder>` syntax (`{"text": "<one short, specific question>"}`), never literal field values. Real-looking format examples live in few-shot messages (separate file, separate code path), not system prompts. Guardrail: `tests/test_prompt_anti_parrot.py` scans every `*_PROMPT`/`*_SCHEMA`/`*_PROTOCOL`/`*_FORMAT` constant in `app/services/` and `app/core/`; CI fails when a marker block contains a literal JSON value or when a known leaked token (jsmith, DC01, ADSync, Dnscache, etc.) appears anywhere in a prompt.
### Frontend / UI

View File

@@ -40,7 +40,7 @@ CRITICAL BEHAVIORS:
- Act as a senior engineer, not a chatbot. Use your domain knowledge to SUGGEST diagnostic steps, not just record what the user says.
- When the user describes a problem area, demonstrate understanding by naming specific sub-categories, common causes, and relevant tools.
- Challenge assumptions constructively: "Before we go down that path, have you considered checking X first? In my experience, that resolves 60% of these cases."
- Capture SPECIFIC commands with exact syntax. Not "check the service" but "Get-Service ADSync | Select-Object Status, StartType".
- Capture SPECIFIC commands with exact syntax (PowerShell/CLI invocations the engineer would actually paste into a shell), not vague directives like "check the service".
- Include expected outcomes for every action: what does success look like?
- Surface edge cases proactively: "What about multi-forest environments?" or "Does this change if they have conditional access policies?"
- Explain WHY the diagnostic order matters: "We check connectivity before auth because a network issue masquerades as an auth failure."
@@ -74,7 +74,7 @@ STRUCTURAL RULES:
- All IDs must be unique strings (use descriptive slugs like "check-service-status")
CROSS-REFERENCE / LOOP-BACK PATTERN:
When a troubleshooting path needs to loop back (e.g., after remediation, re-verify from an earlier checkpoint), set next_node_id to the target node's ID. Example: an action node "restart-ssh-service" can set next_node_id to "verify-ssh-connection" (an ancestor decision node) to create a re-verification loop.
When a troubleshooting path needs to loop back (e.g., after remediation, re-verify from an earlier checkpoint), set next_node_id to the target node's ID — including ancestor decision nodes for re-verification loops. The target ID must already exist somewhere in the tree.
"""
INTERVIEW_PROTOCOL = """
@@ -85,7 +85,7 @@ Ask broad questions to understand the problem domain and scope:
- What type of issue is this flow for?
- Who is the target audience? (Tier 1 help desk, Tier 2, Tier 3?)
- What environment assumptions? (On-prem, hybrid, specific vendors?)
Demonstrate domain expertise immediately. If the user says "Azure AD Sync failures," show understanding: "Are you primarily seeing password hash sync issues, object attribute sync failures, or full directory sync errors?"
Demonstrate domain expertise immediately. When the user names a technology, ask a follow-up that proves you know its common failure modes — a sub-categorization question that only someone fluent in that area would think to ask. Use vocabulary native to whatever the user actually mentioned, not stock examples from past conversations.
DO NOT emit [TREE_UPDATE] during scoping. You are still understanding the problem.
PHASE 2 - DISCOVERY (current_phase: discovery):
@@ -130,7 +130,7 @@ Your response is natural conversational text. When the tree structure changes, i
3. Metadata capture (when you learn the flow's name, description, or tags):
[METADATA]
{"name": "...", "description": "...", "tags": ["..."]}
{"name": "<flow name>", "description": "<one-sentence summary>", "tags": ["<tag1>", "<tag2>"]}
[/METADATA]
IMPORTANT:
@@ -172,8 +172,8 @@ STRUCTURAL RULES:
- All IDs must be unique descriptive slugs (e.g., "check-dns-resolution", not UUIDs)
- The last step MUST be type "procedure_end"
- Use section_headers to organize steps into logical phases
- Commands are arrays of objects: [{"code": "Get-Service ADSync", "label": "Check sync service", "language": "powershell"}]
- Descriptions support [VAR:variable_name] interpolation for intake form variables (e.g., "Connect to [VAR:server_name] via RDP")
- Commands are arrays of objects: [{"code": "<exact command>", "label": "<short label>", "language": "powershell|bash|cmd"}]
- Descriptions support [VAR:variable_name] interpolation for intake form variables. Pick variable names that fit the procedure being built — do not reuse names from prior conversations.
VARIABLE INTERPOLATION:
When the procedure needs per-execution input (server name, IP address, client name, etc.), use [VAR:variable_name] syntax in descriptions and commands. These map to intake form fields that the engineer fills in before starting.
@@ -188,7 +188,7 @@ Understand the process being documented:
- Who will execute it? (Tier 1 help desk, Tier 2, senior engineers?)
- What environment context? (Specific vendor, on-prem vs cloud, tools available?)
- Will this need per-execution input? (server name, client info, IP addresses → intake form fields)
Demonstrate domain expertise: if the user says "Exchange Online mailbox migration," show understanding: "Are we covering full tenant-to-tenant migration, on-prem to Exchange Online cutover, or individual mailbox moves with hybrid?"
Demonstrate domain expertise: when the user names a process, ask a sub-categorization question that distinguishes which variant of that process they mean (the variants will differ by technology — use vocabulary specific to whatever the user mentioned, not examples from prior chats).
DO NOT emit [STEPS_UPDATE] during scoping. You are still understanding the process.
PHASE 2 - DISCOVERY (current_phase: discovery):
@@ -238,12 +238,12 @@ Your response is natural conversational text. When the step structure changes, i
3. Metadata capture (when you learn the flow's name, description, or tags):
[METADATA]
{"name": "...", "description": "...", "tags": ["..."]}
{"name": "<flow name>", "description": "<one-sentence summary>", "tags": ["<tag1>", "<tag2>"]}
[/METADATA]
4. Intake form suggestion (when intake form fields are identified):
[INTAKE_FORM]
[{"variable_name": "server_name", "label": "Server Name", "field_type": "text", "required": true, "placeholder": "e.g., DC01", "group_name": "Server Details", "display_order": 1}]
[{"variable_name": "<snake_case_name>", "label": "<Human Label>", "field_type": "text|password|select|textarea|number|boolean", "required": true|false, "placeholder": "<short hint, optional>", "group_name": "<section heading, optional>", "display_order": <integer>}]
[/INTAKE_FORM]
IMPORTANT:
@@ -659,12 +659,12 @@ Requirements:
Also provide metadata as a separate JSON object after the steps:
[METADATA]
{"name": "...", "description": "...", "tags": ["..."]}
{"name": "<flow name>", "description": "<one-sentence summary>", "tags": ["<tag1>", "<tag2>"]}
[/METADATA]
If we discussed intake form fields, also include:
[INTAKE_FORM]
[{"variable_name": "server_name", "label": "Server Name", "field_type": "text", "required": true, "placeholder": "e.g., DC01", "group_name": "Server Details", "display_order": 1}]
[{"variable_name": "<snake_case_name>", "label": "<Human Label>", "field_type": "text|password|select|textarea|number|boolean", "required": true|false, "placeholder": "<short hint, optional>", "group_name": "<section heading, optional>", "display_order": <integer>}]
[/INTAKE_FORM]"""
else:
generation_instruction = """Based on our entire conversation, generate the COMPLETE and FINAL TreeStructure JSON for this flow.
@@ -681,7 +681,7 @@ Requirements:
Also provide metadata as a separate JSON object after the tree:
[METADATA]
{"name": "...", "description": "...", "tags": ["..."]}
{"name": "<flow name>", "description": "<one-sentence summary>", "tags": ["<tag1>", "<tag2>"]}
[/METADATA]"""
provider_messages.append({"role": "user", "content": generation_instruction})

View File

@@ -89,8 +89,10 @@ Additional rules:
5. Use unique node IDs prefixed with the branch context (e.g., "gpo-check-link")
6. Build the tree bottom-up in your head: create solution/leaf nodes first, then build parent nodes referencing their IDs
Few-shot example showing correct action node next_node_id usage:
{"id": "dns-root", "type": "decision", "question": "Can the client resolve any DNS names?", "help_text": "Run: nslookup google.com", "options": [{"id": "dns-opt-none", "label": "No — nslookup times out or returns 'server failed'", "next_node_id": "dns-check-service"}, {"id": "dns-opt-partial", "label": "Some names resolve but others fail", "next_node_id": "dns-check-specific"}], "children": [{"id": "dns-check-service", "type": "action", "title": "Check DNS Client Service", "description": "Verify the DNS Client service is running on the affected machine", "commands": ["Get-Service -Name Dnscache | Select-Object Status,StartType"], "expected_outcome": "Status should be Running", "next_node_id": "dns-service-solution"}, {"id": "dns-service-solution", "type": "solution", "title": "DNS Service Was Stopped", "description": "The DNS Client service was stopped, preventing all name resolution", "resolution_steps": ["Run: Start-Service Dnscache", "Set startup type: Set-Service Dnscache -StartupType Automatic", "Flush cache: ipconfig /flushdns", "Test: nslookup google.com"]}, {"id": "dns-check-specific", "type": "solution", "title": "Selective DNS Failure — Stale or Missing Records", "description": "Some records resolve correctly, indicating DNS is functional but specific records are stale or missing", "resolution_steps": ["Check DNS server for missing A/CNAME records", "Clear DNS cache on the DNS server: Clear-DnsServerCache", "Flush client cache: ipconfig /flushdns", "Verify with: nslookup <failing-hostname>"]}]}"""
SHAPE-ONLY schema example (do not copy this content verbatim — it shows
how IDs link, NOT what to ask or run; your real tree must reflect the
branch the user described):
{"id": "<root-slug>", "type": "decision", "question": "<diagnostic question for THIS branch>", "help_text": "<optional hint>", "options": [{"id": "<opt-1>", "label": "<observable answer 1>", "next_node_id": "<child-1>"}, {"id": "<opt-2>", "label": "<observable answer 2>", "next_node_id": "<child-2>"}], "children": [{"id": "<child-1>", "type": "action", "title": "<what to do>", "description": "<details>", "commands": ["<exact command for THIS branch>"], "expected_outcome": "<what success looks like>", "next_node_id": "<sibling-id>"}, {"id": "<sibling-id>", "type": "solution", "title": "<resolution title>", "description": "<resolution description>", "resolution_steps": ["<step 1>", "<step 2>"]}, {"id": "<child-2>", "type": "solution", "title": "<other resolution>", "description": "<...>", "resolution_steps": ["<step 1>"]}]}"""
CORRECTIVE_PROMPT_TEMPLATE = """Your previous JSON was invalid for ResolutionFlow's tree schema.

View File

@@ -153,48 +153,29 @@ Identify values that would change between executions (server names, IPs, usernam
## Output Format
Return a JSON object:
Return a JSON object with this SHAPE (DO NOT copy the placeholders below
verbatim — fill each field with content derived from the actual KB article
the engineer attached, NOT from this schema):
```json
{
"title": "Procedure title derived from the article",
"description": "Brief description of what this procedure accomplishes",
"title": "<procedure title derived from the article>",
"description": "<brief description of what this procedure accomplishes>",
"steps": [
{
"id": "unique-step-id",
"type": "step",
"content": "Open Server Manager and navigate to Add Roles on [VAR:server_name]",
"confidence": 0.95,
"source_excerpt": "Step 1: Open Server Manager on DC01..."
},
{
"id": "warning-dns",
"type": "warning",
"content": "WARNING: This will restart DNS and cause brief connectivity loss",
"confidence": 0.90,
"source_excerpt": "Note: Restarting DNS will cause a brief outage"
},
{
"id": "section-verification",
"type": "section_header",
"content": "Verification Steps",
"confidence": 1.0,
"source_excerpt": "Verification"
"id": "<unique-kebab-case-id>",
"type": "step|warning|section_header",
"content": "<step body — may include [VAR:<your_variable>] interpolation>",
"confidence": <float 0.0-1.0>,
"source_excerpt": "<the verbatim sentence/phrase from the article that this step came from>"
}
],
"intake_form": [
{
"variable_name": "server_name",
"label": "Server Name",
"field_type": "text",
"required": true,
"display_order": 1
},
{
"variable_name": "ip_address",
"label": "IP Address",
"field_type": "text",
"required": true,
"display_order": 2
"variable_name": "<snake_case_name fitting THIS procedure>",
"label": "<Human Label>",
"field_type": "text|password|select|textarea|number|boolean",
"required": true|false,
"display_order": <integer>
}
]
}

View File

@@ -247,7 +247,7 @@ the most relevant branch to investigate first.
To create a fork, append this marker AFTER your [QUESTIONS]/[ACTIONS] markers:
[FORK]
{"fork_reason": "Brief reason", "options": [{"label": "Short name", "description": "One sentence"}, {"label": "Another", "description": "One sentence"}]}
{"fork_reason": "<one short sentence: why these branches need independent investigation>", "options": [{"label": "<short hypothesis name for branch 1>", "description": "<one sentence: what this branch will check>"}, {"label": "<branch 2 name>", "description": "<...>"}]}
[/FORK]
2-4 options. Never mention "fork", "branch", or "path" in your visible text.
@@ -274,11 +274,12 @@ ANTI-PARROT RULE: The schemas above use placeholders in `<angle brackets>` to sh
the SHAPE of valid output. Your real questions, actions, facts, and suggested fixes \
must be derived from the engineer's CURRENT message — never copy placeholder text, \
never reuse content from a prior unrelated session, never invent ticket-specific \
details (usernames, hostnames, error codes, application names) that the engineer \
has not stated. If the engineer asks about printers, do not produce questions about \
Outlook. If the engineer says nothing about a user named jsmith, never name jsmith \
in your output. Tickets unrelated to the example domains in the schema must produce \
questions and actions from THAT ticket's domain.
details (usernames, hostnames, IPs, error codes, application names, ticket numbers) \
that the engineer has not stated. The technology, vocabulary, and named entities in \
your output must match the technology, vocabulary, and named entities in the \
engineer's most recent message. If the engineer's ticket is about a different \
domain than the last ticket you saw, your output must reflect the new domain\
do not let the previous ticket's specifics bleed into the new one.
"""

View File

@@ -0,0 +1,184 @@
"""Guardrail: literal output payloads must not live in any LLM system prompt.
This test exists because the same anti-pattern bit us twice in the same
day: a worked example with literal content (Outlook + jsmith + literal
JSON; full DNS troubleshooting tree) sitting inside a `*_PROMPT` constant
caused Claude to recite that content on unrelated tickets, making the
task lane look like it was leaking previous-session data.
The fix is structural: every output example in a system prompt must use
`<placeholder>` or `<...>` syntax, never literal field values, command
names, hostnames, or usernames that the model could parrot. Format
examples that need real-looking content live in few-shot messages
(separate file, separate code path, model treats them as past behavior),
not in system prompts.
Failure messages here name the constant + line; fix by replacing the
literal payload with a placeholder schema, or by moving the example
out of the system prompt entirely.
See CLAUDE.md → Critical Lessons → "Don't put literal payloads in
system prompts" for the longer rationale.
"""
from __future__ import annotations
import importlib
import inspect
import pkgutil
import re
from typing import Iterator
import pytest
# Modules to scan. We deliberately import the modules (not just walk source
# files) so we get the actual string values of `*_PROMPT` constants — which
# may be assembled from concat / .format() / f-strings.
_MODULE_PACKAGES = ("app.services", "app.core")
def _iter_prompt_constants() -> Iterator[tuple[str, str, str]]:
"""Yield (module_name, constant_name, value) for every uppercase string
constant whose name ends in `_PROMPT` (or `_SCHEMA`/`_PROTOCOL`/`_FORMAT`
— same anti-pattern risk).
Skips modules that fail to import to keep the test resilient when an
individual module has unrelated breakage.
"""
suffixes = ("_PROMPT", "_SCHEMA", "_PROTOCOL", "_FORMAT", "_CONTEXT")
for pkg_name in _MODULE_PACKAGES:
pkg = importlib.import_module(pkg_name)
for mod_info in pkgutil.iter_modules(pkg.__path__, prefix=f"{pkg_name}."):
try:
mod = importlib.import_module(mod_info.name)
except Exception:
continue
for name, value in inspect.getmembers(mod):
if not name.isupper() or not name.endswith(suffixes):
continue
if not isinstance(value, str):
continue
yield mod_info.name, name, value
# ── The forbidden patterns ──────────────────────────────────────────────────
# A literal username pattern that Claude has historically parroted across
# unrelated tickets. The list isn't exhaustive — it's the exact strings
# we've seen leak. Add to it if a new one shows up in production.
_FORBIDDEN_LITERAL_TOKENS: tuple[str, ...] = (
"jsmith", # leaked from an Outlook/AD example
"DC01", # leaked from an intake-form example
"ADSync", # leaked from a commands-array example
"Dnscache", # leaked from a DNS troubleshooting tree example
"google.com", # leaked from a DNS troubleshooting tree example
"Outlook keeps", "Teams drops", # specific phrasings from a worked Outlook/WiFi example
)
# Marker-with-payload patterns. A `[QUESTIONS]\n[{...JSON with real field values...}]`
# block in a prompt is the highest-risk shape — the model treats it as a
# canonical response template. We allow placeholder content (anything inside
# angle brackets `<...>` is treated as a placeholder, not a literal).
#
# Restrictions on the regex (to avoid false positives where the marker name
# appears in prose like "include [QUESTIONS] markers"):
# - opening tag must be at start of string OR preceded by newline/whitespace
# AND followed by newline+JSON-ish content
# - block content must START with `[` or `{` after optional whitespace,
# so prose blocks (like the closing-tag-distance regex match across
# markdown headings) are excluded
_MARKER_BLOCK_RE = re.compile(
r"(?:^|\n)\[(QUESTIONS|ACTIONS|SUGGEST_FIX|PROMOTE|FORK|TREE_UPDATE|STEPS_UPDATE|INTAKE_FORM|METADATA|DELTA)\]"
r"\s*\n" # forced newline before content
r"(\s*[\[{][\s\S]*?)" # content must start with [ or {
r"\s*\n\[/\1\]"
)
# Heuristic: only flag JSON VALUES, not JSON KEYS. Keys are followed by `:`,
# values come after `: ` (object value) or are bare strings inside an array.
# The shape we're defending against is `{"text": "Is this user on a laptop?"}` —
# the value `"Is this user on a laptop?"` is a literal payload the model will
# recite. Keys like `"text"` are part of the schema and must stay literal.
#
# Matches a quoted string that has at least 3 chars, no angle brackets, and
# is followed by a JSON value-terminator (`,` `]` `}`) — i.e. NOT followed
# by `:` (which would mark it as a key).
_QUOTED_VALUE_RE = re.compile(
r'"([^"<>][^"<>]{2,}?)"\s*(?=[,\]\}])'
)
# Substrings that, if PRESENT in the candidate value, indicate it's a
# placeholder marker rather than literal output. Be strict — broad markers
# like "?" alone would whitelist any sentence ending in a question mark,
# defeating the test's purpose.
_PLACEHOLDER_HINTS = ("...", "snake_case", "kebab-case", "<", "TODO")
# Schema enum-like values that are part of the format spec, not parrotable text.
_ALLOWED_ENUM_VALUES = frozenset({
"text", "password", "select", "boolean", "number", "textarea", "multi_text",
"powershell", "bash", "cmd", "python",
"question", "diagnostic_check", "user_note", "ai_synthesis",
"decision", "action", "solution", "procedure_step", "section_header", "procedure_end",
"step", "warning",
})
def _block_has_literal_payload(block_body: str) -> tuple[bool, str | None]:
"""Return (True, offending_string) if the marker block looks like literal output."""
for m in _QUOTED_VALUE_RE.finditer(block_body):
s = m.group(1).strip()
if not s:
continue
# Pure placeholder hints — accept.
if any(h in s for h in _PLACEHOLDER_HINTS):
continue
# Pipe-separated enum like `text|password|select` — schema spec.
if "|" in s:
continue
# Single-word enum value we explicitly allow.
if s in _ALLOWED_ENUM_VALUES:
continue
# JSON ellipsis-style placeholders, ".." etc.
if all(c in "._" for c in s):
continue
return True, s
return False, None
# ── Tests ──────────────────────────────────────────────────────────────────
def test_no_known_leaked_literal_tokens_in_prompts() -> None:
"""Constants must not contain strings the model has historically parroted.
Adding a new entry to _FORBIDDEN_LITERAL_TOKENS after a production leak is
the right way to extend coverage — keep this list as the audit trail.
"""
failures: list[str] = []
for module_name, const_name, value in _iter_prompt_constants():
for token in _FORBIDDEN_LITERAL_TOKENS:
if token in value:
failures.append(
f"{module_name}.{const_name} contains forbidden literal token "
f"{token!r} — replace with a <placeholder>. See CLAUDE.md → "
f"'Don't put literal payloads in system prompts'."
)
assert not failures, "\n".join(failures)
def test_marker_blocks_in_prompts_use_placeholders_not_literal_payloads() -> None:
"""Every marker block in a system prompt must contain placeholders only.
A block like `[QUESTIONS]\\n[{"text": "Is this user on a laptop or desktop?"}]\\n[/QUESTIONS]`
will be recited verbatim by Claude on unrelated tickets. Use angle-bracket
placeholders instead: `[{"text": "<one short, specific question>"}]`.
"""
failures: list[str] = []
for module_name, const_name, value in _iter_prompt_constants():
for m in _MARKER_BLOCK_RE.finditer(value):
marker = m.group(1)
body = m.group(2)
has_literal, offender = _block_has_literal_payload(body)
if has_literal:
failures.append(
f"{module_name}.{const_name}: [{marker}] block contains literal "
f"payload string {offender!r}. Replace with a <placeholder>. "
f"See CLAUDE.md → 'Don't put literal payloads in system prompts'."
)
assert not failures, "\n".join(failures)