from datetime import datetime from enum import Enum from typing import Any from pydantic import BaseModel, Field, field_validator class ReasoningType(str, Enum): CHAIN = "chain" IDEA = "idea" CONTEXT = "context" REFLECTION = "reflection" class DocumentCreate(BaseModel): title: str content: str = "" folder_id: str | None = None agent_type: str | None = "general" # research, development, general class DocumentUpdate(BaseModel): title: str | None = None folder_id: str | None = None class DocumentContentUpdate(BaseModel): content: str = Field(..., max_length=1_000_000) # 1MB limit # Phase 2: TipTap Content Update - Union to support both legacy string and new TipTap/Markdown formats class TipTapContentUpdate(BaseModel): content: dict[str, Any] | str # TipTap JSON object or Markdown string format: str = "tiptap" @field_validator("format") @classmethod def validate_format(cls, v: str) -> str: if v not in ("tiptap", "markdown"): raise ValueError("format must be 'tiptap' or 'markdown'") return v class TagInfo(BaseModel): id: str name: str color: str model_config = {"from_attributes": True} # Phase 2: Reasoning Step Schema class ReasoningStep(BaseModel): step: int thought: str = Field(..., max_length=2000) conclusion: str | None = Field(None, max_length=2000) # Phase 2: Reasoning Metadata Schema class ReasoningMetadata(BaseModel): reasoning_type: ReasoningType | None = None confidence: float | None = None reasoning_steps: list[ReasoningStep] = [] model_source: str | None = None model_config = {"protected_namespaces": ()} @field_validator("confidence") @classmethod def validate_confidence(cls, v: float | None) -> float | None: if v is not None and (v < 0.0 or v > 1.0): raise ValueError("confidence must be between 0.0 and 1.0") return v # Phase 2: Reasoning Update Schema class ReasoningUpdate(BaseModel): reasoning_type: ReasoningType | None = None confidence: float | None = None reasoning_steps: list[ReasoningStep] | None = None model_source: str | None = None model_config = {"protected_namespaces": ()} @field_validator("confidence") @classmethod def validate_confidence(cls, v: float | None) -> float | None: if v is not None and (v < 0.0 or v > 1.0): raise ValueError("confidence must be between 0.0 and 1.0") return v # Phase 2: Add Reasoning Step Schema class ReasoningStepAdd(BaseModel): thought: str = Field(..., min_length=1, max_length=2000) conclusion: str | None = Field(None, max_length=2000) # Phase 2: TipTap Content Response class TipTapContentResponse(BaseModel): content: dict[str, Any] | str format: str # Phase 2: Reasoning Panel Schema class ReasoningPanel(BaseModel): document_id: str has_reasoning: bool reasoning: ReasoningMetadata | None = None editable: bool = True class DocumentResponse(BaseModel): id: str title: str content: str project_id: str folder_id: str | None path: str tags: list[TagInfo] = [] created_at: datetime updated_at: datetime # Phase 2: new fields reasoning_type: ReasoningType | None = None confidence: float | None = None reasoning_steps: list[ReasoningStep] = [] model_source: str | None = None tiptap_content: dict[str, Any] | None = None model_config = {"from_attributes": True, "protected_namespaces": ()} class DocumentBriefResponse(BaseModel): """Brief document for list views without content.""" id: str title: str project_id: str folder_id: str | None path: str tags: list[TagInfo] = [] created_at: datetime updated_at: datetime model_config = {"from_attributes": True} class DocumentListResponse(BaseModel): documents: list[DocumentBriefResponse] # ============================================================================= # Phase 3: Link Detection & Graph Schemas # ============================================================================= class DetectLinksRequest(BaseModel): content: str = Field(..., max_length=5_000_000) # ~5MB limit class BrokenLink(BaseModel): reference: str reason: str # "document_not_found" | "invalid_format" class DetectLinksResponse(BaseModel): document_id: str outgoing_links: list[str] links_detected: int links_broken: int broken_links: list[BrokenLink] = [] class BacklinkItem(BaseModel): document_id: str title: str project_id: str project_name: str excerpt: str updated_at: datetime class BacklinksResponse(BaseModel): document_id: str backlinks_count: int backlinks: list[BacklinkItem] class OutgoingLinkItem(BaseModel): document_id: str title: str project_id: str project_name: str exists: bool updated_at: datetime | None class OutgoingLinksResponse(BaseModel): document_id: str outgoing_links_count: int outgoing_links: list[OutgoingLinkItem] class LinkItem(BaseModel): document_id: str title: str anchor_text: str | None = None class LinksResponse(BaseModel): document_id: str outgoing_links: list[LinkItem] backlinks: list[LinkItem] class GraphNode(BaseModel): id: str title: str type: str = "document" class GraphEdge(BaseModel): source: str target: str type: str = "reference" class GraphStats(BaseModel): total_documents: int total_references: int orphaned_documents: int class GraphResponse(BaseModel): project_id: str nodes: list[GraphNode] edges: list[GraphEdge] stats: GraphStats # ============================================================================= # Phase 3: Export Schemas # ============================================================================= class DocumentExportResponse(BaseModel): """Used for JSON export format.""" id: str title: str content: str tiptap_content: dict[str, Any] | None = None created_at: datetime updated_at: datetime metadata: dict[str, Any] = Field(default_factory=dict) class ProjectExportDocument(BaseModel): id: str title: str content: str tiptap_content: dict[str, Any] | None = None outgoing_links: list[str] = [] metadata: dict[str, Any] = Field(default_factory=dict) class ProjectExportResponse(BaseModel): project: dict[str, Any] documents: list[ProjectExportDocument] exported_at: datetime format_version: str = "3.0"