Scenarios and Runbooks — Input Data Formatting
MangaAssist context: JP Manga store chatbot on AWS — Bedrock Claude 3 (Sonnet at $3/$15 per 1M tokens input/output, Haiku at $0.25/$1.25), OpenSearch Serverless (vector store), DynamoDB (sessions/products), ECS Fargate (orchestrator), API Gateway WebSocket, ElastiCache Redis. Target: useful answer in under 3 seconds, 1M messages/day scale.
Skill Mapping
| Dimension | Detail |
|---|---|
| Certification | AWS AIP-C01 — AI Practitioner |
| Domain | 1 — Foundation Model Integration, Data Management, and Compliance |
| Task | 1.3 — Implement data validation and processing pipelines for FM consumption |
| Skill | 1.3.3 — Format input data for FM inference according to model-specific requirements |
| This File | Five production scenarios with detection flowcharts, root cause analysis, resolution code, and prevention strategies |
Skill Scope Statement
This file presents five real-world failure scenarios that MangaAssist has encountered (or would encounter) in production when formatting input data for FM inference — covering JSON request body construction for Amazon Bedrock, conversation history schema compliance, SageMaker endpoint payload contracts, multi-turn dialog ordering, and product data serialization into prompts. Each scenario includes: a problem statement, a mermaid detection flowchart, root cause analysis, Python resolution code, and prevention measures. These runbooks are designed for on-call engineers responding to inference failures.
Mind Map — Input Data Formatting Failure Modes
mindmap
root((Input Data<br/>Formatting Failures))
Bedrock Request Schema
Missing anthropic_version
ValidationException 400
All Inference Blocked
Conversation Role Labels
human/ai vs user/assistant
Context Ignored by Model
Wrong Schema Silently Accepted
SageMaker Payload Contract
prompt vs inputs key mismatch
400 After Model Swap
No Backward Compatibility
Multi-Turn Ordering
Consecutive User Turns
No Assistant Turn Between
Refusal or Hallucination
Product Data Serialization
Python dict str() output
Single-Quote Keys
Model Misparses Key-Values
Scenario Overview
| # | Scenario | Severity | Blast Radius | Typical Detection Time |
|---|---|---|---|---|
| 1 | Bedrock API request missing anthropic_version — 400 ValidationException |
P1 — Critical | All production inference requests | Immediate — 100% error rate |
| 2 | Conversation history uses human/ai roles instead of user/assistant |
P2 — High | Every multi-turn session, context silently dropped | 5-15 min via quality audit / user complaints |
| 3 | SageMaker endpoint expects {"inputs": [...]} but receives {"prompt": "..."} |
P1 — Critical | All requests after model swap | Immediate — 400 on every call |
| 4 | Two consecutive user turns in conversation history — no assistant turn between |
P2 — High | Sessions with large history windows | 10-20 min via refusal/hallucination spike |
| 5 | Manga product data embedded as Python str(dict) — single quotes break model parsing |
P3 — Medium | All product-recommendation responses | Post-hoc via answer accuracy review |
Scenario 1: Missing anthropic_version in Bedrock Request Body
Problem
After a refactor of MangaAssist's Bedrock inference wrapper, the anthropic_version field was accidentally removed from the request body passed to invoke_model. Every call to Claude immediately returns HTTP 400 ValidationException: Malformed input request, taking down all AI responses in production. The field is required by the Bedrock Messages API but not validated at build time.
Detection
flowchart TD
A([CloudWatch Alarm: 5xx Rate > 5%]) --> B{Error type?}
B -->|ValidationException| C[Inspect raw Bedrock request body]
B -->|Other| D[Investigate separately]
C --> E{anthropic_version present?}
E -->|No| F([Root Cause: Missing anthropic_version field])
E -->|Yes| G{max_tokens present?}
G -->|No| H([Root Cause: Missing max_tokens field])
G -->|Yes| I{messages array well-formed?}
I -->|No| J([Root Cause: Malformed messages array])
I -->|Yes| K([Escalate — other schema violation])
Root Cause
The inference wrapper was refactored to extract common fields into a base config dict. During the merge, anthropic_version was left in the base config but the merge used request_body.update(base_config) which was later reversed to base_config.update(request_body) — overwriting the version field with nothing because request_body did not include it. Bedrock's Messages API mandates anthropic_version: "bedrock-2023-05-31" for all Claude model families.
Resolution
import json
import boto3
from typing import Any
bedrock = boto3.client("bedrock-runtime", region_name="us-east-1")
REQUIRED_BEDROCK_FIELDS = {"anthropic_version", "max_tokens", "messages"}
VALID_ANTHROPIC_VERSION = "bedrock-2023-05-31"
def build_claude_request(
messages: list[dict],
max_tokens: int = 1024,
system: str | None = None,
temperature: float = 0.7,
) -> dict[str, Any]:
"""
Build a validated Bedrock Messages API request body for Claude.
Raises ValueError early if required fields would be missing.
"""
if not messages:
raise ValueError("messages list must not be empty")
body: dict[str, Any] = {
"anthropic_version": VALID_ANTHROPIC_VERSION, # REQUIRED — never omit
"max_tokens": max_tokens,
"messages": messages,
"temperature": temperature,
}
if system:
body["system"] = system
_validate_bedrock_request(body)
return body
def _validate_bedrock_request(body: dict[str, Any]) -> None:
"""Fail fast before sending to Bedrock — avoid paying for failed calls."""
missing = REQUIRED_BEDROCK_FIELDS - body.keys()
if missing:
raise ValueError(
f"Bedrock request body is missing required fields: {missing}. "
f"anthropic_version must be '{VALID_ANTHROPIC_VERSION}'."
)
if body.get("anthropic_version") != VALID_ANTHROPIC_VERSION:
raise ValueError(
f"anthropic_version must be '{VALID_ANTHROPIC_VERSION}', "
f"got: '{body.get('anthropic_version')}'"
)
if not isinstance(body.get("messages"), list) or len(body["messages"]) == 0:
raise ValueError("messages must be a non-empty list")
def invoke_claude(
model_id: str,
messages: list[dict],
system: str | None = None,
max_tokens: int = 1024,
) -> str:
"""Invoke Bedrock Claude model with a validated request body."""
request_body = build_claude_request(
messages=messages,
max_tokens=max_tokens,
system=system,
)
response = bedrock.invoke_model(
modelId=model_id,
body=json.dumps(request_body),
contentType="application/json",
accept="application/json",
)
response_body = json.loads(response["body"].read())
return response_body["content"][0]["text"]
# --- Hotfix: usage example ---
if __name__ == "__main__":
msgs = [{"role": "user", "content": "Recommend a shonen manga under $15."}]
answer = invoke_claude(
model_id="anthropic.claude-3-sonnet-20240229-v1:0",
messages=msgs,
system="You are MangaAssist, a helpful manga recommendation assistant.",
)
print(answer)
Prevention
- Add
_validate_bedrock_request()as a unit test fixture called in CI before any Bedrock integration test. - Introduce a
BedrockRequestBuilderdataclass withanthropic_versionas aClassVarconstant — schema fields cannot be accidentally omitted by dict merges. - CloudWatch Metric Filter on
ValidationException→ alarm at threshold > 0 for 1 minute (zero-tolerance policy for schema errors). - Contract test: invoke a real Bedrock call in a staging canary every 5 minutes; alert if any 400 is returned.
Scenario 2: Conversation History Uses role: human/ai Instead of role: user/assistant
Problem
MangaAssist originally integrated with an older LangChain memory module that serialized conversation turns as {"role": "human", "content": "..."} and {"role": "ai", "content": "..."}. When these are passed directly to the Bedrock Messages API for Claude, Bedrock silently accepts the request but Claude ignores the entire history and treats every message as a fresh conversation. Users asking follow-up questions like "What about volume 2?" get responses as if no prior context exists.
Detection
flowchart TD
A([User complaint: bot ignores follow-up questions]) --> B[Pull session history from DynamoDB]
B --> C{Inspect role field values}
C -->|human / ai labels| D([Root Cause: LangChain serialization — wrong role schema])
C -->|user / assistant labels| E[Check message ordering]
E -->|Consecutive user turns| F([Escalate to Scenario 4])
E -->|Correct ordering| G[Check content field structure]
G -->|Content is list not string| H([Root Cause: Structured content blocks not supported on this model])
G -->|Content is string| I([Escalate — unknown context loss])
Root Cause
LangChain's ConversationBufferMemory uses HumanMessage and AIMessage objects that serialize to role: "human" and role: "ai". Amazon Bedrock's Messages API for Claude requires exactly role: "user" and role: "assistant". Bedrock does not raise a validation error for unknown role values — it silently strips unrecognized roles, causing Claude to see an empty or single-message conversation for every turn.
Resolution
from __future__ import annotations
from dataclasses import dataclass
from typing import Literal
# Bedrock-compliant role literals
BedrockRole = Literal["user", "assistant"]
# LangChain / legacy role mappings
ROLE_NORMALIZATION_MAP: dict[str, BedrockRole] = {
"human": "user",
"user": "user",
"ai": "assistant",
"assistant": "assistant",
"bot": "assistant",
"system": "user", # system messages handled separately via system param
}
@dataclass
class BedrockMessage:
role: BedrockRole
content: str
def to_dict(self) -> dict[str, str]:
return {"role": self.role, "content": self.content}
def normalize_conversation_history(
raw_history: list[dict[str, str]],
) -> list[dict[str, str]]:
"""
Convert conversation history from any known role schema to Bedrock-compliant
user/assistant format.
Raises ValueError for unrecognized role labels so failures are loud.
"""
normalized: list[dict[str, str]] = []
for turn in raw_history:
raw_role = turn.get("role", "").lower().strip()
content = turn.get("content", "").strip()
if raw_role not in ROLE_NORMALIZATION_MAP:
raise ValueError(
f"Unrecognized conversation role '{raw_role}'. "
f"Accepted values: {list(ROLE_NORMALIZATION_MAP.keys())}. "
"Check your LangChain memory serializer or session store schema."
)
if not content:
# Skip empty turns — they confuse the model
continue
bedrock_role = ROLE_NORMALIZATION_MAP[raw_role]
normalized.append(BedrockMessage(role=bedrock_role, content=content).to_dict())
return normalized
def load_and_normalize_session(session_id: str, dynamo_table) -> list[dict[str, str]]:
"""Load conversation history from DynamoDB and normalize roles for Bedrock."""
response = dynamo_table.get_item(Key={"session_id": session_id})
item = response.get("Item", {})
raw_history: list[dict] = item.get("conversation_history", [])
normalized = normalize_conversation_history(raw_history)
return normalized
# --- Hotfix: migration script to fix corrupted sessions in DynamoDB ---
def migrate_legacy_roles_in_place(dynamo_table, session_id: str) -> None:
"""One-time fix: rewrite role labels for a session already stored with human/ai."""
response = dynamo_table.get_item(Key={"session_id": session_id})
item = response.get("Item", {})
history = item.get("conversation_history", [])
fixed_history = normalize_conversation_history(history)
dynamo_table.update_item(
Key={"session_id": session_id},
UpdateExpression="SET conversation_history = :h",
ExpressionAttributeValues={":h": fixed_history},
)
print(f"[MIGRATED] session {session_id}: {len(history)} turns rewritten.")
Prevention
- Add a
pytestparametrize test: inputhuman/airoles → assert output isuser/assistant. - Enforce role normalization at the DynamoDB write boundary (session write path), not at read time, so the store is always canonical.
- Add a CloudWatch Log Insights query alerting on
"Unrecognized conversation role"log messages. - Document the canonical schema in the session store README: roles MUST be
userorassistantat rest.
Scenario 3: SageMaker Endpoint Expects {"inputs": [...]} But Receives {"prompt": "..."}
Problem
MangaAssist uses a self-hosted fine-tuned model on SageMaker for Japanese manga title classification. After a model swap from a HuggingFace text-generation container (which accepts {"inputs": "..."}) to a custom TGI container expecting {"inputs": [{"role": "user", "content": "..."}]}, the inference service begins returning HTTP 400 on every call. The payload builder was not updated alongside the model deployment.
Detection
flowchart TD
A([CloudWatch Alarm: SageMaker InvocationErrors > 0]) --> B[Check SageMaker endpoint logs in CloudWatch]
B --> C{HTTP status code?}
C -->|400| D[Inspect ModelError message]
D --> E{Error contains payload key mismatch?}
E -->|prompt key / unexpected field| F([Root Cause: Wrong payload schema for current container])
E -->|inputs key missing| G([Root Cause: Old text-completion format sent to chat container])
E -->|Other| H[Check content-type header]
C -->|500| I[Check container resource limits — OOM or timeout]
C -->|200| J([False alarm — check output parsing])
Root Cause
Two different SageMaker container families use incompatible payload schemas:
- HuggingFace text-generation (legacy): {"inputs": "plain text string"}
- TGI chat container (current): {"inputs": [{"role": "user", "content": "..."}]}
- Some custom containers: {"prompt": "...", "parameters": {...}}
The payload builder was written against the legacy schema and not versioned. When the model was swapped, the builder was not updated, causing a permanent 400 loop.
Resolution
import json
import boto3
from enum import Enum
from typing import Any
sagemaker_runtime = boto3.client("sagemaker-runtime", region_name="us-east-1")
class SageMakerPayloadSchema(str, Enum):
LEGACY_TEXT_GENERATION = "legacy_text_generation" # {"inputs": "string"}
TGI_CHAT = "tgi_chat" # {"inputs": [{"role":...}]}
CUSTOM_PROMPT = "custom_prompt" # {"prompt": "...", "parameters": {}}
def build_sagemaker_payload(
prompt: str | list[dict],
schema: SageMakerPayloadSchema,
parameters: dict[str, Any] | None = None,
) -> str:
"""
Build a SageMaker invoke_endpoint payload according to the deployed container schema.
Always pass the schema explicitly — never rely on a global default.
"""
parameters = parameters or {}
if schema == SageMakerPayloadSchema.LEGACY_TEXT_GENERATION:
if not isinstance(prompt, str):
raise ValueError(
"LEGACY_TEXT_GENERATION schema expects a plain string prompt, "
f"got {type(prompt).__name__}. Did you mean TGI_CHAT?"
)
payload = {"inputs": prompt, **parameters}
elif schema == SageMakerPayloadSchema.TGI_CHAT:
if isinstance(prompt, str):
# Auto-convert plain string to chat message list
messages = [{"role": "user", "content": prompt}]
elif isinstance(prompt, list):
messages = prompt
else:
raise ValueError(f"TGI_CHAT schema expects str or list, got {type(prompt).__name__}")
payload = {"inputs": messages, **parameters}
elif schema == SageMakerPayloadSchema.CUSTOM_PROMPT:
if not isinstance(prompt, str):
raise ValueError("CUSTOM_PROMPT schema expects a plain string prompt.")
payload = {"prompt": prompt, "parameters": parameters}
else:
raise ValueError(f"Unknown SageMaker payload schema: {schema}")
return json.dumps(payload)
def invoke_sagemaker_endpoint(
endpoint_name: str,
prompt: str | list[dict],
schema: SageMakerPayloadSchema,
parameters: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Invoke a SageMaker endpoint with schema-validated payload."""
serialized_payload = build_sagemaker_payload(prompt, schema, parameters)
response = sagemaker_runtime.invoke_endpoint(
EndpointName=endpoint_name,
ContentType="application/json",
Accept="application/json",
Body=serialized_payload,
)
response_body = json.loads(response["Body"].read())
return response_body
# --- Usage after model swap ---
if __name__ == "__main__":
result = invoke_sagemaker_endpoint(
endpoint_name="mangaassist-classifier-tgi-v2",
prompt="Classify this manga: Demon Slayer Vol 1",
schema=SageMakerPayloadSchema.TGI_CHAT, # Updated to match new container
parameters={"max_new_tokens": 128, "temperature": 0.3},
)
print(result)
Prevention
- Store the
SageMakerPayloadSchemaenum value as a tag on the SageMaker endpoint itself (viaadd_tags) so the payload builder can read it at runtime:endpoint_name → schemamapping never drifts. - Contract test: after every model deployment, run a canary that sends a known probe payload and asserts the response format matches the expected schema.
- Add the schema version to the SageMaker model card and require it in the model deployment PR checklist.
- IaC (CDK/Terraform) module for SageMaker endpoints should include a
payload_schemaparameter that is used to auto-generate the payload builder config.
Scenario 4: Two Consecutive user Turns in Conversation History
Problem
A race condition in MangaAssist's session write path occasionally allows two user messages to be written back-to-back to DynamoDB before the assistant response arrives. When this history is replayed to Claude, the model receives two consecutive user turns with no interleaved assistant turn. Claude (via Bedrock Messages API) either refuses to continue ("I can only respond to one message at a time"), hallucinates an assistant turn, or produces a degraded response. The bug affects roughly 2% of high-concurrency sessions.
Detection
flowchart TD
A([Quality alarm: Refusal rate > 1% in 5-min window]) --> B[Sample refused sessions from CloudWatch Logs]
B --> C[Fetch conversation history for session_id]
C --> D{Check role alternation pattern}
D -->|user → user detected| E([Root Cause: Consecutive user turns — missing assistant interleave])
D -->|Correct alternation| F[Check content of user messages]
F -->|Injection attempt in content| G([Security: Prompt injection — escalate to security team])
F -->|Normal content| H[Check model version and system prompt length]
H -->|System prompt > 8K tokens| I([Root Cause: System prompt truncation squeezing out history])
H -->|Normal| J([Escalate — unknown refusal cause])
Root Cause
Two concurrent WebSocket connections for the same session_id (e.g., user has two browser tabs open) both call append_user_message() at the same time. DynamoDB conditional writes were not used, so both writes succeed, adding two user messages before either assistant turn is stored. The session writer did not validate role alternation before persisting.
Resolution
import boto3
from boto3.dynamodb.conditions import Attr
from botocore.exceptions import ClientError
dynamodb = boto3.resource("dynamodb", region_name="us-east-1")
sessions_table = dynamodb.Table("mangaassist-sessions")
def validate_role_alternation(history: list[dict[str, str]]) -> None:
"""
Enforce strict user/assistant alternation required by Bedrock Messages API.
Raises ValueError describing the first violation found.
"""
for i in range(1, len(history)):
prev_role = history[i - 1].get("role")
curr_role = history[i].get("role")
if prev_role == curr_role:
raise ValueError(
f"Invalid conversation history: consecutive '{curr_role}' turns at "
f"positions {i - 1} and {i}. Bedrock Messages API requires strict "
"user/assistant alternation. Check for race condition in session writer."
)
def repair_consecutive_turns(history: list[dict[str, str]]) -> list[dict[str, str]]:
"""
Repair a history with consecutive same-role turns by inserting placeholder
assistant turns. Use only as a hotfix for existing corrupted sessions.
"""
if not history:
return history
repaired: list[dict[str, str]] = [history[0]]
for i in range(1, len(history)):
prev_role = repaired[-1]["role"]
curr_role = history[i]["role"]
if prev_role == curr_role == "user":
# Insert a neutral assistant placeholder to restore alternation
repaired.append({
"role": "assistant",
"content": "[Continuing from your previous message]",
})
repaired.append(history[i])
return repaired
def append_user_message_safe(
session_id: str,
user_message: str,
expected_length: int,
) -> None:
"""
Append a user message with a conditional write that enforces:
1. The history length has not changed since we last read it (optimistic lock).
2. Falls back to repair if a consecutive-turn violation is detected.
"""
new_turn = {"role": "user", "content": user_message}
try:
sessions_table.update_item(
Key={"session_id": session_id},
UpdateExpression="SET conversation_history = list_append(conversation_history, :t)",
# Optimistic lock: only write if history length matches what we read
ConditionExpression=Attr("history_length").eq(expected_length),
ExpressionAttributeValues={
":t": [new_turn],
},
)
except ClientError as e:
if e.response["Error"]["Code"] == "ConditionalCheckFailedException":
# Another writer beat us — re-read and validate before retrying
response = sessions_table.get_item(Key={"session_id": session_id})
current_history = response["Item"].get("conversation_history", [])
validate_role_alternation(current_history + [new_turn])
# If validation passes, retry unconditionally
sessions_table.update_item(
Key={"session_id": session_id},
UpdateExpression="SET conversation_history = list_append(conversation_history, :t)",
ExpressionAttributeValues={":t": [new_turn]},
)
else:
raise
Prevention
- Always call
validate_role_alternation()before passing history to Bedrock — treat it as a cheap guard at the inference boundary. - Use DynamoDB conditional writes with
history_lengthas an optimistic lock counter on every session write. - Add a CloudWatch Logs Insights query: detect
"consecutive"role errors and alarm when rate > 0.5%. - Session writer unit test: assert that two concurrent
append_user_message_safecalls for the same session never result in back-to-back user turns.
Scenario 5: Manga Product Data Embedded as Python str(dict) — Single Quotes Break Model Parsing
Problem
MangaAssist's product recommendation pipeline fetches manga metadata from DynamoDB and embeds it into the inference prompt using Python's str() on a dict: f"Product info: {str(product_dict)}". This produces strings like {'title': 'Naruto Vol 1', 'price': 9.99, 'genre': 'shonen'} with single-quote delimiters. Claude 3 sometimes misparses these as Python literals rather than structured data, confuses the key names, or omits fields — resulting in wrong prices and incorrect recommendations being returned to customers.
Detection
flowchart TD
A([Quality alert: product recommendation accuracy < 90%]) --> B[Sample failing recommendations from evaluation dataset]
B --> C[Inspect raw prompt sent to Bedrock for failing cases]
C --> D{Product data format in prompt?}
D -->|Single-quoted Python dict format| E([Root Cause: str dict serialization — model misparses single-quote keys])
D -->|JSON double-quoted format| F[Check if values contain special characters]
F -->|Unescaped newlines or quotes in values| G([Root Cause: JSON escaping not applied to product field values])
F -->|Clean values| H[Check prompt template — product data placement]
H -->|Data after > 6K tokens| I([Root Cause: Product data truncated by context window])
H -->|Normal position| J([Escalate — unknown parsing failure])
Root Cause
Python's str() on a dict uses single-quote delimiters and does not produce valid JSON. LLMs are trained predominantly on JSON and web data that uses double-quoted keys. When the model sees {'price': 9.99} it may:
1. Treat it as a Python code block and attempt Python-style parsing.
2. Interpret the single-quoted keys as string literals with ambiguous boundaries.
3. Silently skip fields it cannot parse.
The fix is to use json.dumps() which produces standards-compliant, double-quoted JSON that models parse reliably.
Resolution
import json
from decimal import Decimal
from typing import Any
class DecimalEncoder(json.JSONEncoder):
"""Handle DynamoDB Decimal types which are not JSON-serializable by default."""
def default(self, obj: Any) -> Any:
if isinstance(obj, Decimal):
# Preserve numeric precision — return float for LLM consumption
return float(obj)
return super().default(obj)
def serialize_product_for_prompt(product: dict[str, Any]) -> str:
"""
Serialize a manga product dict to a valid JSON string suitable for embedding
in an LLM prompt. Uses double-quoted keys and handles DynamoDB Decimal types.
NEVER use str(product) — single quotes are not valid JSON and confuse models.
"""
return json.dumps(product, cls=DecimalEncoder, ensure_ascii=False, indent=None)
def build_recommendation_prompt(
user_query: str,
products: list[dict[str, Any]],
max_products: int = 5,
) -> str:
"""
Build a product recommendation prompt with properly formatted product data.
Limits to max_products to avoid context window bloat.
"""
# Trim to most relevant products before serializing
selected = products[:max_products]
# Serialize each product as valid JSON — never use str()
products_json_array = json.dumps(
[
{
"title": p.get("title", ""),
"volume": p.get("volume", ""),
"price_usd": float(p["price"]) if "price" in p else None,
"genre": p.get("genre", ""),
"in_stock": p.get("in_stock", False),
}
for p in selected
],
cls=DecimalEncoder,
ensure_ascii=False,
)
prompt = (
f"You are MangaAssist. A customer asked: \"{user_query}\"\n\n"
f"Available products (JSON):\n{products_json_array}\n\n"
"Recommend the most suitable product(s) from this list. "
"Reference exact titles and prices from the JSON above."
)
return prompt
def assert_no_single_quote_keys(prompt: str) -> None:
"""
Defensive check: scan the built prompt for Python-style single-quoted dict
patterns and raise if found. Use in CI and staging canaries.
"""
import re
# Match patterns like {'key': value} — Python dict serialization artifact
python_dict_pattern = re.compile(r"\{'\w+'\s*:")
if python_dict_pattern.search(prompt):
raise ValueError(
"Prompt contains Python-style single-quoted dict keys. "
"Use json.dumps() instead of str() for all product data serialization."
)
# --- Usage example ---
if __name__ == "__main__":
# Simulate DynamoDB product records (Decimal from DynamoDB scan)
sample_products = [
{"title": "Naruto", "volume": "1", "price": Decimal("9.99"), "genre": "shonen", "in_stock": True},
{"title": "Attack on Titan", "volume": "1", "price": Decimal("12.99"), "genre": "dark_fantasy", "in_stock": True},
]
prompt = build_recommendation_prompt(
user_query="I want an action manga under $13",
products=sample_products,
)
# Guard: will raise immediately if str() was used anywhere in the pipeline
assert_no_single_quote_keys(prompt)
print(prompt)
Prevention
- Add
assert_no_single_quote_keys()as a pytest fixture that runs against every generated prompt in the test suite. - Add a linting rule (via
rufforpylint) that flags anystr(...)call on DynamoDB response items in the prompt-building module. - Document in the codebase prompt-building README: "All structured data embedded in prompts MUST be serialized with
json.dumps().str()on dicts is forbidden in prompt pipeline code." - Add a
DecimalEncoderto a sharedutils/serialization.pymodule so all teams use the same encoder and DynamoDB Decimal types are never a reason to avoidjson.dumps().