"""Structured context workspace and capsule contracts for multi-turn QA.""" from __future__ import annotations from typing import Any, Literal from pydantic import BaseModel, ConfigDict, Field, field_validator def _clean_text(value: Any, *, limit: int = 240) -> str: return " ".join(str(value or "").strip().split())[:limit] class ResearchCandidateItem(BaseModel): model_config = ConfigDict(extra="ignore") candidate_ref: str = "" display_name: str = "" summary: str = "" confidence: Literal["high", "medium", "low", "unknown"] = "unknown" status: Literal["direct_competitor", "adjacent", "weak_candidate", "unknown"] = "unknown" source_labels: list[str] = Field(default_factory=list) source_urls: list[str] = Field(default_factory=list) @field_validator("candidate_ref", "display_name", "summary", mode="before") @classmethod def _normalize_text(cls, value: Any) -> str: return _clean_text(value, limit=400) @field_validator("source_labels", "source_urls", mode="before") @classmethod def _normalize_list(cls, value: Any) -> list[str]: rows = value if isinstance(value, list) else [] out: list[str] = [] for item in rows: normalized = _clean_text(item, limit=240) if normalized and normalized not in out: out.append(normalized) return out[:8] class ResearchWorkspace(BaseModel): model_config = ConfigDict(extra="ignore") workspace_version: str = "research_workspace.v1" workspace_id: str = "" workspace_kind: Literal["external_research", "hybrid_research", "internal_research", "new_project_ideation"] = "external_research" topic: str = "" primary_object_ref: str | None = None primary_object_id: int | None = None primary_object_title: str | None = None candidate_set_binding: str | None = None candidate_items: list[ResearchCandidateItem] = Field(default_factory=list) selected_candidate_refs: list[str] = Field(default_factory=list) research_goal: str | None = None analysis_dimensions: list[str] = Field(default_factory=list) evidence_refs: list[str] = Field(default_factory=list) confidence_state: Literal["high", "medium", "low", "unknown"] = "unknown" collected_inputs: dict[str, Any] = Field(default_factory=dict) open_questions: list[str] = Field(default_factory=list) next_recommended_actions: list[str] = Field(default_factory=list) status: Literal["active", "paused", "done"] = "active" pmflow_ai_thread_workspace: dict[str, Any] = Field(default_factory=dict) @field_validator( "workspace_id", "topic", "primary_object_ref", "primary_object_title", "candidate_set_binding", "research_goal", mode="before", ) @classmethod def _normalize_field(cls, value: Any) -> str | None: if value is None: return None return _clean_text(value, limit=400) @field_validator( "selected_candidate_refs", "analysis_dimensions", "evidence_refs", "open_questions", "next_recommended_actions", mode="before", ) @classmethod def _normalize_text_list(cls, value: Any) -> list[str]: rows = value if isinstance(value, list) else [] out: list[str] = [] for item in rows: normalized = _clean_text(item, limit=240) if normalized and normalized not in out: out.append(normalized) return out[:12] @field_validator("collected_inputs", "pmflow_ai_thread_workspace", mode="before") @classmethod def _normalize_dict(cls, value: Any) -> dict[str, Any]: return dict(value) if isinstance(value, dict) else {} class ContextCapsule(BaseModel): model_config = ConfigDict(extra="ignore") capsule_version: str = "context_capsule.v1" workspace_id: str = "" workspace_kind: str = "" topic: str = "" primary_object_ref: str | None = None candidate_set_binding: str | None = None summary: str = "" open_questions: list[str] = Field(default_factory=list) next_action: str | None = None @field_validator( "workspace_id", "workspace_kind", "topic", "primary_object_ref", "candidate_set_binding", "summary", "next_action", mode="before", ) @classmethod def _normalize_capsule_text(cls, value: Any) -> str | None: if value is None: return None return _clean_text(value, limit=400) @field_validator("open_questions", mode="before") @classmethod def _normalize_open_questions(cls, value: Any) -> list[str]: rows = value if isinstance(value, list) else [] out: list[str] = [] for item in rows: normalized = _clean_text(item, limit=240) if normalized and normalized not in out: out.append(normalized) return out[:8] def normalize_research_workspace_payload(value: Any) -> dict[str, Any]: if not isinstance(value, dict) or not value: return {} try: return ResearchWorkspace.model_validate(value).model_dump() except Exception: return {} def normalize_context_capsules_payload(value: Any) -> list[dict[str, Any]]: rows = value if isinstance(value, list) else [] out: list[dict[str, Any]] = [] for item in rows: if not isinstance(item, dict): continue try: out.append(ContextCapsule.model_validate(item).model_dump()) except Exception: continue return out[:8]