Source code for klea_utils.stores.query_schema

#!/usr/bin/env python3
"""
Strict-safe retrieval-query schema builder.

Builds the RAG query generator's structured-output schema from the domain's
configured filter fields, so each allowed filter is a real, typed, optional
field instead of a dynamic-key ``filters`` object.  A dynamic-key object
becomes a JSON object with ``additionalProperties``, which providers' strict
structured-output modes close to ``{}`` (ADR-0044) - the model then cannot
express any constraint at all.  One typed field per configured filter avoids
that and restricts the model to the deployment's declared field names.

Field type follows :attr:`FilterFieldInfo.value_type`:

* ``string`` -> ``str`` or ``list[str]`` (a list is an ``$in`` constraint);
* ``int`` -> ``int``, ``list[int]``, or an :class:`IntRange`
  (``{"gte": ..., "lte": ...}``; equality is a bare value);
* ``float`` -> ``float``, ``list[float]``, or a :class:`FloatRange`;
* ``list`` -> ``list[str]`` (element membership).

Every filter field is optional (omitted when the question states no
constraint).  A bare value is equality; a ``*Range`` value carries
``eq``/``gte``/``lte`` bounds.

The built schema is:
``{search_query: str, <filter field>: <type> | None, ...}``.

File: klea_utils/stores/query_schema.py

Copyright 2026 Ankur Sinha
Author: Ankur Sinha <sanjay DOT ankur AT gmail DOT com>
"""

import logging
from typing import Any

from pydantic import BaseModel, create_model

from klea_utils.stores.config import FilterFieldInfo

logger = logging.getLogger(__name__)


[docs] class IntRange(BaseModel): # Range/equality bounds for an integer filter field, translated to the # ``$eq``/``$gte``/``$lte`` DSL operators by the node. eq: int | None = None gte: int | None = None lte: int | None = None
[docs] class FloatRange(BaseModel): # Range/equality bounds for a float filter field. eq: float | None = None gte: float | None = None lte: float | None = None
#: Python type per configured ``value_type`` (scalars, lists, and ranges). _FIELD_TYPES: dict[str, Any] = { "string": str | list[str], "int": int | list[int] | IntRange, "float": float | list[float] | FloatRange, "list": list[str], } #: The always-present field carrying the free-text search query. _QUERY_FIELD = "search_query" #: Process-local cache of built schemas, keyed by the field signature. _SCHEMA_CACHE: dict[tuple[tuple[str, str], ...], type[BaseModel]] = {} def _signature(fields: list[FilterFieldInfo]) -> tuple[tuple[str, str], ...]: """Return a stable cache key for a filter-field set.""" return tuple(sorted((field.name, field.value_type) for field in fields))
[docs] def build_retrieval_query_schema( fields: list[FilterFieldInfo], ) -> type[BaseModel]: """Build the query generator's strict-safe structured-output schema. :param fields: The allowed filter fields for the run's domains (already resolved by the node). :returns: A pydantic model with ``search_query`` and one optional typed field per valid, uniquely-named configured filter. """ key = _signature(fields) cached = _SCHEMA_CACHE.get(key) if cached is not None: return cached definitions: dict[str, Any] = {_QUERY_FIELD: (str, "")} for field in fields: if field.name == _QUERY_FIELD: logger.warning( f"Filter field {field.name!r} shadows the query field; skipped" ) continue if not field.name.isidentifier(): logger.warning( f"Filter field {field.name!r} is not a valid identifier; skipped" ) continue python_type: Any = _FIELD_TYPES.get(field.value_type, str) definitions[field.name] = (python_type | None, None) output = create_model("RetrievalQueryOutput", **definitions) _SCHEMA_CACHE[key] = output logger.debug( f"built retrieval-query schema\n{len(fields) = }\n{list(definitions) = }" ) return output