- Add outgoing_links (JSON) and backlinks_count to Document model
- POST /documents/{id}/detect-links — detect [[uuid]] patterns in content
- GET /documents/{id}/backlinks — documents referencing this doc
- GET /documents/{id}/outgoing-links — documents this doc references
- GET /documents/{id}/links — combined incoming + outgoing
- GET /projects/{id}/graph — full project relationship graph
- GET /search/quick — fuzzy search (Quick Switcher Cmd+K)
- GET /projects/{id}/documents/search — project-scoped search
- GET /documents/{id}/export — markdown|json export
- GET /projects/{id}/export — json|zip export
- 27 new tests
267 lines
6.4 KiB
Python
267 lines
6.4 KiB
Python
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
|
|
|
|
|
|
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"
|