feat(interviews): add pydantic models for instruments and responses

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Christian Moellmann 2026-05-23 12:04:45 +02:00
parent 071f8b5c4c
commit f1898b4eac
3 changed files with 129 additions and 0 deletions

View File

@ -0,0 +1,99 @@
from __future__ import annotations
from enum import Enum
from typing import Optional
from pydantic import BaseModel, Field, field_validator, model_validator
class InterviewPhase(str, Enum):
T0 = "T0"
T1 = "T1"
class SubagentKind(str, Enum):
LONGITUDINAL = "longitudinal"
DIVERSITY = "diversity"
DELPHI = "delphi"
SCENARIO = "scenario"
class LikertItem(BaseModel):
item_id: str
de: str
en: str
scale: int = Field(ge=3, le=7)
family: Optional[str] = None
reverse_coded: bool = False
@field_validator("scale")
@classmethod
def odd_scale(cls, v: int) -> int:
if v not in (3, 5, 7):
raise ValueError("scale must be 3, 5, or 7")
return v
class LikertInstrument(BaseModel):
name: str
version: str = "1.0"
language_default: str = "de"
items: list[LikertItem]
@model_validator(mode="after")
def unique_item_ids(self) -> "LikertInstrument":
ids = [i.item_id for i in self.items]
if len(set(ids)) != len(ids):
raise ValueError("duplicate item_id in instrument")
return self
class LikertResponse(BaseModel):
agent_id: int
phase: InterviewPhase
responses: dict[str, int]
confidence: dict[str, float] = Field(default_factory=dict)
open_comment: Optional[str] = None
memory_available: bool = True
failed_items: list[str] = Field(default_factory=list)
@model_validator(mode="after")
def values_in_range(self) -> "LikertResponse":
for k, v in self.responses.items():
if not 1 <= v <= 5:
raise ValueError(f"response {k}={v} out of 1..5 range")
for k, v in self.confidence.items():
if not 0.0 <= v <= 1.0:
raise ValueError(f"confidence {k}={v} out of 0..1 range")
return self
class QSortStatement(BaseModel):
statement_id: str
de: str
en: str
class QSortInstrument(BaseModel):
name: str
version: str = "1.0"
statements: list[QSortStatement]
distribution: list[int] # e.g. [2,3,4,6,4,3,2] for -3..+3
class QSortResponse(BaseModel):
agent_id: int
placements: dict[str, int] # statement_id -> bucket (-3..+3)
likert_axes: dict[str, int] # axis_id -> 1..7
class DelphiOpenResponse(BaseModel):
agent_id: int
round: int = 1
answers: dict[str, str] # question_id -> free text
class DelphiRatingResponse(BaseModel):
agent_id: int
round: int
ratings: dict[str, dict[str, int]] # theme_id -> {importance, plausibility}
justification: Optional[str] = None
class ScenarioRating(BaseModel):
desirability: int = Field(ge=1, le=7)
plausibility: int = Field(ge=1, le=7)
impact_on_my_group: int = Field(ge=1, le=7)
fairness: int = Field(ge=1, le=7)
if_woke_up_response: str
class ScenarioResponse(BaseModel):
agent_id: int
ratings: dict[str, ScenarioRating] # scenario_id -> rating

View File

View File

@ -0,0 +1,30 @@
import pytest
from pydantic import ValidationError
from app.models.interview import (
LikertItem, LikertInstrument, LikertResponse,
InterviewPhase, SubagentKind,
)
def test_likert_item_requires_de_and_en():
item = LikertItem(item_id="x1", de="Frage", en="Question", scale=5)
assert item.scale == 5
def test_likert_item_rejects_bad_scale():
with pytest.raises(ValidationError):
LikertItem(item_id="x1", de="d", en="e", scale=2)
def test_likert_instrument_unique_item_ids():
with pytest.raises(ValidationError):
LikertInstrument(
name="t",
items=[LikertItem(item_id="a", de="d", en="e", scale=5),
LikertItem(item_id="a", de="d", en="e", scale=5)],
)
def test_likert_response_validates_scale_range():
with pytest.raises(ValidationError):
LikertResponse(agent_id=1, phase=InterviewPhase.T0,
responses={"a": 6}, confidence={"a": 0.5})
def test_subagent_kind_enum():
assert SubagentKind.LONGITUDINAL.value == "longitudinal"