PMFlow/backend/app/schemas/context_workspace.py

165 lines
5.5 KiB
Python

"""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]