"""Pydantic schemas for the MAF roundtable Conversation API (V2). These are the public wire contracts for ``/api/maf-roundtable/conversations``. Internal fields (owner_user_id, internal_session_id, active_run_id, thread ids, capability snapshots, idempotency keys) are NEVER exposed here. """ from __future__ import annotations from typing import Any from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator _TASK_CONTEXT_SAFE_KEYS = frozenset({"business_code", "task_id", "task_name", "chain_id", "chain_name"}) class _StrictRequestModel(BaseModel): """Reject unknown request members instead of silently ignoring spoofed input.""" model_config = ConfigDict(extra="forbid") class CreateConversationRequest(_StrictRequestModel): title: str = Field(default="", max_length=255) task_context: dict[str, Any] | None = None @model_validator(mode="before") @classmethod def _reject_internal_fields(cls, values: Any) -> Any: if isinstance(values, dict): forbidden = {"user_id", "owner_user_id", "internal_session_id", "run_id", "thread_id", "capability_snapshot", "id"} found = forbidden & set(values.keys()) if found: raise ValueError(f"Forbidden fields: {sorted(found)}") return values @field_validator("task_context") @classmethod def _validate_task_context(cls, value: dict[str, Any] | None) -> dict[str, str] | None: if value is None: return None unexpected = set(value) - _TASK_CONTEXT_SAFE_KEYS if unexpected: raise ValueError(f"Unsupported task_context fields: {sorted(unexpected)}") cleaned: dict[str, str] = {} for key, raw in value.items(): if not isinstance(raw, (str, int)) or isinstance(raw, bool): raise ValueError(f"task_context.{key} must be a string or integer") rendered = str(raw).strip() if not rendered: raise ValueError(f"task_context.{key} cannot be blank") if len(rendered) > 255: raise ValueError(f"task_context.{key} exceeds 255 characters") cleaned[key] = rendered return cleaned class SendMessageRequest(_StrictRequestModel): content: str = Field(min_length=1, max_length=20000) @model_validator(mode="before") @classmethod def _reject_internal_fields(cls, values: Any) -> Any: if isinstance(values, dict): forbidden = {"user_id", "owner_user_id", "internal_session_id", "run_id", "thread_id", "capability_snapshot", "idempotency_key"} found = forbidden & set(values.keys()) if found: raise ValueError(f"Forbidden fields: {sorted(found)}") return values @model_validator(mode="after") def _strip_content(self) -> SendMessageRequest: stripped = self.content.strip() if not stripped: raise ValueError("content cannot be empty or whitespace-only") self.content = stripped return self class ConversationResponse(BaseModel): id: str title: str task_context: dict[str, Any] | None = None safe_status: str created_at: str | None = None updated_at: str | None = None last_stream_seq: int = 0 class ConversationListResponse(BaseModel): conversations: list[ConversationResponse] total: int class MessageResponse(BaseModel): id: str conversation_id: str seq: int message_kind: str role: str agent_id: str | None = None message_ref: str | None = None content: str metadata: dict[str, Any] | None = None created_at: str | None = None class MessageListResponse(BaseModel): messages: list[MessageResponse] last_stream_seq: int class SendMessageResponse(BaseModel): message_id: str conversation_id: str seq: int