feat(fase2): fail-fast contract loaders

This commit is contained in:
Kjell Tore Guttormsen 2026-06-24 13:50:28 +02:00
commit 36606ebee7
2 changed files with 146 additions and 0 deletions

View file

@ -0,0 +1,96 @@
"""Fail-fast contract loaders (brief NFR: validate ALL configs at startup, before any
chat-client is constructed or called).
Four Pydantic contracts give JSON-Schema-grade validation (CLAUDE.md convention):
* ``DataSourceContract`` the local-folder data source (docs dir + top_k).
* ``ModelMapContract`` validates ``data/model_map.json`` (Step 8): a role->model map per
backend ``Profile``, each with a ``default``.
* ``TerminationContract`` the stop criteria + budget cap (max_rounds, max_tokens) required
at startup (never an unbounded loop).
* ``FeedbackContract`` the expert-verdict feedback shape (decision + rationale).
``load_contracts`` validates all four and raises ``pydantic.ValidationError`` on the first
malformed one purely at the config layer, so it can run before any backend/client exists.
"""
from __future__ import annotations
import json
from importlib.resources import files
from typing import Any, Literal
from pydantic import BaseModel, Field, model_validator
from portfolio_optimiser.backends import Profile
_MODEL_MAP_RESOURCE = "data/model_map.json"
class DataSourceContract(BaseModel):
"""The local-folder data source config (JSON-Schema-validated, fail-fast)."""
docs_dir: str = Field(min_length=1)
top_k: int = Field(gt=0)
class ModelMapContract(BaseModel):
"""Role -> model/deployment map per backend profile (validates data/model_map.json)."""
local: dict[str, str] = Field(min_length=1)
azure: dict[str, str] = Field(min_length=1)
@model_validator(mode="after")
def _each_profile_has_default(self) -> ModelMapContract:
for prof in (Profile.LOCAL.value, Profile.AZURE.value):
if "default" not in getattr(self, prof):
raise ValueError(f"model_map.{prof} must include a 'default' model id")
return self
class TerminationContract(BaseModel):
"""Stop criteria + budget cap required at startup (fail-fast, never unbounded)."""
max_rounds: int = Field(gt=0)
max_tokens: int = Field(gt=0)
class FeedbackContract(BaseModel):
"""The expert-verdict feedback shape fed back into the VerdictStore (Layer-2)."""
decision: Literal["approved", "rejected"]
rationale: str = Field(min_length=1)
class Contracts(BaseModel):
"""The validated bundle of all startup contracts."""
data_source: DataSourceContract
model_map: ModelMapContract
termination: TerminationContract
feedback: FeedbackContract
def _bundled_model_map() -> dict[str, Any]:
return json.loads(
files("portfolio_optimiser").joinpath(_MODEL_MAP_RESOURCE).read_text(encoding="utf-8")
)
def load_contracts(
data_source: dict[str, Any],
termination: dict[str, Any],
feedback: dict[str, Any],
*,
model_map: dict[str, Any] | None = None,
) -> Contracts:
"""Validate ALL contracts at startup (fail-fast, before any chat-client is built). Raises
``pydantic.ValidationError`` on the first malformed contract. ``model_map`` defaults to the
bundled ``data/model_map.json`` (the same file Step 8 ships)."""
raw_map = _bundled_model_map() if model_map is None else model_map
return Contracts(
data_source=DataSourceContract(**data_source),
model_map=ModelMapContract(**raw_map),
termination=TerminationContract(**termination),
feedback=FeedbackContract(**feedback),
)