Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 15 additions & 3 deletions src/utils/representation.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,16 +114,28 @@ class PromptRepresentation(BaseModel):
"""

explicit: list[ExplicitObservationBase] = Field(
description="Facts LITERALLY stated by the user - direct quotes or clear paraphrases only, no interpretation or inference. Example: ['The user is 25 years old', 'The user has a dog named Rover']",
description="Facts LITERALLY stated by the user - direct quotes or clear paraphrases only, no interpretation or inference. Example: [{'content': 'The user is 25 years old'}, {'content': 'The user has a dog named Rover'}]",
default_factory=list,
)

@field_validator("explicit", mode="before")
@classmethod
def convert_none_to_empty_list(cls, v: Any) -> Any:
"""Convert None to empty list - handles LLMs returning null instead of []."""
def normalize_explicit(cls, v: Any) -> Any:
"""Normalize the ``explicit`` payload before validation.

Handles two shapes LLMs emit in ``json_object`` mode, where the model
sees only the prompt/schema text and often returns the simpler form:

- ``None`` instead of ``[]`` (some models return null for empty lists).
- bare strings instead of ``{"content": ...}`` objects. Providers
without ``json_schema`` support (e.g. DeepSeek, some vLLM configs)
frequently emit ``{"explicit": ["fact 1", ...]}``; coerce each string
into the expected object so observations aren't silently dropped (#893).
"""
if v is None:
return []
if isinstance(v, list):
return [{"content": item} if isinstance(item, str) else item for item in v]
return v


Expand Down
2 changes: 2 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,8 @@ def emit(self, record: logging.LogRecord):
# LLM transport tests mock providers directly and don't need database/runtime setup.
"tests/utils/test_length_finish_reason.py",
"tests/utils/test_clients.py",
# Pure PromptRepresentation model-validation tests — no DB needed.
"tests/utils/test_representation.py",
# Pure JWT scope tests — operate on src.security directly, no DB needed.
"tests/test_security.py",
"tests/test_generate_jwt_script.py",
Expand Down
54 changes: 54 additions & 0 deletions tests/utils/test_representation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
"""
Tests for PromptRepresentation input normalization.

Providers without ``json_schema`` structured-output support (e.g. DeepSeek via
LiteLLM, some vLLM configs) run in ``json_object`` mode and infer the JSON shape
from the prompt/schema text. They frequently return the ``explicit`` field as a
list of bare strings instead of ``{"content": ...}`` objects. These tests pin
that the bare-string shape is coerced rather than silently dropped (#893).
"""

import pytest
from pydantic import ValidationError

from src.utils.representation import ExplicitObservationBase, PromptRepresentation


def test_explicit_bare_strings_are_coerced_to_objects() -> None:
rep = PromptRepresentation.model_validate(
{"explicit": ["alice is 25 years old", "alice has a dog"]}
)

assert rep.explicit == [
ExplicitObservationBase(content="alice is 25 years old"),
ExplicitObservationBase(content="alice has a dog"),
]


def test_explicit_objects_still_accepted() -> None:
rep = PromptRepresentation.model_validate(
{"explicit": [{"content": "alice is 25 years old"}]}
)

assert rep.explicit == [ExplicitObservationBase(content="alice is 25 years old")]


def test_explicit_mixed_shapes_are_normalized() -> None:
rep = PromptRepresentation.model_validate(
{"explicit": ["bare fact", {"content": "object fact"}]}
)

assert [o.content for o in rep.explicit] == ["bare fact", "object fact"]


def test_explicit_none_becomes_empty_list() -> None:
rep = PromptRepresentation.model_validate({"explicit": None})

assert rep.explicit == []


def test_explicit_non_string_scalar_still_rejected() -> None:
# Coercion is limited to strings; a stray int is a genuine shape error and
# should not be silently turned into content.
with pytest.raises(ValidationError):
PromptRepresentation.model_validate({"explicit": [123]})