diff --git a/backend/app/alembic/versions/010_add_traceability_columns.py b/backend/app/alembic/versions/010_add_traceability_columns.py new file mode 100644 index 0000000..a023b09 --- /dev/null +++ b/backend/app/alembic/versions/010_add_traceability_columns.py @@ -0,0 +1,84 @@ +"""Add traceability columns to request_log and validator_log + +Revision ID: 010 +Revises: 009 +Create Date: 2026-08-25 00:00:00.000000 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects.postgresql import JSONB + +revision: str = "010" +down_revision = "009" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "request_log", + sa.Column( + "metadata", + JSONB, + nullable=True, + comment="Full run_guardrails request payload", + ), + ) + op.add_column( + "validator_log", + sa.Column( + "order", + sa.Integer(), + nullable=True, + comment="1-based execution order of the validator within the request", + ), + ) + op.add_column( + "validator_log", + sa.Column( + "duration_ms", + sa.Integer(), + nullable=True, + comment="Wall-clock execution time of the validator in milliseconds", + ), + ) + op.add_column( + "validator_log", + sa.Column( + "stage", + sa.String(), + nullable=True, + comment="Stage the validator checked (input or output)", + ), + ) + op.add_column( + "validator_log", + sa.Column( + "type", + sa.String(), + nullable=True, + comment="Validator type (ValidatorType enum value)", + ), + ) + op.add_column( + "validator_log", + sa.Column( + "metadata", + JSONB, + nullable=True, + comment="Full resolved validator config used for this run", + ), + ) + + +def downgrade() -> None: + op.drop_column("validator_log", "metadata") + op.drop_column("validator_log", "type") + op.drop_column("validator_log", "stage") + op.drop_column("validator_log", "duration_ms") + op.drop_column("validator_log", "order") + op.drop_column("request_log", "metadata") diff --git a/backend/app/api/API_USAGE.md b/backend/app/api/API_USAGE.md index 2c6f263..e04d003 100644 --- a/backend/app/api/API_USAGE.md +++ b/backend/app/api/API_USAGE.md @@ -186,7 +186,7 @@ Endpoint: - `POST /api/v1/guardrails/` Query params: -- `suppress_pass_logs=true|false` (default `true`) +- `suppress_pass_logs=true|false` (default `false`: pass-case validator logs are persisted too; set `true` to store only fail-case logs) Request fields: - `request_id` (UUID string) @@ -204,7 +204,7 @@ Important: Example: ```bash -curl -X POST "http://localhost:8001/api/v1/guardrails/?suppress_pass_logs=true" \ +curl -X POST "http://localhost:8001/api/v1/guardrails/" \ -H "Authorization: Bearer " \ -H "X-ORGANIZATION-ID: 1" \ -H "X-PROJECT-ID: 101" \ diff --git a/backend/app/api/docs/guardrails/run_guardrails.md b/backend/app/api/docs/guardrails/run_guardrails.md index 7a02220..b48f6b2 100644 --- a/backend/app/api/docs/guardrails/run_guardrails.md +++ b/backend/app/api/docs/guardrails/run_guardrails.md @@ -2,9 +2,9 @@ Runs guardrails on input text with a selected list of validators. Behavior notes: - Runtime validator format uses `on_fail`; config-style payloads with `on_fail_action` are accepted and normalized. -- `suppress_pass_logs=true` skips persisting pass-case validator logs. -- The endpoint always saves a `request_log` entry for the run. -- Validator logs are also saved; with `suppress_pass_logs=true`, only fail-case validator logs are persisted. Otherwise, all validator logs are added. +- By default (`suppress_pass_logs=false`), a validator log is persisted for every executed validator, pass or fail. Pass `suppress_pass_logs=true` to persist only fail-case validator logs. +- The endpoint always saves a `request_log` entry for the run, including the full request payload in its `metadata`. +- Each validator log records the validator's execution `order`, `duration_ms`, `stage` (input/output), `type`, and the resolved config (plus any verdict detail under `result_metadata`) in `metadata`. - For `ban_list`, `ban_list_id` can be resolved to `banned_words` from tenant ban list configs. - For `topic_relevance`, `topic_relevance_config_id` is required and is resolved to `llm_prompt` + `prompt_schema_version` from tenant LLM prompt configs. Requires `OPENAI_API_KEY` to be configured; returns a validation failure with an explicit error if missing. - For `llm_critic`, `OPENAI_API_KEY` must be configured; returns `success=false` with an explicit error if missing. diff --git a/backend/app/api/routes/guardrails.py b/backend/app/api/routes/guardrails.py index 5779c93..d615833 100644 --- a/backend/app/api/routes/guardrails.py +++ b/backend/app/api/routes/guardrails.py @@ -1,3 +1,5 @@ +import json +import logging import uuid from uuid import UUID @@ -13,8 +15,8 @@ LLM_CRITIC_REPHRASE_MESSAGE, REPHRASE_ON_FAIL_PREFIX, ) -from app.core.enum import LLMValidatorName, ValidatorType -from app.core.exception_handlers import _safe_error_message +from app.core.enum import LLMValidatorName, Stage, ValidatorType +from app.core.exception_handlers import _normalize_error_detail, _safe_error_message from app.core.guardrail_controller import build_guard, get_validator_config_models from app.core.validators.config.answer_relevance_custom_llm_safety_validator_config import ( AnswerRelevanceCustomLLMSafetyValidatorConfig, @@ -34,9 +36,15 @@ from app.crud.validator_log import ValidatorLogCrud from app.models.logging.request_log import RequestLogUpdate, RequestStatus from app.models.logging.validator_log import ValidatorLog, ValidatorOutcome -from app.schemas.guardrail_config import GuardrailRequest, GuardrailResponse +from app.schemas.guardrail_config import ( + GuardrailRequest, + GuardrailResponse, + ValidatorConfigItem, +) from app.utils import APIResponse, load_description +logger = logging.getLogger(__name__) + router = APIRouter(prefix="/guardrails", tags=["guardrails"]) @@ -50,7 +58,7 @@ def run_guardrails( payload: GuardrailRequest, session: SessionDep, auth: AuthDep, - suppress_pass_logs: bool = True, + suppress_pass_logs: bool = False, ): """ Resolves any config-backed validator references (ban list words, topic relevance scope), @@ -61,12 +69,40 @@ def run_guardrails( try: request_log = request_log_crud.create( - payload, auth.organization_id, auth.project_id + payload, auth.organization_id, auth.project_id, suppress_pass_logs ) except ValueError: + logger.warning( + "[run_guardrails] invalid request_id %r (org=%s project=%s), no request log written", + payload.request_id, + auth.organization_id, + auth.project_id, + ) return APIResponse.failure_response(error="Invalid request_id") - _resolve_validator_configs(payload, session, auth) + try: + _resolve_validator_configs(payload, session, auth) + except Exception as exc: + # Config resolution failed (missing/mismatched stored config, DB error). + # Finalize the request log so it never sits at PROCESSING forever. + error_message = ( + _normalize_error_detail(exc.detail) + if isinstance(exc, HTTPException) + else _safe_error_message(exc) + ) + if isinstance(error_message, list): + error_message = "; ".join(str(item) for item in error_message) + logger.error( + "[run_guardrails] config resolution failed for request_log %s: %s", + request_log.id, + exc, + exc_info=not isinstance(exc, HTTPException), + ) + _mark_request_failed(request_log_crud, request_log.id, error_message) + if isinstance(exc, HTTPException): + raise + return APIResponse.failure_response(error=error_message) + has_output_validator = any( isinstance(v, AnswerRelevanceCustomLLMSafetyValidatorConfig) for v in payload.validators @@ -84,7 +120,7 @@ def run_guardrails( @router.get("/", description=load_description("guardrails/list_validators.md")) -def list_validators(auth: AuthDep): +def list_validators(_: AuthDep): """ Lists all validators and their parameters directly. """ @@ -113,6 +149,29 @@ def list_validators(auth: AuthDep): return {"validators": validators} +def _mark_request_failed( + request_log_crud: RequestLogCrud, request_log_id: UUID, error_message: str +) -> None: + """Best-effort finalization of a request log on an early-exit error path.""" + try: + # The error that got us here may have left the shared session in a + # failed-transaction state; clear it or this update raises too. + request_log_crud.session.rollback() + request_log_crud.update( + request_log_id=request_log_id, + request_status=RequestStatus.ERROR, + request_log_update=RequestLogUpdate( + response_text=error_message, + response_id=uuid.uuid4(), + ), + ) + except Exception: + logger.exception( + "[_mark_request_failed] failed to finalize request log %s after an error", + request_log_id, + ) + + def _resolve_validator_configs( payload: GuardrailRequest, session: Session, auth: TenantContext ) -> None: @@ -221,19 +280,39 @@ def _finalize( if response_text is None: response_text = "Validation failed" - request_log_crud.update( - request_log_id=request_log_id, - request_status=status, - request_log_update=RequestLogUpdate( - response_text=response_text, - response_id=response_id, - ), - ) + # Log-persistence failures are logged but never break the user response. + try: + request_log_crud.update( + request_log_id=request_log_id, + request_status=status, + request_log_update=RequestLogUpdate( + response_text=response_text, + response_id=response_id, + ), + ) + except Exception: + logger.exception( + "[_finalize] failed to update request log %s", request_log_id + ) + # Clear the failed transaction so the validator-log writes + # below still have a usable session. + request_log_crud.session.rollback() if guard is not None: - add_validator_logs( - guard, request_log_id, validator_log_crud, auth, suppress_pass_logs - ) + try: + add_validator_logs( + guard, + request_log_id, + validator_log_crud, + auth, + suppress_pass_logs, + validator_configs=validators, + ) + except Exception: + logger.exception( + "[_finalize] failed to write validator logs for request log %s", + request_log_id, + ) rephrase_needed = validated_output is not None and ( validated_output == LLM_CRITIC_REPHRASE_MESSAGE @@ -277,6 +356,12 @@ def _finalize( ) except Exception as exc: + logger.error( + "[_validate_with_guard] guardrails execution failed for request log %s: %s", + request_log_id, + exc, + exc_info=True, + ) # Case 3: unexpected system / runtime failure # First try to extract structured fail results from guard history. # This handles on_fail="exception" where guardrails raises instead of returning. @@ -322,16 +407,38 @@ def _redact_input(error_message: str, input_text: str) -> str: return error_message.replace(input_text, "") +def _map_validator_configs( + guard: Guard, validator_configs: list[ValidatorConfigItem] | None +) -> dict[str, ValidatorConfigItem]: + """ + Maps guard-history validator names (rail_alias) back to the request's + validator configs, using the built validators the guard actually ran. + """ + built = getattr(guard, "_validators", None) + if not built or not validator_configs: + return {} + # ponytail: first config wins per alias; two same-type validators in one + # request share trace fields. Split by position if that ever matters. + mapping: dict[str, ValidatorConfigItem] = {} + for validator, config in zip(built, validator_configs): + alias = getattr(validator, "rail_alias", None) + if alias: + mapping.setdefault(alias, config) + return mapping + + def add_validator_logs( guard: Guard, request_log_id: UUID, validator_log_crud: ValidatorLogCrud, auth: TenantContext, suppress_pass_logs: bool = False, + validator_configs: list[ValidatorConfigItem] | None = None, ) -> None: """ Writes a ValidatorLog entry for each validator outcome in the guard's last iteration. - Pass results are skipped when suppress_pass_logs is True. + Pass results are skipped when suppress_pass_logs is True; `order` keeps each + row's true execution position, so persisted orders may have gaps — intentional. """ history = getattr(guard, "history", None) if not history: @@ -346,7 +453,9 @@ def add_validator_logs( if not outputs or not getattr(outputs, "validator_logs", None): return - for log in iteration.outputs.validator_logs: + config_by_alias = _map_validator_configs(guard, validator_configs) + + for order, log in enumerate(iteration.outputs.validator_logs, start=1): result = log.validation_result if result is None: @@ -359,18 +468,63 @@ def add_validator_logs( if isinstance(result, FailResult): error_message = result.error_message + # registered_name is the rail alias ("guardrails/ban_list"); + # validator_name is only the display/class name. + config = config_by_alias.get( + getattr(log, "registered_name", None) or log.validator_name + ) + stage = type_ = meta = None + if config is not None: + type_ = config.type + # Per-config stage defaults live on the config classes + # (answer_relevance defaults to output). + stage = config.stage.value if config.stage else Stage.Input.value + meta = config.model_dump(mode="json") + + # Verdict detail the validator attached to its result (e.g. topic + # relevance scope_score/reasoning); stored beside the config dump. + result_metadata = getattr(result, "metadata", None) + if result_metadata: + meta = meta or {} + # Round-trip through json so a non-serializable value degrades to + # its str() instead of failing the row insert. + meta["result_metadata"] = json.loads( + json.dumps(result_metadata, default=str) + ) + + duration_ms = None + start_time = getattr(log, "start_time", None) + end_time = getattr(log, "end_time", None) + if start_time and end_time: + duration_ms = int((end_time - start_time).total_seconds() * 1000) + validator_log = ValidatorLog( request_id=request_log_id, organization_id=auth.organization_id, project_id=auth.project_id, name=log.validator_name, + order=order, + duration_ms=duration_ms, + stage=stage, + type=type_, + meta=meta, input=str(log.value_before_validation), output=log.value_after_validation, error=error_message, outcome=ValidatorOutcome(result.outcome.upper()), ) - validator_log_crud.create(log=validator_log) + try: + validator_log_crud.create(log=validator_log) + except Exception: + logger.exception( + "[add_validator_logs] failed to write validator log (validator=%s, request_log=%s)", + log.validator_name, + request_log_id, + ) + # Clear the failed transaction so one bad row doesn't poison + # the inserts for the remaining validators. + validator_log_crud.session.rollback() def _normalize_llm_critic_error(message: str) -> str: diff --git a/backend/app/core/exception_handlers.py b/backend/app/core/exception_handlers.py index dd5739c..aa5b62a 100644 --- a/backend/app/core/exception_handlers.py +++ b/backend/app/core/exception_handlers.py @@ -1,3 +1,5 @@ +import logging + from fastapi import FastAPI, Request, HTTPException from fastapi.exceptions import RequestValidationError, ResponseValidationError from fastapi.responses import JSONResponse @@ -10,6 +12,8 @@ from app.core.config import settings from app.utils import APIResponse +logger = logging.getLogger(__name__) + def _format_validation_errors(errors: list[dict]) -> str: missing_fields: list[str] = [] @@ -102,6 +106,12 @@ async def starlette_http_exception_handler( async def response_validation_error_handler( request: Request, exc: ResponseValidationError ): + logger.error( + "[response_validation_error_handler] response validation failed for %s %s", + request.method, + request.url.path, + exc_info=exc, + ) return JSONResponse( status_code=HTTP_500_INTERNAL_SERVER_ERROR, content=APIResponse.failure_response(_safe_error_message(exc)).model_dump(), @@ -109,6 +119,12 @@ async def response_validation_error_handler( @app.exception_handler(Exception) async def generic_error_handler(request: Request, exc: Exception): + logger.error( + "[generic_error_handler] unhandled exception for %s %s", + request.method, + request.url.path, + exc_info=exc, + ) return JSONResponse( status_code=HTTP_500_INTERNAL_SERVER_ERROR, content=APIResponse.failure_response(_safe_error_message(exc)).model_dump(), diff --git a/backend/app/core/validators/config/answer_relevance_custom_llm_safety_validator_config.py b/backend/app/core/validators/config/answer_relevance_custom_llm_safety_validator_config.py index d3ae0ed..8cc2563 100644 --- a/backend/app/core/validators/config/answer_relevance_custom_llm_safety_validator_config.py +++ b/backend/app/core/validators/config/answer_relevance_custom_llm_safety_validator_config.py @@ -2,12 +2,15 @@ from uuid import UUID from app.core.config import settings +from app.core.enum import Stage from app.core.validators.answer_relevance_custom_llm import AnswerRelevanceCustomLLM from app.core.validators.config.base_validator_config import BaseValidatorConfig class AnswerRelevanceCustomLLMSafetyValidatorConfig(BaseValidatorConfig): type: Literal["answer_relevance_custom_llm"] + # This validator judges the LLM's answer, so it checks the output stage. + stage: Stage | None = Stage.Output llm_callable: str = settings.ANSWER_RELEVANCE_LLM_MODEL # Inline prompt template with {query} and {answer} placeholders. # If None, the validator uses its built-in default. diff --git a/backend/app/core/validators/config/base_validator_config.py b/backend/app/core/validators/config/base_validator_config.py index 1fda762..d336a52 100644 --- a/backend/app/core/validators/config/base_validator_config.py +++ b/backend/app/core/validators/config/base_validator_config.py @@ -5,7 +5,7 @@ from pydantic import ConfigDict, PrivateAttr from sqlmodel import SQLModel -from app.core.enum import GuardrailOnFail, ValidatorType +from app.core.enum import GuardrailOnFail, Stage, ValidatorType from app.core.on_fail_actions import rephrase_query_on_fail @@ -13,6 +13,8 @@ class BaseValidatorConfig(SQLModel): model_config = ConfigDict(extra="forbid", arbitrary_types_allowed=True) on_fail: GuardrailOnFail = GuardrailOnFail.Fix + # Which text this validator is meant to check; recorded in ValidatorLog. + stage: Optional[Stage] = None _validator_metadata: Optional[Dict[str, Any]] = PrivateAttr(default=None) def _on_fix(self, value: str, fail_result: FailResult): diff --git a/backend/app/crud/request_log.py b/backend/app/crud/request_log.py index 17731c6..c7e901c 100644 --- a/backend/app/crud/request_log.py +++ b/backend/app/crud/request_log.py @@ -16,14 +16,20 @@ def create( payload: GuardrailRequest, organization_id: int, project_id: int, + suppress_pass_logs: bool = False, ) -> RequestLog: request_id = UUID(payload.request_id) + # suppress_pass_logs is a query param (not part of the payload), but a + # trace reader needs it to tell "passes suppressed" from "nothing ran". + meta = payload.model_dump(mode="json") + meta["suppress_pass_logs"] = suppress_pass_logs create_request_log = RequestLog( request_id=request_id, request_text=payload.input, output_text=payload.output, organization_id=organization_id, project_id=project_id, + meta=meta, ) self.session.add(create_request_log) self.session.commit() diff --git a/backend/app/models/logging/request_log.py b/backend/app/models/logging/request_log.py index 648e44d..4d7e29c 100644 --- a/backend/app/models/logging/request_log.py +++ b/backend/app/models/logging/request_log.py @@ -1,8 +1,10 @@ from datetime import datetime from enum import Enum -from typing import Optional +from typing import Any, Optional from uuid import UUID, uuid4 +from sqlalchemy import Column +from sqlalchemy.dialects.postgresql import JSONB from sqlmodel import SQLModel, Field from app.utils import now @@ -61,6 +63,16 @@ class RequestLog(SQLModel, table=True): sa_column_kwargs={"comment": "LLM output text passed for output guardrails"}, ) + meta: Optional[dict[str, Any]] = Field( + default=None, + sa_column=Column( + "metadata", + JSONB, + nullable=True, + comment="Full run_guardrails request payload", + ), + ) + response_text: Optional[str] = Field( default=None, nullable=True, diff --git a/backend/app/models/logging/validator_log.py b/backend/app/models/logging/validator_log.py index c04a4a1..6ede6b1 100644 --- a/backend/app/models/logging/validator_log.py +++ b/backend/app/models/logging/validator_log.py @@ -1,7 +1,10 @@ from datetime import datetime from enum import Enum +from typing import Any from uuid import UUID, uuid4 +from sqlalchemy import Column +from sqlalchemy.dialects.postgresql import JSONB from sqlmodel import SQLModel, Field from app.utils import now @@ -42,6 +45,44 @@ class ValidatorLog(SQLModel, table=True): sa_column_kwargs={"comment": "Name of the validator used"}, ) + order: int | None = Field( + default=None, + nullable=True, + sa_column_kwargs={ + "comment": "1-based execution order of the validator within the request" + }, + ) + + duration_ms: int | None = Field( + default=None, + nullable=True, + sa_column_kwargs={ + "comment": "Wall-clock execution time of the validator in milliseconds" + }, + ) + + stage: str | None = Field( + default=None, + nullable=True, + sa_column_kwargs={"comment": "Stage the validator checked (input or output)"}, + ) + + type: str | None = Field( + default=None, + nullable=True, + sa_column_kwargs={"comment": "Validator type (ValidatorType enum value)"}, + ) + + meta: dict[str, Any] | None = Field( + default=None, + sa_column=Column( + "metadata", + JSONB, + nullable=True, + comment="Full resolved validator config used for this run", + ), + ) + input: str = Field( nullable=False, sa_column_kwargs={"comment": "Input message for the validator to check"}, diff --git a/backend/app/schemas/guardrail_config.py b/backend/app/schemas/guardrail_config.py index 24fafcf..9dd57be 100644 --- a/backend/app/schemas/guardrail_config.py +++ b/backend/app/schemas/guardrail_config.py @@ -86,9 +86,11 @@ def normalize_validators_from_config_api(cls, data): # Strip persistence/system fields before handing a stored validator # config to Guardrails. Reuse the shared system-field set, but keep - # `type` (the discriminator) and `on_fail_action` (remapped below), - # and add the DB-only columns. - drop_fields = (VALIDATOR_CONFIG_SYSTEM_FIELDS - {"type", "on_fail_action"}) | { + # `type` (the discriminator), `stage` (recorded in validator logs), + # and `on_fail_action` (remapped below), and add the DB-only columns. + drop_fields = ( + VALIDATOR_CONFIG_SYSTEM_FIELDS - {"type", "on_fail_action", "stage"} + ) | { "id", "created_at", "updated_at", diff --git a/backend/app/tests/test_guardrails_traceability_integration.py b/backend/app/tests/test_guardrails_traceability_integration.py new file mode 100644 index 0000000..d049479 --- /dev/null +++ b/backend/app/tests/test_guardrails_traceability_integration.py @@ -0,0 +1,129 @@ +from unittest.mock import MagicMock, patch + +import pytest +from sqlmodel import Session, select + +from app.models.logging.request_log import RequestLog, RequestStatus +from app.models.logging.validator_log import ValidatorLog +from app.tests.conftest import test_engine +from app.tests.seed_data import ( + VALIDATOR_INTEGRATION_ORGANIZATION_ID, + VALIDATOR_INTEGRATION_PROJECT_ID, +) +from app.tests.utils.constants import VALIDATE_API_PATH + +pytestmark = pytest.mark.integration + +request_id = "123e4567-e89b-12d3-a456-426614174000" +TENANT_HEADERS = { + "X-ORGANIZATION-ID": str(VALIDATOR_INTEGRATION_ORGANIZATION_ID), + "X-PROJECT-ID": str(VALIDATOR_INTEGRATION_PROJECT_ID), +} + + +def test_validator_logs_capture_order_stage_type_and_metadata( + integration_client, +): + payload = { + "request_id": request_id, + "input": "this contains badword", + "validators": [ + {"type": "ban_list", "banned_words": ["badword"], "stage": "input"}, + {"type": "uli_slur_match", "severity": "all"}, + ], + } + response = integration_client.post( + VALIDATE_API_PATH, headers=TENANT_HEADERS, json=payload + ) + assert response.status_code == 200 + assert response.json()["success"] is True + + with Session(test_engine) as session: + request_log = session.exec(select(RequestLog)).one() + assert request_log.status == RequestStatus.SUCCESS + # Metadata is the parsed payload with validator defaults filled in. + assert request_log.meta["request_id"] == request_id + assert request_log.meta["input"] == payload["input"] + # Query params aren't in the payload; recorded so the trace shows + # whether pass logs were suppressed. + assert request_log.meta["suppress_pass_logs"] is False + assert [v["type"] for v in request_log.meta["validators"]] == [ + "ban_list", + "uli_slur_match", + ] + + logs = session.exec(select(ValidatorLog).order_by(ValidatorLog.order)).all() + # Passing validators are logged by default (suppress_pass_logs=False). + assert [log.order for log in logs] == [1, 2] + assert all(log.duration_ms is not None and log.duration_ms >= 0 for log in logs) + assert [log.type for log in logs] == ["ban_list", "uli_slur_match"] + assert [log.stage for log in logs] == ["input", "input"] + assert logs[0].meta["banned_words"] == ["badword"] + assert logs[0].meta["stage"] == "input" + assert logs[1].meta["type"] == "uli_slur_match" + + +def test_config_resolution_failure_finalizes_request_log(integration_client): + response = integration_client.post( + VALIDATE_API_PATH, + headers=TENANT_HEADERS, + json={ + "request_id": request_id, + "input": "hello", + "validators": [ + { + "type": "ban_list", + "ban_list_id": "00000000-0000-0000-0000-000000000000", + } + ], + }, + ) + assert response.status_code == 404 + + with Session(test_engine) as session: + request_log = session.exec(select(RequestLog)).one() + # The row must not be left at PROCESSING forever. + assert request_log.status == RequestStatus.ERROR + assert request_log.response_text + + +def test_guard_execution_failure_finalizes_request_log(integration_client): + failing_guard = MagicMock() + failing_guard.validate.side_effect = RuntimeError("boom") + failing_guard.history = None # no structured fail results to extract + + with patch("app.api.routes.guardrails.build_guard", return_value=failing_guard): + response = integration_client.post( + VALIDATE_API_PATH, + headers=TENANT_HEADERS, + json={ + "request_id": request_id, + "input": "hello", + "validators": [{"type": "ban_list", "banned_words": ["x"]}], + }, + ) + + # The failure is a first-class outcome, not an exception to the caller. + assert response.status_code == 200 + assert response.json()["success"] is False + + with Session(test_engine) as session: + request_log = session.exec(select(RequestLog)).one() + assert request_log.status == RequestStatus.ERROR + assert request_log.response_text + + +def test_invalid_request_id_returns_failure_without_crash(integration_client): + response = integration_client.post( + VALIDATE_API_PATH, + headers=TENANT_HEADERS, + json={ + "request_id": "not-a-uuid", + "input": "hello", + "validators": [{"type": "ban_list", "banned_words": ["x"]}], + }, + ) + assert response.status_code == 200 + body = response.json() + assert body["success"] is False + assert body["error"] == "Invalid request_id"