bookly/Bookly.lit.md

76 KiB

title
Bookly

Introduction

Bookly is a customer-support chatbot for a bookstore. It handles three things: looking up orders, processing returns, and answering a small set of standard policy questions. Everything else it refuses, using a verbatim template.

The interesting engineering is not the feature set. It is the guardrails. A chat agent wired to real tools can hallucinate order details, leak private information, skip verification steps, or wander off topic -- and the consequences land on real customers. Bookly defends against that with four independent layers, each of which assumes the previous layers have failed.

This document is both the prose walkthrough and the source code. The code you see below is the code that runs. Tangling this file produces the Python source tree byte-for-byte; weaving it produces the HTML you are reading.

The four guardrail layers

Before anything else, it helps to see the layers laid out in one picture. Each layer is a separate defence, and a malicious or confused input has to defeat all of them to cause harm.

graph TD
  U[User message]
  L1[Layer 1: System prompt<br/>identity, critical_rules, scope,<br/>verbatim policy, refusal template]
  L2[Layer 2: Runtime reminders<br/>injected every turn +<br/>long-conversation re-anchor]
  M[Claude]
  T{Tool use?}
  L3[Layer 3: Tool-side enforcement<br/>input validation +<br/>protocol guard<br/>eligibility before return]
  L4[Layer 4: Output validation<br/>regex grounding checks,<br/>markdown / off-topic / ID / date]
  OK[Reply to user]
  BAD[Safe fallback,<br/>bad reply dropped from history]

  U --> L1
  L1 --> L2
  L2 --> M
  M --> T
  T -- yes --> L3
  L3 --> M
  T -- no --> L4
  L4 -- ok --> OK
  L4 -- violations --> BAD

Layer 1 is the system prompt itself. It tells the model what Bookly is, what it can and cannot help with, what the return policy actually says (quoted verbatim, not paraphrased), and exactly which template to use when refusing. Layer 2 adds short reminder blocks on every turn so the model re-reads the non-negotiable rules at the highest-attention position right before the user turn. Layer 3 lives in tools.py: the tool handlers refuse unsafe calls regardless of what the model decides. Layer 4 lives at the end of the agent loop and does a deterministic regex pass over the final reply looking for things like fabricated order IDs, markdown leakage, and off-topic engagement.

Request lifecycle

A single user message travels this path:

sequenceDiagram
  autonumber
  participant B as Browser
  participant N as nginx
  participant S as FastAPI
  participant A as agent.run_turn
  participant C as Claude
  participant TL as tools.dispatch_tool

  B->>N: POST /api/chat { message }
  N->>S: proxy_pass
  S->>S: security_headers middleware
  S->>S: resolve_session (cookie)
  S->>S: rate limit (ip + session)
  S->>A: run_turn(session_id, message)
  A->>A: SessionStore.get_or_create<br/>+ per-session lock
  A->>C: messages.create(tools, system, history)
  loop tool_use
    C-->>A: tool_use blocks
    A->>TL: dispatch_tool(name, args, state)
    TL-->>A: tool result
    A->>C: messages.create(history+tool_result)
  end
  C-->>A: final text
  A->>A: validate_reply (layer 4)
  A-->>S: reply text
  S-->>B: { reply }

Module layout

Five Python files form the core. They depend on each other in one direction only -- there are no cycles.

graph LR
  MD[mock_data.py<br/>ORDERS, POLICIES, RETURN_POLICY]
  C[config.py<br/>Settings]
  T[tools.py<br/>schemas, handlers, dispatch]
  A[agent.py<br/>SessionStore, run_turn, validate]
  SV[server.py<br/>FastAPI, middleware, routes]

  MD --> T
  MD --> A
  C --> T
  C --> A
  C --> SV
  T --> A
  A --> SV

The rest of this document visits each module in dependency order: configuration first, then the data fixtures they read, then tools, then the agent loop, then the HTTP layer on top.

Configuration

Every setting that might reasonably change between environments lives in one place. The two required values -- the Anthropic API key and the session-cookie signing secret -- are wrapped in SecretStr so an accidental print(settings) cannot leak them to a log.

Everything else has a default that is safe for local development and reasonable for a small production deployment. A few knobs are worth noticing:

  • max_tool_use_iterations bounds the Layer-3 loop in agent.py. A model that keeps asking for tools forever will not burn API credit forever.
  • session_store_max_entries and session_idle_ttl_seconds cap the in-memory SessionStore, so a trivial script that opens millions of sessions cannot OOM the process.
  • rate_limit_per_ip_per_minute and rate_limit_per_session_per_minute feed the sliding-window limiter in server.py.
"""Application configuration loaded from environment variables.

Settings are read from `.env` at process start. The Anthropic API key and
the session-cookie signing secret are the only required values; everything
else has a sensible default so the app can boot in dev without ceremony.
"""

from __future__ import annotations

import secrets

from pydantic import Field, SecretStr
from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
    model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")

    # Required secrets -- wrapped in SecretStr so accidental logging or repr
    # does not leak them. Access the raw value with `.get_secret_value()`.
    anthropic_api_key: SecretStr
    # Signing key for the server-issued session cookie. A fresh random value is
    # generated at import if none is configured -- this means sessions do not
    # survive a process restart in dev, which is the desired behavior until a
    # real secret is set in the environment.
    session_secret: SecretStr = Field(default_factory=lambda: SecretStr(secrets.token_urlsafe(32)))

    anthropic_model: str = "claude-sonnet-4-5"
    max_tokens: int = 1024
    # Upper bound on the Anthropic HTTP call. A stuck request must not hold a
    # worker thread forever -- see the tool-use loop cap in agent.py for the
    # paired total-work bound.
    anthropic_timeout_seconds: float = 30.0

    server_host: str = "127.0.0.1"
    server_port: int = 8014

    # Session store bounds. Protects against a trivial DoS that opens many
    # sessions or drives a single session to unbounded history length.
    session_store_max_entries: int = 10_000
    session_idle_ttl_seconds: int = 1800  # 30 minutes
    max_turns_per_session: int = 40

    # Hard cap on iterations of the tool-use loop within a single turn. The
    # model should never legitimately need this many tool calls for a support
    # conversation -- the cap exists to stop a runaway loop.
    max_tool_use_iterations: int = 8

    # Per-minute sliding-window rate limits. Enforced by a tiny in-memory
    # limiter in server.py; suitable for a single-process demo deployment.
    rate_limit_per_ip_per_minute: int = 30
    rate_limit_per_session_per_minute: int = 20

    # Session cookie configuration.
    session_cookie_name: str = "bookly_session"
    session_cookie_secure: bool = False  # Flip to True behind HTTPS.
    session_cookie_max_age_seconds: int = 60 * 60 * 8  # 8 hours


# The type ignore is needed because pydantic-settings reads `anthropic_api_key`
# and `session_secret` from environment / .env at runtime, but mypy sees them as
# required constructor arguments and has no way to know about that.
settings = Settings()  # type: ignore[call-arg]

Data fixtures

Bookly does not talk to a real database. Four fixture orders are enough to cover the interesting scenarios: a delivered order that is still inside the 30-day return window, an in-flight order that has not been delivered yet, a processing order that has not shipped, and an old delivered order outside the return window. Sarah Chen owns two of the four so the agent has to disambiguate when she says "my order".

The RETURN_POLICY dict is the single source of truth for policy facts. Two things read it: the system prompt (via _format_return_policy_block in agent.py, which renders it as the <return_policy> section the model must quote) and the check_return_eligibility handler (which enforces the window in code). Having one copy prevents the two from drifting apart.

POLICIES is a tiny FAQ keyed by topic. The lookup_policy tool returns one of these entries verbatim and the system prompt instructs the model to quote the response without paraphrasing. This is a deliberate anti-hallucination pattern: the less the model has to generate, the less it can make up.

RETURNS is the only mutable state in this file. initiate_return writes a new RMA record to it on each successful return.

"""In-memory data fixtures for orders, returns, and FAQ policies.

`ORDERS` and `RETURN_POLICY` are read by both the system prompt (so the prompt
quotes policy verbatim instead of paraphrasing) and the tool handlers (so the
two never drift apart). `RETURNS` is mutated by `initiate_return` at runtime.
"""

from datetime import date, timedelta

# A frozen "today" so the four-order fixture stays deterministic across runs.
TODAY = date(2026, 4, 14)


def _days_ago(n: int) -> str:
    return (TODAY - timedelta(days=n)).isoformat()


RETURN_POLICY: dict = {
    "window_days": 30,
    "condition_requirements": "Items must be unread, undamaged, and in their original packaging.",
    "refund_method": "Refunds are issued to the original payment method.",
    "refund_timeline_days": 7,
    "non_returnable_categories": ["ebooks", "audiobooks", "gift cards", "personalized items"],
}


# Four orders covering the interesting scenarios. Sarah Chen has two orders so
# the agent must disambiguate when she says "my order".
ORDERS: dict = {
    "BK-10042": {
        "order_id": "BK-10042",
        "customer_name": "Sarah Chen",
        "email": "sarah.chen@example.com",
        "status": "delivered",
        "order_date": _days_ago(20),
        "delivered_date": _days_ago(15),
        "tracking_number": "1Z999AA10123456784",
        "items": [
            {"title": "The Goldfinch", "author": "Donna Tartt", "price": 16.99, "category": "fiction"},
            {"title": "Sapiens", "author": "Yuval Noah Harari", "price": 19.99, "category": "nonfiction"},
        ],
        "total": 36.98,
    },
    "BK-10089": {
        "order_id": "BK-10089",
        "customer_name": "James Murphy",
        "email": "james.murphy@example.com",
        "status": "shipped",
        "order_date": _days_ago(4),
        "delivered_date": None,
        "tracking_number": "1Z999AA10987654321",
        "items": [
            {"title": "Project Hail Mary", "author": "Andy Weir", "price": 18.99, "category": "fiction"},
        ],
        "total": 18.99,
    },
    "BK-10103": {
        "order_id": "BK-10103",
        "customer_name": "Sarah Chen",
        "email": "sarah.chen@example.com",
        "status": "processing",
        "order_date": _days_ago(1),
        "delivered_date": None,
        "tracking_number": None,
        "items": [
            {"title": "Tomorrow, and Tomorrow, and Tomorrow", "author": "Gabrielle Zevin", "price": 17.99, "category": "fiction"},
        ],
        "total": 17.99,
    },
    "BK-9871": {
        "order_id": "BK-9871",
        "customer_name": "Maria Gonzalez",
        "email": "maria.gonzalez@example.com",
        "status": "delivered",
        "order_date": _days_ago(60),
        "delivered_date": _days_ago(55),
        "tracking_number": "1Z999AA10555555555",
        "items": [
            {"title": "The Midnight Library", "author": "Matt Haig", "price": 15.99, "category": "fiction"},
        ],
        "total": 15.99,
    },
}


# Verbatim FAQ entries returned by `lookup_policy`. The agent quotes these
# without paraphrasing.
POLICIES: dict[str, str] = {
    "shipping": (
        "Standard shipping is free on orders over $25 and takes 3-5 business days. "
        "Expedited shipping (1-2 business days) is $9.99. We ship to all 50 US states. "
        "International shipping is not currently available."
    ),
    "password_reset": (
        "To reset your password, go to bookly.com/account/login and click \"Forgot password.\" "
        "Enter the email on your account and we will send you a reset link. "
        "The link expires after 24 hours. If you do not receive the email, check your spam folder."
    ),
    "returns_overview": (
        "You can return most items within 30 days of delivery for a full refund to your original "
        "payment method. Items must be unread, undamaged, and in their original packaging. "
        "Ebooks, audiobooks, gift cards, and personalized items are not returnable. "
        "Refunds typically post within 7 business days of us receiving the return."
    ),
}


# Mutated at runtime by `initiate_return`. Keyed by return_id.
RETURNS: dict[str, dict] = {}

Tools: Layer 3 enforcement

Four tools back the agent: lookup_order, check_return_eligibility, initiate_return, and lookup_policy. Each has an Anthropic-format schema (used in the tools argument to messages.create) and a handler function that takes a validated arg dict plus the per-session guard state and returns a dict that becomes the tool_result content sent back to the model.

The most important guardrail in the entire system lives in this file. handle_initiate_return refuses unless check_return_eligibility has already succeeded for the same order in the same session. This is enforced in code, not in the prompt -- if a model somehow decides to skip the eligibility check, the tool itself refuses. This is "Layer 3" in the stack: the model's last line of defence against itself.

A second guardrail is the privacy boundary in handle_lookup_order. When a caller supplies a customer_email and it does not match the email on the order, the handler returns the same order_not_found error as a missing order. This mirror means an attacker cannot probe for which order IDs exist by watching response differences. The check uses hmac.compare_digest for constant-time comparison so response-time side channels cannot leak the correct email prefix either.

Input validation lives in _require_* helpers at the top of the file. Every string is control-character-stripped before length checks so a malicious \x00 byte injected into a tool arg cannot sneak into the tool result JSON and reappear in the next turn's prompt. Order IDs, emails, and policy topics are validated with tight regexes; unexpected input becomes a structured invalid_arguments error that the model can recover from on its next turn.

TypedDict argument shapes make the schema-to-handler contract visible to the type checker without losing runtime validation -- the model is an untrusted caller, so the runtime checks stay.

"""Tool schemas, dispatch, and Layer 3 (tool-side) guardrail enforcement.

Each tool has an Anthropic-format schema (used in the `tools` argument to
`messages.create`) and a handler. Handlers are typed with `TypedDict`s so the
contract between schema and handler is visible to the type checker; inputs
are still validated at runtime because the caller is ultimately the model.

The most important guardrail in the whole system lives here:
`handle_initiate_return` refuses unless `check_return_eligibility` has already
succeeded for the same order in the same session. This protects against the
agent skipping the protocol even if the system prompt is ignored entirely.
"""

from __future__ import annotations

import hmac
import re
import uuid
from dataclasses import dataclass, field
from datetime import date
from typing import Any, Callable, TypedDict

try:
    from typing import NotRequired  # Python 3.11+
except ImportError:  # pragma: no cover -- Python 3.10 fallback
    from typing_extensions import NotRequired  # type: ignore[assignment]

from mock_data import ORDERS, POLICIES, RETURN_POLICY, RETURNS, TODAY


# ---------------------------------------------------------------------------
# Validation helpers
# ---------------------------------------------------------------------------

# Validator limits. These are deliberately tight: tool arguments come from
# model output, which in turn reflects user input, so anything that would not
# plausibly appear in a real support conversation is rejected.
ORDER_ID_RE = re.compile(r"^BK-\d{4,6}$")
EMAIL_RE = re.compile(r"^[^@\s]{1,64}@[^@\s]{1,255}\.[^@\s]{1,10}$")
TOPIC_RE = re.compile(r"^[a-z][a-z_]{0,39}$")
ITEM_TITLE_MAX_LENGTH = 200
REASON_MAX_LENGTH = 500
ITEM_TITLES_MAX_COUNT = 50

# Control characters are stripped from any free-form input. Keeping them out
# of tool payloads means they cannot end up in prompts on later turns, which
# closes one prompt-injection surface.
_CONTROL_CHAR_RE = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]")


class ToolValidationError(ValueError):
    """Raised when a tool argument fails validation.

    The dispatcher catches this and converts it into a tool-result error so
    the model can recover on its next turn instead of crashing the request.
    """


def _require_string(value: Any, field_name: str, *, max_length: int) -> str:
    if not isinstance(value, str):
        raise ToolValidationError(f"{field_name} must be a string")
    cleaned = _CONTROL_CHAR_RE.sub("", value).strip()
    if not cleaned:
        raise ToolValidationError(f"{field_name} is required")
    if len(cleaned) > max_length:
        raise ToolValidationError(f"{field_name} must be at most {max_length} characters")
    return cleaned


def _require_order_id(value: Any) -> str:
    order_id = _require_string(value, "order_id", max_length=16)
    if not ORDER_ID_RE.match(order_id):
        raise ToolValidationError("order_id must match the format BK-NNNN")
    return order_id


def _require_email(value: Any, *, field_name: str = "customer_email") -> str:
    email = _require_string(value, field_name, max_length=320)
    if not EMAIL_RE.match(email):
        raise ToolValidationError(f"{field_name} is not a valid email address")
    return email


def _optional_email(value: Any, *, field_name: str = "customer_email") -> str | None:
    if value is None:
        return None
    return _require_email(value, field_name=field_name)


def _require_topic(value: Any) -> str:
    topic = _require_string(value, "topic", max_length=40)
    topic = topic.lower()
    if not TOPIC_RE.match(topic):
        raise ToolValidationError("topic must be lowercase letters and underscores only")
    return topic


def _optional_item_titles(value: Any) -> list[str] | None:
    if value is None:
        return None
    if not isinstance(value, list):
        raise ToolValidationError("item_titles must be a list of strings")
    if len(value) > ITEM_TITLES_MAX_COUNT:
        raise ToolValidationError(f"item_titles may contain at most {ITEM_TITLES_MAX_COUNT} entries")
    cleaned: list[str] = []
    for index, entry in enumerate(value):
        cleaned.append(_require_string(entry, f"item_titles[{index}]", max_length=ITEM_TITLE_MAX_LENGTH))
    return cleaned


def _emails_match(supplied: str | None, stored: str | None) -> bool:
    """Constant-time email comparison with normalization.

    Returns False if either side is missing. Uses `hmac.compare_digest` to
    close the timing side-channel that would otherwise leak the correct
    prefix of a stored email.
    """
    if supplied is None or stored is None:
        return False
    supplied_norm = supplied.strip().lower().encode("utf-8")
    stored_norm = stored.strip().lower().encode("utf-8")
    return hmac.compare_digest(supplied_norm, stored_norm)


def _is_within_return_window(delivered_date: str | None) -> tuple[bool, int | None]:
    """Return (within_window, days_since_delivery)."""
    if delivered_date is None:
        return (False, None)
    delivered = date.fromisoformat(delivered_date)
    days_since = (TODAY - delivered).days
    return (days_since <= RETURN_POLICY["window_days"], days_since)


# ---------------------------------------------------------------------------
# TypedDict argument shapes
# ---------------------------------------------------------------------------


class LookupOrderArgs(TypedDict, total=False):
    order_id: str
    customer_email: NotRequired[str]


class CheckReturnEligibilityArgs(TypedDict):
    order_id: str
    customer_email: str


class InitiateReturnArgs(TypedDict, total=False):
    order_id: str
    customer_email: str
    reason: str
    item_titles: NotRequired[list[str]]


class LookupPolicyArgs(TypedDict):
    topic: str


@dataclass
class SessionGuardState:
    """Per-session protocol state used to enforce tool ordering rules.

    Sessions are short-lived chats, so plain in-memory sets are fine. A
    production deployment would back this with a session store.
    """

    eligibility_checks_passed: set[str] = field(default_factory=set)
    returns_initiated: set[str] = field(default_factory=set)


# ---------------------------------------------------------------------------
# Tool schemas (Anthropic format)
# ---------------------------------------------------------------------------

LOOKUP_ORDER_SCHEMA: dict[str, Any] = {
    "name": "lookup_order",
    "description": (
        "Look up the status and details of a Bookly order by order ID. "
        "Optionally pass the customer email to verify ownership before returning details. "
        "Use this whenever the customer asks about an order."
    ),
    "input_schema": {
        "type": "object",
        "properties": {
            "order_id": {
                "type": "string",
                "description": "The order ID, formatted as 'BK-' followed by digits.",
            },
            "customer_email": {
                "type": "string",
                "description": "Optional email used to verify the customer owns the order.",
            },
        },
        "required": ["order_id"],
    },
}

CHECK_RETURN_ELIGIBILITY_SCHEMA: dict[str, Any] = {
    "name": "check_return_eligibility",
    "description": (
        "Check whether an order is eligible for return. Requires both order ID and the email "
        "on the order. Must be called and succeed before initiate_return."
    ),
    "input_schema": {
        "type": "object",
        "properties": {
            "order_id": {"type": "string"},
            "customer_email": {"type": "string"},
        },
        "required": ["order_id", "customer_email"],
    },
}

INITIATE_RETURN_SCHEMA: dict[str, Any] = {
    "name": "initiate_return",
    "description": (
        "Start a return for an order. Only call this after check_return_eligibility has "
        "succeeded for the same order in this conversation, and after the customer has "
        "confirmed they want to proceed."
    ),
    "input_schema": {
        "type": "object",
        "properties": {
            "order_id": {"type": "string"},
            "customer_email": {"type": "string"},
            "reason": {
                "type": "string",
                "description": "The customer's stated reason for the return.",
            },
            "item_titles": {
                "type": "array",
                "items": {"type": "string"},
                "description": "Optional list of specific item titles to return. Defaults to all items.",
            },
        },
        "required": ["order_id", "customer_email", "reason"],
    },
}

LOOKUP_POLICY_SCHEMA: dict[str, Any] = {
    "name": "lookup_policy",
    "description": (
        "Look up a Bookly customer policy by topic. Use this whenever the customer asks "
        "about shipping, password reset, returns overview, or similar standard policies. "
        "Returns the verbatim policy text or topic_not_supported."
    ),
    "input_schema": {
        "type": "object",
        "properties": {
            "topic": {
                "type": "string",
                "description": "Policy topic, e.g. 'shipping', 'password_reset', 'returns_overview'.",
            },
        },
        "required": ["topic"],
    },
    # Cache breakpoint: marking the last tool with `cache_control` extends the
    # prompt cache over the whole tools block so schemas are not re-tokenized
    # on every turn. The big system prompt already has its own breakpoint.
    "cache_control": {"type": "ephemeral"},
}

TOOL_SCHEMAS: list[dict[str, Any]] = [
    LOOKUP_ORDER_SCHEMA,
    CHECK_RETURN_ELIGIBILITY_SCHEMA,
    INITIATE_RETURN_SCHEMA,
    LOOKUP_POLICY_SCHEMA,
]


# ---------------------------------------------------------------------------
# Handlers
# ---------------------------------------------------------------------------


def handle_lookup_order(args: LookupOrderArgs, state: SessionGuardState) -> dict[str, Any]:
    order_id = _require_order_id(args.get("order_id"))
    customer_email = _optional_email(args.get("customer_email"))

    order = ORDERS.get(order_id)
    if order is None:
        return {"error": "order_not_found", "message": f"No order found with ID {order_id}."}

    # Privacy: when an email is supplied and does not match, return the same
    # error as a missing order so callers cannot enumerate which IDs exist.
    if customer_email is not None and not _emails_match(customer_email, order["email"]):
        return {"error": "order_not_found", "message": f"No order found with ID {order_id}."}

    return {"order": order}


def handle_check_return_eligibility(
    args: CheckReturnEligibilityArgs, state: SessionGuardState
) -> dict[str, Any]:
    order_id = _require_order_id(args.get("order_id"))
    customer_email = _require_email(args.get("customer_email"))

    order = ORDERS.get(order_id)
    if order is None or not _emails_match(customer_email, order["email"]):
        return {
            "error": "auth_failed",
            "message": "Could not verify that order ID and email together. Please double-check both.",
        }

    if order["status"] != "delivered":
        return {
            "eligible": False,
            "reason": (
                f"This order has status '{order['status']}', not 'delivered'. "
                "Returns can only be started after an order has been delivered."
            ),
            "policy": RETURN_POLICY,
        }

    within_window, days_since = _is_within_return_window(order.get("delivered_date"))
    if not within_window:
        return {
            "eligible": False,
            "reason": (
                f"This order was delivered {days_since} days ago, which is outside the "
                f"{RETURN_POLICY['window_days']}-day return window."
            ),
            "policy": RETURN_POLICY,
        }

    state.eligibility_checks_passed.add(order_id)
    return {
        "eligible": True,
        "reason": (
            f"Order delivered {days_since} days ago, within the "
            f"{RETURN_POLICY['window_days']}-day window."
        ),
        "items": order["items"],
        "policy": RETURN_POLICY,
    }


def handle_initiate_return(args: InitiateReturnArgs, state: SessionGuardState) -> dict[str, Any]:
    order_id = _require_order_id(args.get("order_id"))
    customer_email = _require_email(args.get("customer_email"))
    reason = _require_string(args.get("reason"), "reason", max_length=REASON_MAX_LENGTH)
    item_titles = _optional_item_titles(args.get("item_titles"))

    # Layer 3 protocol guard: the agent must have called check_return_eligibility
    # for this exact order in this session, and it must have passed.
    if order_id not in state.eligibility_checks_passed:
        return {
            "error": "eligibility_not_verified",
            "message": (
                "Cannot initiate a return without a successful eligibility check for this "
                "order in the current session. Call check_return_eligibility first."
            ),
        }

    if order_id in state.returns_initiated:
        return {
            "error": "already_initiated",
            "message": "A return has already been initiated for this order in this session.",
        }

    order = ORDERS.get(order_id)
    # Paired assertion: we already checked eligibility against the same order,
    # but re-verify here so a future edit that makes ORDERS mutable cannot
    # silently break the email-binding guarantee.
    if order is None or not _emails_match(customer_email, order["email"]):
        return {"error": "auth_failed", "message": "Order/email mismatch."}

    # Explicit: an empty list means "no items selected" (a caller error we
    # reject) while `None` means "default to all items on the order".
    if item_titles is not None and not item_titles:
        return {"error": "no_items_selected", "message": "item_titles cannot be an empty list."}
    titles = item_titles if item_titles is not None else [item["title"] for item in order["items"]]

    return_id = f"RMA-{uuid.uuid4().hex[:8].upper()}"
    record = {
        "return_id": return_id,
        "order_id": order_id,
        "customer_email": order["email"],
        "items": titles,
        "reason": reason,
        "refund_method": RETURN_POLICY["refund_method"],
        "refund_timeline_days": RETURN_POLICY["refund_timeline_days"],
        "next_steps": (
            "We've emailed a prepaid shipping label to the address on file. Drop the package at "
            "any carrier location within 14 days. Your refund will post within "
            f"{RETURN_POLICY['refund_timeline_days']} business days of us receiving the return."
        ),
    }
    RETURNS[return_id] = record
    state.returns_initiated.add(order_id)
    return record


def handle_lookup_policy(args: LookupPolicyArgs, state: SessionGuardState) -> dict[str, Any]:
    topic = _require_topic(args.get("topic"))

    text = POLICIES.get(topic)
    if text is None:
        return {
            "error": "topic_not_supported",
            # Echo the normalized topic, not the raw input, so nothing the
            # caller injected is ever reflected back into model context.
            "message": f"No policy entry for topic '{topic}'.",
            "available_topics": sorted(POLICIES.keys()),
        }
    return {"topic": topic, "text": text}


# ---------------------------------------------------------------------------
# Dispatch
# ---------------------------------------------------------------------------


_HANDLERS: dict[str, Callable[[Any, SessionGuardState], dict[str, Any]]] = {
    "lookup_order": handle_lookup_order,
    "check_return_eligibility": handle_check_return_eligibility,
    "initiate_return": handle_initiate_return,
    "lookup_policy": handle_lookup_policy,
}


def dispatch_tool(name: str, args: dict[str, Any], state: SessionGuardState) -> dict[str, Any]:
    handler = _HANDLERS.get(name)
    if handler is None:
        return {"error": "unknown_tool", "message": f"No tool named {name}."}
    if not isinstance(args, dict):
        return {"error": "invalid_arguments", "message": "Tool arguments must be an object."}
    try:
        return handler(args, state)
    except ToolValidationError as exc:
        # Return validation errors as structured tool errors so the model can
        # recover. Never surface the message verbatim from untrusted input --
        # `_require_string` already stripped control characters, and the error
        # messages themselves are constructed from field names, not user data.
        return {"error": "invalid_arguments", "message": str(exc)}

Agent loop

This is the biggest file. It wires everything together: the system prompt, runtime reminders, output validation (Layer 4), the in-memory session store with per-session locking, the cached Anthropic client, and the actual tool-use loop that drives a turn end to end.

System prompt

The prompt is structured with XML-style tags (<identity>, <critical_rules>, <scope>, <return_policy>, <tool_rules>, <tone>, <examples>, <reminders>). The critical rules are stated up front and repeated at the bottom (primacy plus recency). The return policy section interpolates the RETURN_POLICY dict verbatim via _format_return_policy_block, so the prompt and the enforcement in tools.py cannot disagree.

Four few-shot examples are embedded directly in the prompt. Each one demonstrates a case that is easy to get wrong: missing order ID, quoting a policy verbatim, refusing an off-topic request, disambiguating between two orders.

Runtime reminders

On every turn, build_system_content appends a short CRITICAL_REMINDER block to the system content. Once the turn count crosses LONG_CONVERSATION_TURN_THRESHOLD, a second LONG_CONVERSATION_REMINDER is added. The big SYSTEM_PROMPT block is the only one marked cache_control: ephemeral -- the reminders vary per turn and we want them at the highest-attention position, not in the cached prefix.

Layer 4 output validation

After the model produces its final reply, validate_reply runs four cheap deterministic checks: every BK-NNNN string in the reply must also appear in a tool result from this turn, every ISO date in the reply must appear in a tool result, the reply must not contain markdown, and if the reply contains off-topic engagement phrases it must also contain the refusal template. Violations are collected and returned as a frozen ValidationResult.

The off-topic patterns used to be loose substring matches on a keyword set. That false-positived on plenty of legitimate support replies ("I'd recommend contacting..."). The current patterns use word boundaries so only the intended phrases trip them.

Session store

SessionStore is a bounded in-memory LRU with an idle TTL. It stores Session objects (history, guard state, turn count) keyed by opaque server-issued session IDs. It also owns the per-session locks used to serialize concurrent turns for the same session, since FastAPI runs the sync chat handler in a threadpool and two simultaneous requests for the same session would otherwise corrupt the conversation history.

The locks-dict is itself protected by a class-level lock so two threads trying to create the first lock for a session cannot race into two different lock instances.

Under the "single-process demo deployment" constraint this is enough. For multi-worker, the whole class would get swapped for a Redis-backed equivalent.

The tool-use loop

_run_tool_use_loop drives the model until it stops asking for tools. It is bounded by settings.max_tool_use_iterations so a runaway model cannot burn credit in an infinite loop. Each iteration serializes the assistant's content blocks into history, dispatches every requested tool, packs the results into a single tool_result user-role message, and calls Claude again. Before each call, _with_last_message_cache_breakpoint stamps the last message with cache_control: ephemeral so prior turns do not need to be re-tokenized on every call. This turns the per-turn input-token cost from O(turns^2) into O(turns) across a session.

run_turn

run_turn is the top-level entry point the server calls. It validates its inputs, acquires the per-session lock, appends the user message, runs the loop, and then either persists the final reply to history or -- on validation failure -- drops the bad reply and returns a safe fallback. Dropping a bad reply from history is important: it prevents a hallucinated claim from poisoning subsequent turns.

Warning logs never include the reply body. Session IDs and reply contents are logged only as short SHA-256 hashes for correlation, which keeps PII out of the log pipeline even under active incident response.

"""Bookly agent: system prompt, guardrails, session store, and the agentic loop.

This module wires four guardrail layers together:

1. The system prompt itself (XML-tagged, primacy+recency duplication, verbatim
   policy block, refusal template, few-shot examples for edge cases).
2. Runtime reminder injection: a short "non-negotiable rules" block appended
   to the system content on every turn, plus a stronger reminder once the
   conversation gets long enough that the original prompt has decayed in
   effective attention.
3. Tool-side enforcement (lives in `tools.py`): handlers refuse unsafe calls
   regardless of what the model decides.
4. Output validation: deterministic regex checks on the final reply for
   ungrounded order IDs/dates, markdown leakage, and off-topic engagement
   without the refusal template. On failure, the bad reply is dropped and the
   user gets a safe canned message -- and the bad reply is never appended to
   history, so it cannot poison subsequent turns.

Anthropic prompt caching is enabled on the large system-prompt block AND on
the last tool schema and the last message in history, so per-turn input cost
scales linearly in the number of turns instead of quadratically.
"""

from __future__ import annotations

import functools
import hashlib
import json
import logging
import re
import threading
import time
from collections import OrderedDict
from dataclasses import dataclass, field
from typing import Any

from anthropic import Anthropic

from config import settings
from tools import SessionGuardState, TOOL_SCHEMAS, dispatch_tool
from mock_data import POLICIES, RETURN_POLICY

logger = logging.getLogger("bookly.agent")


# ---------------------------------------------------------------------------
# System prompt
# ---------------------------------------------------------------------------


def _format_return_policy_block() -> str:
    """Render `RETURN_POLICY` as a compact, quotable block for the prompt.

    Embedding the dict verbatim (instead of paraphrasing it in English) is a
    deliberate anti-hallucination move: the model quotes the block instead of
    inventing details.
    """
    non_returnable = ", ".join(RETURN_POLICY["non_returnable_categories"])
    return (
        f"Return window: {RETURN_POLICY['window_days']} days from delivery.\n"
        f"Condition: {RETURN_POLICY['condition_requirements']}\n"
        f"Refund method: {RETURN_POLICY['refund_method']}\n"
        f"Refund timeline: within {RETURN_POLICY['refund_timeline_days']} business days of receipt.\n"
        f"Non-returnable categories: {non_returnable}."
    )


SUPPORTED_POLICY_TOPICS = ", ".join(sorted(POLICIES.keys()))


SYSTEM_PROMPT = f"""<identity>
You are Bookly's customer support assistant. You help customers with two things: checking the status of their orders, and processing returns and refunds. You are friendly, concise, and professional.
</identity>

<critical_rules>
These rules override everything else. Read them before every response.

1. NEVER invent order details, tracking numbers, delivery dates, prices, or customer information. If you do not have a value from a tool result in this conversation, you do not have it.
2. NEVER state a return policy detail that is not in the <return_policy> section below. Quote it; do not paraphrase it.
3. NEVER call initiate_return unless check_return_eligibility has returned success for that same order in this conversation.
4. NEVER reveal order details without verifying the customer's email matches the order.
5. If a user asks about anything outside order status, returns, and the supported policy topics, refuse using the refusal template in <scope>. Do not engage with the off-topic request even briefly.
</critical_rules>

<scope>
You CAN help with:
- Looking up order status
- Checking return eligibility and initiating returns
- Answering policy questions covered by the lookup_policy tool. Currently supported topics: {SUPPORTED_POLICY_TOPICS}

You CANNOT help with:
- Book recommendations, reviews, or opinions about books
- Payment changes, refunds outside the return flow, or billing disputes
- Live account management (changing a password, email, or address — you can only EXPLAIN the password reset process via lookup_policy, not perform it)
- General conversation unrelated to an order or a supported policy topic

For any policy question, call lookup_policy first. Only if the tool returns topic_not_supported should you use the refusal template below.

Refusal template (use verbatim, filling in the topic):
"I can help with order status, returns, and our standard policies, but I'm not able to help with {{topic}}. Is there an order or a policy question I can help you with instead?"
</scope>

<return_policy>
{_format_return_policy_block()}

This is the authoritative policy. Any claim you make about returns must be traceable to a line in this block. If a customer asks about a scenario this policy does not cover, say so honestly and offer to connect them with a human agent.
</return_policy>

<tool_rules>
You have four tools: lookup_order, check_return_eligibility, initiate_return, and lookup_policy.

Before calling a tool:
- You must have every required parameter. If you are missing one, ask the customer for it. Do not guess, do not use placeholder values, do not call the tool and hope.
- For initiate_return, you must have already called check_return_eligibility for that exact order_id in this conversation, and it must have returned success.

After a tool call:
- Relay the result honestly. If the tool returns an error, tell the customer what went wrong using the tool's error message, not a paraphrase.
- Do not mix tool results from different orders in a single response unless the customer explicitly asked about multiple.
- For lookup_policy, quote the returned policy text; do not summarize or embellish. If lookup_policy returns topic_not_supported, fall through to the refusal template in <scope>.
</tool_rules>

<clarifying_rules>
Ask one clarifying question at a time, not a list. Common cases:

- Customer mentions "my order" without an order ID: ask for the order ID. Tell them it starts with "BK-" and is in their confirmation email.
- Customer gives an order ID but no email, and wants a return: ask for the email on the order.
- A customer has multiple orders and was ambiguous: ask which order they mean, listing them by ID and status only.
- Customer wants to initiate a return: after eligibility is confirmed, summarize what will happen (which items, refund method, timeline) and ask for explicit confirmation before calling initiate_return.
</clarifying_rules>

<tone>
- Friendly and warm, but not chatty. One or two sentences per turn is usually right.
- Use the customer's first name once you know it, but not in every message.
- Plain text only. No markdown, no bullet points, no headers, no asterisks for emphasis. The chat UI does not render markdown.
- Never apologize more than once for the same issue.
</tone>

<examples>
Example 1 — missing order ID:
User: "Where's my order?"
Assistant: "Happy to check on that for you. Could you share your order ID? It starts with 'BK-' and you'll find it in your order confirmation email."

Example 2 — policy question (supported):
User: "How do I reset my password?"
Assistant (after lookup_policy returns the password_reset entry): quote the returned instructions verbatim without adding steps the tool did not mention.

Example 3 — out of scope:
User: "Can you recommend a good mystery novel?"
Assistant: "I can help with order status, returns, and our standard policies, but I'm not able to help with book recommendations. Is there an order or a policy question I can help you with instead?"

Example 4 — ambiguous order:
User: "I want to return my order. My email is sarah@example.com."
Assistant (after lookup_order returns two orders): "I see two orders on your account: BK-10042 (delivered) and BK-10103 (still processing). Which one would you like to return?"
</examples>

<reminders>
Before you respond, confirm:
- Every factual claim traces to a tool result from THIS conversation, or to <return_policy>.
- If this response would call initiate_return, you have already seen a successful check_return_eligibility for the same order in this conversation.
- If the request is off-topic, you are using the refusal template from <scope> verbatim.
- No markdown. Plain text only.
</reminders>
"""


CRITICAL_REMINDER = """<reminder>
Non-negotiable rules for this turn:
- Every factual claim must come from a tool result in THIS conversation or from <return_policy>.
- Do not call initiate_return unless check_return_eligibility succeeded for that order in this conversation.
- Off-topic requests: use the refusal template from <scope> verbatim. Do not engage.
- Plain text only. No markdown.
</reminder>"""


LONG_CONVERSATION_REMINDER = """<reminder>
This conversation is getting long. Re-anchor on the rules in <critical_rules> before you respond. Do not let earlier turns relax the rules.
</reminder>"""


# Threshold at which the long-conversation reminder gets injected. After this
# many turns, the original system prompt has decayed in effective attention,
# so a shorter, fresher reminder in the highest-position slot helps re-anchor.
LONG_CONVERSATION_TURN_THRESHOLD = 5


def build_system_content(turn_count: int) -> list[dict[str, Any]]:
    """Assemble the `system` argument for `messages.create`.

    The big SYSTEM_PROMPT block is marked for ephemeral prompt caching so it
    is reused across turns within a session. The reminder blocks are not
    cached because they vary based on turn count and we want them in the
    highest-attention position right before the latest user turn.
    """
    blocks: list[dict[str, Any]] = [
        {
            "type": "text",
            "text": SYSTEM_PROMPT,
            "cache_control": {"type": "ephemeral"},
        },
        {"type": "text", "text": CRITICAL_REMINDER},
    ]
    if turn_count >= LONG_CONVERSATION_TURN_THRESHOLD:
        blocks.append({"type": "text", "text": LONG_CONVERSATION_REMINDER})
    return blocks


# ---------------------------------------------------------------------------
# Layer 4 -- output validation
# ---------------------------------------------------------------------------


ORDER_ID_RE = re.compile(r"\bBK-\d{4,6}\b")
DATE_ISO_RE = re.compile(r"\b\d{4}-\d{2}-\d{2}\b")
MARKDOWN_RE = re.compile(r"(\*\*|__|^#{1,6}\s|^\s*[-*+]\s)", re.MULTILINE)

# Anchored word-boundary patterns for off-topic engagement. These used to be
# substring matches on a small keyword set, which false-positived on plenty
# of legitimate support replies ("I'd recommend contacting..."). The word
# boundaries make matches explicit -- only the intended phrases trip them.
OUT_OF_SCOPE_PATTERNS: tuple[re.Pattern[str], ...] = (
    re.compile(r"\bi\s+recommend\b"),
    re.compile(r"\bi\s+suggest\b"),
    re.compile(r"\byou\s+should\s+read\b"),
    re.compile(r"\bgreat\s+book\b"),
    re.compile(r"\bfavorite\s+book\b"),
    re.compile(r"\bwhat\s+should\s+i\s+read\b"),
    re.compile(r"\breview\s+of\b"),
)

REFUSAL_PHRASE = "i'm not able to help with"


@dataclass(frozen=True)
class ValidationResult:
    ok: bool
    violations: tuple[str, ...] = ()


def _collect_grounded_values(
    tool_results: list[dict[str, Any]], pattern: re.Pattern[str]
) -> set[str]:
    """Pull every substring matching `pattern` out of the tool result JSON."""
    grounded: set[str] = set()
    for entry in tool_results:
        text = json.dumps(entry.get("result", {}))
        grounded.update(pattern.findall(text))
    return grounded


def validate_reply(reply: str, tool_results_this_turn: list[dict[str, Any]]) -> ValidationResult:
    """Run deterministic checks on the final assistant reply.

    Heuristic, not exhaustive. Catches the cheap wins -- fabricated order IDs,
    made-up dates, markdown leakage, and obvious off-topic engagement. For
    anything subtler we rely on layers 1-3.
    """
    if not isinstance(reply, str):
        raise TypeError("reply must be a string")
    if not isinstance(tool_results_this_turn, list):
        raise TypeError("tool_results_this_turn must be a list")

    violations: list[str] = []

    grounded_ids = _collect_grounded_values(tool_results_this_turn, ORDER_ID_RE)
    for match in ORDER_ID_RE.findall(reply):
        if match not in grounded_ids:
            violations.append(f"ungrounded_order_id:{match}")

    grounded_dates = _collect_grounded_values(tool_results_this_turn, DATE_ISO_RE)
    for match in DATE_ISO_RE.findall(reply):
        if match not in grounded_dates:
            violations.append(f"ungrounded_date:{match}")

    if MARKDOWN_RE.search(reply):
        violations.append("markdown_leaked")

    lowered = reply.lower()
    engaged_off_topic = any(pattern.search(lowered) for pattern in OUT_OF_SCOPE_PATTERNS)
    if engaged_off_topic and REFUSAL_PHRASE not in lowered:
        violations.append("off_topic_engagement")

    return ValidationResult(ok=not violations, violations=tuple(violations))


# ---------------------------------------------------------------------------
# Session store
# ---------------------------------------------------------------------------


SAFE_FALLBACK = (
    "I hit a problem generating a response. Could you rephrase your question, "
    "or share an order ID so I can try again?"
)

CONVERSATION_TOO_LONG_MESSAGE = (
    "This conversation has gone long enough that I need to reset before I keep "
    "making mistakes. Please start a new chat and I'll be happy to help from there."
)

TOOL_LOOP_EXCEEDED_MESSAGE = (
    "I got stuck working on that request. Could you try rephrasing it, or share "
    "an order ID so I can try a fresh approach?"
)


@dataclass
class Session:
    history: list[dict[str, Any]] = field(default_factory=list)
    guard_state: SessionGuardState = field(default_factory=SessionGuardState)
    turn_count: int = 0


class SessionStore:
    """Bounded in-memory session store with LRU eviction and idle TTL.

    Also owns the per-session locks used to serialize turns for the same
    session_id when FastAPI runs the sync handler in its threadpool. The
    creation of a per-session lock is itself guarded by a class-level lock to
    avoid a double-create race.

    Designed for a single-process demo deployment. For multi-worker, swap
    this class out for a Redis-backed equivalent.
    """

    def __init__(self, *, max_entries: int, idle_ttl_seconds: int) -> None:
        if max_entries <= 0:
            raise ValueError("max_entries must be positive")
        if idle_ttl_seconds <= 0:
            raise ValueError("idle_ttl_seconds must be positive")
        self._max_entries = max_entries
        self._idle_ttl_seconds = idle_ttl_seconds
        self._entries: OrderedDict[str, tuple[Session, float]] = OrderedDict()
        self._store_lock = threading.Lock()
        self._session_locks: dict[str, threading.Lock] = {}
        self._locks_lock = threading.Lock()

    def get_or_create(self, session_id: str) -> Session:
        if not isinstance(session_id, str) or not session_id:
            raise ValueError("session_id is required")
        now = time.monotonic()
        with self._store_lock:
            self._evict_expired_locked(now)
            entry = self._entries.get(session_id)
            if entry is None:
                session = Session()
                self._entries[session_id] = (session, now)
                self._enforce_size_cap_locked()
                return session
            session, _ = entry
            self._entries[session_id] = (session, now)
            self._entries.move_to_end(session_id)
            return session

    def lock_for(self, session_id: str) -> threading.Lock:
        """Return the lock guarding turns for `session_id`, creating if new."""
        if not isinstance(session_id, str) or not session_id:
            raise ValueError("session_id is required")
        with self._locks_lock:
            lock = self._session_locks.get(session_id)
            if lock is None:
                lock = threading.Lock()
                self._session_locks[session_id] = lock
            return lock

    def clear(self) -> None:
        """Drop all sessions. Intended for tests and admin operations only."""
        with self._store_lock:
            self._entries.clear()
        with self._locks_lock:
            self._session_locks.clear()

    def __len__(self) -> int:
        with self._store_lock:
            return len(self._entries)

    def __contains__(self, session_id: object) -> bool:
        if not isinstance(session_id, str):
            return False
        with self._store_lock:
            return session_id in self._entries

    def __getitem__(self, session_id: str) -> Session:
        with self._store_lock:
            entry = self._entries.get(session_id)
            if entry is None:
                raise KeyError(session_id)
            return entry[0]

    def _evict_expired_locked(self, now: float) -> None:
        expired = [
            sid for sid, (_, last) in self._entries.items() if now - last > self._idle_ttl_seconds
        ]
        for sid in expired:
            del self._entries[sid]
            with self._locks_lock:
                self._session_locks.pop(sid, None)

    def _enforce_size_cap_locked(self) -> None:
        while len(self._entries) > self._max_entries:
            sid, _ = self._entries.popitem(last=False)
            with self._locks_lock:
                self._session_locks.pop(sid, None)


SESSIONS = SessionStore(
    max_entries=settings.session_store_max_entries,
    idle_ttl_seconds=settings.session_idle_ttl_seconds,
)


# ---------------------------------------------------------------------------
# Anthropic client
# ---------------------------------------------------------------------------


@functools.lru_cache(maxsize=1)
def _get_client() -> Anthropic:
    """Return the shared Anthropic client.

    Cached so every turn reuses the same HTTP connection pool. Tests swap
    this out with `monkeypatch.setattr(agent, "_get_client", ...)`.
    """
    return Anthropic(
        api_key=settings.anthropic_api_key.get_secret_value(),
        timeout=settings.anthropic_timeout_seconds,
    )


# ---------------------------------------------------------------------------
# Content serialization helpers
# ---------------------------------------------------------------------------


def _extract_text(content_blocks: list[Any]) -> str:
    parts: list[str] = []
    for block in content_blocks:
        if getattr(block, "type", None) == "text":
            parts.append(getattr(block, "text", ""))
    return "".join(parts).strip()


def _serialize_assistant_content(content_blocks: list[Any]) -> list[dict[str, Any]]:
    """Convert SDK content blocks back into JSON-serializable dicts for history."""
    serialized: list[dict[str, Any]] = []
    for block in content_blocks:
        block_type = getattr(block, "type", None)
        if block_type == "text":
            serialized.append({"type": "text", "text": getattr(block, "text", "")})
        elif block_type == "tool_use":
            serialized.append(
                {
                    "type": "tool_use",
                    "id": block.id,
                    "name": block.name,
                    "input": getattr(block, "input", None) or {},
                }
            )
    return serialized


def _with_last_message_cache_breakpoint(
    messages: list[dict[str, Any]],
) -> list[dict[str, Any]]:
    """Return a shallow-copied message list with a cache breakpoint on the last block.

    Marking the last content block with `cache_control: ephemeral` extends the
    prompt cache through the full conversation history so prior turns do not
    need to be re-tokenized on every call. We avoid mutating the stored history
    because the stored form should stay canonical.
    """
    if not messages:
        return messages
    head = messages[:-1]
    last = dict(messages[-1])
    content = last.get("content")
    if isinstance(content, str):
        last["content"] = [
            {"type": "text", "text": content, "cache_control": {"type": "ephemeral"}}
        ]
    elif isinstance(content, list) and content:
        new_content = [dict(block) for block in content]
        new_content[-1] = {**new_content[-1], "cache_control": {"type": "ephemeral"}}
        last["content"] = new_content
    return head + [last]


def _hash_for_logging(value: str) -> str:
    """Short stable hash for log correlation without leaking content."""
    return hashlib.sha256(value.encode("utf-8")).hexdigest()[:16]


# ---------------------------------------------------------------------------
# Agent loop
# ---------------------------------------------------------------------------


class ToolLoopLimitExceeded(RuntimeError):
    """Raised when the tool-use loop hits `settings.max_tool_use_iterations`."""


def _run_tool_use_loop(
    session: Session,
    system_content: list[dict[str, Any]],
    client: Anthropic,
) -> tuple[Any, list[dict[str, Any]]]:
    """Drive the model until it stops asking for tools.

    Returns the final Anthropic response object plus the cumulative list of
    tool results produced during the turn (used by Layer 4 validation to
    check for ungrounded claims in the final reply).

    Raises `ToolLoopLimitExceeded` if the model keeps asking for tools past
    `settings.max_tool_use_iterations`. This is the structural guard that
    prevents one bad request from burning API credit in an infinite loop.
    """
    tool_results_this_turn: list[dict[str, Any]] = []

    response = client.messages.create(
        model=settings.anthropic_model,
        max_tokens=settings.max_tokens,
        system=system_content,
        tools=TOOL_SCHEMAS,
        messages=_with_last_message_cache_breakpoint(session.history),
    )

    for _ in range(settings.max_tool_use_iterations):
        if getattr(response, "stop_reason", None) != "tool_use":
            return response, tool_results_this_turn

        assistant_blocks = _serialize_assistant_content(response.content)
        session.history.append({"role": "assistant", "content": assistant_blocks})

        tool_result_blocks: list[dict[str, Any]] = []
        for block in response.content:
            if getattr(block, "type", None) != "tool_use":
                continue
            name = block.name
            args = getattr(block, "input", None) or {}
            tool_id = block.id
            result = dispatch_tool(name, args, session.guard_state)
            tool_results_this_turn.append({"name": name, "result": result})
            tool_result_blocks.append(
                {
                    "type": "tool_result",
                    "tool_use_id": tool_id,
                    "content": json.dumps(result, ensure_ascii=False),
                }
            )

        session.history.append({"role": "user", "content": tool_result_blocks})

        response = client.messages.create(
            model=settings.anthropic_model,
            max_tokens=settings.max_tokens,
            system=system_content,
            tools=TOOL_SCHEMAS,
            messages=_with_last_message_cache_breakpoint(session.history),
        )

    # Fell out of the for loop without hitting `end_turn` -- the model is
    # still asking for tools. Refuse the request rather than loop forever.
    raise ToolLoopLimitExceeded(
        f"Tool-use loop did not terminate within {settings.max_tool_use_iterations} iterations"
    )


def run_turn(session_id: str, user_message: str) -> str:
    """Run one user turn end-to-end and return the assistant's reply text.

    Wires together: session lookup with locking, history append, system
    content with reminders, the tool-use loop, output validation, and the
    safe-fallback path on validation failure.
    """
    if not isinstance(session_id, str) or not session_id:
        raise ValueError("session_id is required")
    if not isinstance(user_message, str) or not user_message.strip():
        raise ValueError("user_message is required")

    session_lock = SESSIONS.lock_for(session_id)
    with session_lock:
        session = SESSIONS.get_or_create(session_id)

        # Bounded conversations. Past the limit we refuse rather than let
        # history grow unbounded, which keeps memory usage predictable and
        # avoids the model losing coherence late in a chat.
        if session.turn_count >= settings.max_turns_per_session:
            return CONVERSATION_TOO_LONG_MESSAGE

        session.history.append({"role": "user", "content": user_message})
        system_content = build_system_content(session.turn_count)
        client = _get_client()

        try:
            response, tool_results_this_turn = _run_tool_use_loop(session, system_content, client)
        except ToolLoopLimitExceeded:
            logger.warning(
                "tool_loop_exceeded session=%s turn=%s",
                _hash_for_logging(session_id),
                session.turn_count,
            )
            session.turn_count += 1
            return TOOL_LOOP_EXCEEDED_MESSAGE

        reply_text = _extract_text(response.content)
        validation = validate_reply(reply_text, tool_results_this_turn)
        if not validation.ok:
            # Redact PII: log only violation codes plus hashes of session and
            # reply. Never log the reply body -- it may contain customer name,
            # email, order ID, or tracking number.
            logger.warning(
                "validation_failed session=%s turn=%s violations=%s reply_sha=%s",
                _hash_for_logging(session_id),
                session.turn_count,
                list(validation.violations),
                _hash_for_logging(reply_text),
            )
            # Do NOT append the bad reply to history -- that would poison
            # future turns.
            session.turn_count += 1
            return SAFE_FALLBACK

        session.history.append(
            {"role": "assistant", "content": _serialize_assistant_content(response.content)}
        )
        session.turn_count += 1
        return reply_text

HTTP surface

The FastAPI app exposes four routes: GET /health, GET / (redirects to /static/index.html), POST /api/chat, and GET /architecture (this very document). Everything else is deliberately missing -- the OpenAPI docs pages and the redoc pages are disabled so the public surface is as small as possible.

Security headers

A middleware injects a strict Content-Security-Policy and friends on every response. CSP is defense in depth: the chat UI in static/chat.js already renders model replies with textContent rather than innerHTML, so XSS is structurally impossible today. The CSP exists to catch any future regression that accidentally switches to innerHTML.

Datadog RUM adds three narrow allowances to that baseline: the Browser SDK CDN in script-src, the Datadog intake origin in connect-src, and worker-src blob: for Session Replay. The real environment gate lives in static/rum.js, which checks for exactly bookly.codyborders.com before loading the SDK, so localhost and preview hosts still stay dark.

Sliding-window rate limiter

SlidingWindowRateLimiter keeps a deque of timestamps per key and evicts anything older than the window. The /api/chat handler checks twice per call -- once with an ip: prefix, once with a session: prefix -- so a single attacker cannot exhaust the per-session budget by rotating cookies, and a legitimate user does not get locked out by a noisy neighbour on the same IP.

Suitable for a single-process demo deployment. A multi-worker deployment would externalize this to Redis.

Session cookies

The client never chooses its own session ID. On the first request a new random ID is minted, HMAC-signed with settings.session_secret, and set in an HttpOnly, SameSite=Lax cookie. Subsequent requests carry the cookie; the server verifies the signature in constant time (hmac.compare_digest) and trusts nothing else. A leaked or guessed request body cannot hijack another user's conversation because the session ID is not in the body at all.

/api/chat

The handler resolves the session, checks both rate limits, then calls into agent.run_turn. The Anthropic exception hierarchy is caught explicitly so a rate-limit incident and a code bug cannot look identical to operators: anthropic.RateLimitError becomes 503, APIConnectionError becomes 503, APIStatusError becomes 502, ValueError from the agent becomes 400, anything else becomes 500.

/architecture

This is where the woven literate program is served. The handler reads static/architecture.html (produced by pandoc from this file) and returns it with a relaxed CSP. The one deliberate CSP change is style-src 'unsafe-inline', because pandoc's standalone HTML emits inline styles. The page also gets the same /static/rum.js bootstrap as the chat UI, but that injection happens at response time so the generated artifact on disk stays unchanged. If the file does not exist yet, the route 404s with a clear message rather than raising a 500.

"""FastAPI app for Bookly. Hosts /api/chat, /health, and the static chat UI.

Security posture notes:

- Sessions are server-issued and HMAC-signed. The client never chooses its
  own session ID, so a leaked or guessed body cannot hijack someone else's
  chat history. See `_resolve_session`.
- Every response carries a strict Content-Security-Policy and related
  headers (see `security_headers`). The chat UI already uses `textContent`
  for model replies, so XSS is structurally impossible; CSP is defense in
  depth for future edits.
- In-memory sliding-window rate limiting is applied per IP and per session.
  Suitable for a single-process demo deployment; swap to a shared store for
  multi-worker.
"""

from __future__ import annotations

import hashlib
import hmac
import logging
import secrets
import threading
import time
from collections import defaultdict, deque
from pathlib import Path

import anthropic
from fastapi import FastAPI, HTTPException, Request, Response
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field

import agent
from config import settings

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s")
logger = logging.getLogger("bookly.server")

app = FastAPI(title="Bookly", docs_url=None, redoc_url=None)


# ---------------------------------------------------------------------------
# Security headers
# ---------------------------------------------------------------------------


_DATADOG_SCRIPT_ORIGIN = "https://www.datadoghq-browser-agent.com"
_DATADOG_RUM_INTAKE_ORIGIN = "https://browser-intake-datadoghq.com"
_RUM_BOOTSTRAP_TAG = '<script src="/static/rum.js"></script>'


def _build_content_security_policy(*, allow_inline_styles: bool) -> str:
    """Return the CSP shared by the chat UI and the architecture page.

    Datadog RUM needs explicit allowances for its CDN loader, its intake
    endpoint, and its Session Replay worker. We keep the policy otherwise
    strict and let the browser-side bootstrap decide whether the current host
    is allowed to initialize RUM at all.
    """
    style_source = "style-src 'self'"
    if allow_inline_styles:
        style_source = "style-src 'self' 'unsafe-inline'"

    directives = (
        "default-src 'self'",
        f"script-src 'self' {_DATADOG_SCRIPT_ORIGIN}",
        style_source,
        "img-src 'self' data:",
        f"connect-src 'self' {_DATADOG_RUM_INTAKE_ORIGIN}",
        "worker-src blob:",
        "object-src 'none'",
        "base-uri 'none'",
        "frame-ancestors 'none'",
        "form-action 'self'",
    )
    return "; ".join(directives)


def _inject_rum_bootstrap(html: str) -> str:
    """Inject the shared RUM bootstrap into a standalone HTML document.

    `/architecture` serves a prebuilt Pandoc artifact from disk. Injecting the
    shared bootstrap here keeps the artifact byte-for-byte unchanged while
    ensuring the live page gets the same RUM loader as `/static/index.html`.
    """
    if not html:
        raise ValueError("html must be non-empty")
    if _RUM_BOOTSTRAP_TAG in html:
        return html

    head_close = "</head>"
    if head_close not in html:
        raise ValueError("architecture html is missing </head>")

    updated_html = html.replace(head_close, f"  {_RUM_BOOTSTRAP_TAG}
{head_close}", 1)
    assert _RUM_BOOTSTRAP_TAG in updated_html
    assert updated_html.count(_RUM_BOOTSTRAP_TAG) == 1
    return updated_html


_SECURITY_HEADERS: dict[str, str] = {
    # Tight CSP: same-origin assets plus only the Datadog endpoints needed for
    # browser RUM and Session Replay. The exact hostname gate lives in
    # `static/rum.js`, so localhost and preview hosts stay dark.
    "Content-Security-Policy": _build_content_security_policy(allow_inline_styles=False),
    "X-Content-Type-Options": "nosniff",
    "X-Frame-Options": "DENY",
    "Referrer-Policy": "no-referrer",
    "Permissions-Policy": "geolocation=(), microphone=(), camera=()",
}


@app.middleware("http")
async def security_headers(request: Request, call_next):
    response = await call_next(request)
    for header_name, header_value in _SECURITY_HEADERS.items():
        response.headers.setdefault(header_name, header_value)
    return response


# ---------------------------------------------------------------------------
# Sliding-window rate limiter (in-memory)
# ---------------------------------------------------------------------------


class SlidingWindowRateLimiter:
    """Per-key request counter over a fixed trailing window.

    Not meant to be bulletproof -- this is a small demo deployment, not an
    edge-network WAF. Enforces a ceiling per IP and per session so a single
    caller cannot burn the Anthropic budget or exhaust memory by spamming
    `/api/chat`.
    """

    def __init__(self, *, window_seconds: int = 60) -> None:
        if window_seconds <= 0:
            raise ValueError("window_seconds must be positive")
        self._window = window_seconds
        self._hits: defaultdict[str, deque[float]] = defaultdict(deque)
        self._lock = threading.Lock()

    def check(self, key: str, max_hits: int) -> bool:
        """Record a hit on `key`. Returns True if under the limit, False otherwise."""
        if max_hits <= 0:
            return False
        now = time.monotonic()
        cutoff = now - self._window
        with self._lock:
            bucket = self._hits[key]
            while bucket and bucket[0] < cutoff:
                bucket.popleft()
            if len(bucket) >= max_hits:
                return False
            bucket.append(now)
            return True


_rate_limiter = SlidingWindowRateLimiter(window_seconds=60)


def _client_ip(request: Request) -> str:
    """Best-effort client IP for rate limiting.

    If the app is deployed behind a reverse proxy, set the proxy to add
    `X-Forwarded-For` and trust the first hop. Otherwise fall back to the
    direct client address.
    """
    forwarded = request.headers.get("x-forwarded-for", "")
    if forwarded:
        first = forwarded.split(",", 1)[0].strip()
        if first:
            return first
    if request.client is not None:
        return request.client.host
    return "unknown"


# ---------------------------------------------------------------------------
# Session cookies (server-issued, HMAC-signed)
# ---------------------------------------------------------------------------


_SESSION_COOKIE_SEPARATOR = "."


def _sign_session_id(session_id: str) -> str:
    secret = settings.session_secret.get_secret_value().encode("utf-8")
    signature = hmac.new(secret, session_id.encode("utf-8"), hashlib.sha256).hexdigest()
    return f"{session_id}{_SESSION_COOKIE_SEPARATOR}{signature}"


def _verify_signed_session(signed_value: str) -> str | None:
    if not signed_value or _SESSION_COOKIE_SEPARATOR not in signed_value:
        return None
    session_id, _, signature = signed_value.partition(_SESSION_COOKIE_SEPARATOR)
    if not session_id or not signature:
        return None
    expected = _sign_session_id(session_id)
    # Compare the full signed form in constant time to avoid timing leaks on
    # the signature bytes.
    if not hmac.compare_digest(expected, signed_value):
        return None
    return session_id


def _issue_new_session_id() -> str:
    return secrets.token_urlsafe(24)


def _resolve_session(request: Request, response: Response) -> str:
    """Return the session_id for this request, issuing a fresh cookie if needed.

    The client never chooses the session_id. Anything in the request body
    that claims to be one is ignored. If the cookie is missing or tampered
    with, we mint a new session_id and set the cookie on the response.
    """
    signed_cookie = request.cookies.get(settings.session_cookie_name, "")
    session_id = _verify_signed_session(signed_cookie)
    if session_id is not None:
        return session_id

    session_id = _issue_new_session_id()
    response.set_cookie(
        key=settings.session_cookie_name,
        value=_sign_session_id(session_id),
        max_age=settings.session_cookie_max_age_seconds,
        httponly=True,
        secure=settings.session_cookie_secure,
        samesite="lax",
        path="/",
    )
    return session_id


# ---------------------------------------------------------------------------
# Request/response models
# ---------------------------------------------------------------------------


class ChatRequest(BaseModel):
    # `session_id` is intentionally NOT accepted from clients. Sessions are
    # tracked server-side via the signed cookie.
    message: str = Field(..., min_length=1, max_length=4000)


class ChatResponse(BaseModel):
    reply: str


# ---------------------------------------------------------------------------
# Routes
# ---------------------------------------------------------------------------


@app.get("/health")
def health() -> dict:
    return {"status": "ok"}


@app.get("/")
def root() -> RedirectResponse:
    return RedirectResponse(url="/static/index.html")


@app.post("/api/chat", response_model=ChatResponse)
def chat(body: ChatRequest, http_request: Request, http_response: Response) -> ChatResponse:
    session_id = _resolve_session(http_request, http_response)

    ip = _client_ip(http_request)
    if not _rate_limiter.check(f"ip:{ip}", settings.rate_limit_per_ip_per_minute):
        logger.info("rate_limited scope=ip")
        raise HTTPException(status_code=429, detail="Too many requests. Please slow down.")
    if not _rate_limiter.check(f"session:{session_id}", settings.rate_limit_per_session_per_minute):
        logger.info("rate_limited scope=session")
        raise HTTPException(status_code=429, detail="Too many requests. Please slow down.")

    try:
        reply = agent.run_turn(session_id, body.message)
    except anthropic.RateLimitError:
        logger.warning("anthropic_rate_limited")
        raise HTTPException(
            status_code=503,
            detail="Our AI provider is busy right now. Please try again in a moment.",
        )
    except anthropic.APIConnectionError:
        logger.warning("anthropic_connection_error")
        raise HTTPException(
            status_code=503,
            detail="We couldn't reach our AI provider. Please try again in a moment.",
        )
    except anthropic.APIStatusError as exc:
        logger.error("anthropic_api_error status=%s", exc.status_code)
        raise HTTPException(
            status_code=502,
            detail="Our AI provider returned an error. Please try again.",
        )
    except ValueError:
        # Programmer-visible input errors (e.g., blank message). Surface a
        # 400 rather than a 500 so clients can distinguish.
        raise HTTPException(status_code=400, detail="Invalid request.")
    except Exception:
        logger.exception("chat_failed")
        raise HTTPException(status_code=500, detail="Something went wrong handling that message.")

    return ChatResponse(reply=reply)


# Absolute path so the mount works regardless of the process working directory.
_STATIC_DIR = Path(__file__).resolve().parent / "static"
_ARCHITECTURE_HTML_PATH = _STATIC_DIR / "architecture.html"


# Pandoc-generated literate program. The HTML comes from weaving Bookly.lit.md
# and contains inline styles (and inline SVG from mermaid-filter), so the
# chat-page CSP needs one change here: allow inline styles while keeping the
# same Datadog allowances used by the shared RUM bootstrap.
_ARCHITECTURE_CSP = _build_content_security_policy(allow_inline_styles=True)


@app.get("/architecture", response_class=HTMLResponse)
def architecture() -> HTMLResponse:
    """Serve the woven literate program for the Bookly codebase."""
    try:
        html = _ARCHITECTURE_HTML_PATH.read_text(encoding="utf-8")
    except FileNotFoundError:
        raise HTTPException(
            status_code=404,
            detail="Architecture document has not been built yet.",
        )
    response = HTMLResponse(content=_inject_rum_bootstrap(html))
    response.headers["Content-Security-Policy"] = _ARCHITECTURE_CSP
    return response


app.mount("/static", StaticFiles(directory=str(_STATIC_DIR)), name="static")