Phase 2: Add reasoning and TipTap content endpoints

- Extend Document model with reasoning_type, confidence, reasoning_steps, model_source, tiptap_content fields
- Add new endpoints:
  - GET /documents/{id}/reasoning - Get reasoning metadata
  - PATCH /documents/{id}/reasoning - Update reasoning metadata
  - GET /documents/{id}/reasoning-panel - Get reasoning panel data for UI
  - POST /documents/{id}/reasoning-steps - Add reasoning step
  - DELETE /documents/{id}/reasoning-steps/{step} - Delete reasoning step
  - GET /documents/{id}/content?format=tiptap|markdown - Get content in TipTap or Markdown
  - PUT /documents/{id}/content - Update content (supports both TipTap JSON and Markdown)
- Add TipTap to Markdown conversion
- Update database schema with new columns
- Add comprehensive tests for all new endpoints
- All 37 tests passing
This commit is contained in:
Motoko
2026-03-30 23:11:44 +00:00
parent 0645b9c59c
commit bbbe42358d
5 changed files with 880 additions and 7 deletions

View File

@@ -185,7 +185,12 @@ def _create_schema(sync_conn):
deleted_at TIMESTAMP NULL,
deleted_by TEXT NULL,
created_at TIMESTAMP NOT NULL DEFAULT (datetime('now')),
updated_at TIMESTAMP NOT NULL DEFAULT (datetime('now'))
updated_at TIMESTAMP NOT NULL DEFAULT (datetime('now')),
reasoning_type TEXT,
confidence TEXT,
reasoning_steps TEXT,
model_source TEXT,
tiptap_content TEXT
)
"""))

View File

@@ -1,5 +1,6 @@
import uuid
from datetime import datetime
from enum import Enum
from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
@@ -11,6 +12,13 @@ def generate_uuid() -> str:
return str(uuid.uuid4())
class ReasoningType(str, Enum):
CHAIN = "chain"
IDEA = "idea"
CONTEXT = "context"
REFLECTION = "reflection"
class Document(Base):
__tablename__ = "documents"
@@ -25,3 +33,9 @@ class Document(Base):
deleted_by: Mapped[str | None] = mapped_column(String(36), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
# Phase 2: Reasoning fields
reasoning_type: Mapped[str | None] = mapped_column(String(20), nullable=True)
confidence: Mapped[str | None] = mapped_column(String(10), nullable=True) # Store as string to handle JSON serialization
reasoning_steps: Mapped[str | None] = mapped_column(Text, nullable=True) # JSON array as text
model_source: Mapped[str | None] = mapped_column(String(100), nullable=True)
tiptap_content: Mapped[str | None] = mapped_column(Text, nullable=True) # JSON object as text

View File

@@ -1,12 +1,13 @@
import json
import uuid
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy import delete, select, text
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models.document import Document
from app.models.document import Document, ReasoningType
from app.models.folder import Folder
from app.models.project import Project
from app.models.tag import DocumentTag, Tag
@@ -18,7 +19,14 @@ from app.schemas.document import (
DocumentListResponse,
DocumentResponse,
DocumentUpdate,
ReasoningMetadata,
ReasoningPanel,
ReasoningStep,
ReasoningStepAdd,
ReasoningUpdate,
TagInfo,
TipTapContentResponse,
TipTapContentUpdate,
)
from app.schemas.tag import DocumentTagsAssign
@@ -32,6 +40,85 @@ def build_doc_path(project_id: str, doc_id: str, folder_id: str | None, folder_p
return f"/{project_id}/{doc_id}"
def tiptap_to_markdown(tiptap: dict) -> str:
"""Convert TipTap JSON to Markdown string."""
if not tiptap or not isinstance(tiptap, dict):
return ""
lines = []
def process_node(node: dict) -> str:
if not isinstance(node, dict):
return ""
node_type = node.get("type", "")
content = node.get("content", [])
if node_type == "doc":
result = []
for child in content:
result.append(process_node(child))
return "\n".join(result)
elif node_type == "paragraph":
inner = "".join(process_node(c) for c in content)
return f"{inner}\n"
elif node_type == "heading":
level = node.get("attrs", {}).get("level", 1)
inner = "".join(process_node(c) for c in content)
return f"{'#' * level} {inner}\n"
elif node_type == "text":
text_val = node.get("text", "")
marks = node.get("marks", [])
for mark in marks:
if mark.get("type") == "bold":
text_val = f"**{text_val}**"
elif mark.get("type") == "italic":
text_val = f"*{text_val}*"
elif mark.get("type") == "code":
text_val = f"`{text_val}`"
elif mark.get("type") == "strike":
text_val = f"~~{text_val}~~"
return text_val
elif node_type == "bulletList":
return "\n".join(process_node(item) for item in content) + "\n"
elif node_type == "orderedList":
return "\n".join(process_node(item) for item in content) + "\n"
elif node_type == "listItem":
inner = "".join(process_node(c) for c in content)
return f"- {inner.strip()}\n"
elif node_type == "blockquote":
inner = "".join(process_node(c) for c in content)
return f"> {inner.strip()}\n"
elif node_type == "codeBlock":
lang = node.get("attrs", {}).get("language", "")
inner = "".join(process_node(c) for c in content)
return f"```{lang}\n{inner}\n```\n"
elif node_type == "hardBreak":
return "\n"
elif node_type == "horizontalRule":
return "---\n"
elif node_type == "image":
src = node.get("attrs", {}).get("src", "")
alt = node.get("attrs", {}).get("alt", "")
return f"![{alt}]({src})"
return ""
result = process_node(tiptap)
return result.strip()
async def get_document_tags(db: AsyncSession, doc_id: str) -> list[TagInfo]:
result = await db.execute(
text("""
@@ -48,6 +135,31 @@ async def get_document_tags(db: AsyncSession, doc_id: str) -> list[TagInfo]:
async def document_to_response(db: AsyncSession, doc: Document) -> DocumentResponse:
tags = await get_document_tags(db, doc.id)
# Parse reasoning_steps from JSON if present
reasoning_steps = []
if doc.reasoning_steps:
try:
reasoning_steps = json.loads(doc.reasoning_steps)
except json.JSONDecodeError:
reasoning_steps = []
# Parse tiptap_content from JSON if present
tiptap_content = None
if doc.tiptap_content:
try:
tiptap_content = json.loads(doc.tiptap_content)
except json.JSONDecodeError:
tiptap_content = None
# Parse confidence from string if present
confidence = None
if doc.confidence:
try:
confidence = float(doc.confidence)
except (ValueError, TypeError):
confidence = None
return DocumentResponse(
id=doc.id,
title=doc.title,
@@ -58,6 +170,11 @@ async def document_to_response(db: AsyncSession, doc: Document) -> DocumentRespo
tags=tags,
created_at=doc.created_at,
updated_at=doc.updated_at,
reasoning_type=doc.reasoning_type,
confidence=confidence,
reasoning_steps=reasoning_steps,
model_source=doc.model_source,
tiptap_content=tiptap_content,
)
@@ -276,9 +393,14 @@ async def delete_document(
async def update_document_content(
request: Request,
document_id: str,
payload: DocumentContentUpdate,
payload: TipTapContentUpdate,
db: AsyncSession = Depends(get_db),
):
"""Update document content (TipTap JSON or Markdown).
Phase 2: Now supports both TipTap JSON and Markdown formats via the 'format' field.
Also backward-compatible with legacy string content (treated as markdown).
"""
agent = await get_current_agent(request, db)
result = await db.execute(
@@ -301,7 +423,37 @@ async def update_document_content(
if not proj_result.scalar_one_or_none():
raise HTTPException(status_code=403, detail="Forbidden")
doc.content = payload.content
# Determine actual format based on content type (backward compatibility)
# If content is a string, treat as markdown regardless of format field
# If content is a dict, treat as tiptap
is_string_content = isinstance(payload.content, str)
# Validate content size (1MB limit)
content_json = json.dumps(payload.content)
if len(content_json) > 1_000_000:
raise HTTPException(status_code=413, detail="Content too large (max 1MB)")
if is_string_content:
# Legacy string content or markdown - store as markdown
doc.content = payload.content
# Create a simple tiptap structure for the editor
doc.tiptap_content = json.dumps({
"type": "doc",
"content": [
{
"type": "paragraph",
"content": [{"type": "text", "text": payload.content}]
}
]
})
else:
# TipTap JSON content
if not isinstance(payload.content, dict):
raise HTTPException(status_code=400, detail="content must be a string or dict")
doc.tiptap_content = content_json
# Also update the plain content by converting tiptap -> markdown
doc.content = tiptap_to_markdown(payload.content)
doc.updated_at = datetime.utcnow()
await db.flush()
return await document_to_response(db, doc)
@@ -433,3 +585,258 @@ async def remove_tag(
)
await db.flush()
return None
# =============================================================================
# Phase 2: New Endpoints
# =============================================================================
async def _get_doc_with_access(
request: Request,
document_id: str,
db: AsyncSession,
require_write: bool = False,
) -> tuple[Document, bool]:
"""Get document and check access. Returns (doc, has_access)."""
agent = await get_current_agent(request, db)
result = await db.execute(
select(Document).where(
Document.id == document_id,
Document.is_deleted == False,
)
)
doc = result.scalar_one_or_none()
if not doc:
raise HTTPException(status_code=404, detail="Document not found")
proj_result = await db.execute(
select(Project).where(
Project.id == doc.project_id,
Project.agent_id == agent.id,
Project.is_deleted == False,
)
)
if not proj_result.scalar_one_or_none():
raise HTTPException(status_code=403, detail="Forbidden")
return doc, True
@router.get("/api/v1/documents/{document_id}/reasoning")
async def get_document_reasoning(
request: Request,
document_id: str,
db: AsyncSession = Depends(get_db),
):
"""Get reasoning metadata for a document."""
doc, _ = await _get_doc_with_access(request, document_id, db)
# Parse reasoning_steps
reasoning_steps = []
if doc.reasoning_steps:
try:
reasoning_steps = json.loads(doc.reasoning_steps)
except json.JSONDecodeError:
reasoning_steps = []
# Parse confidence
confidence = None
if doc.confidence:
try:
confidence = float(doc.confidence)
except (ValueError, TypeError):
confidence = None
return {
"reasoning_type": doc.reasoning_type,
"confidence": confidence,
"reasoning_steps": reasoning_steps,
"model_source": doc.model_source,
}
@router.patch("/api/v1/documents/{document_id}/reasoning")
async def update_document_reasoning(
request: Request,
document_id: str,
payload: ReasoningUpdate,
db: AsyncSession = Depends(get_db),
):
"""Update reasoning metadata for a document."""
doc, _ = await _get_doc_with_access(request, document_id, db, require_write=True)
if payload.reasoning_type is not None:
doc.reasoning_type = payload.reasoning_type.value if hasattr(payload.reasoning_type, 'value') else payload.reasoning_type
if payload.confidence is not None:
if payload.confidence < 0.0 or payload.confidence > 1.0:
raise HTTPException(status_code=400, detail="confidence must be between 0.0 and 1.0")
doc.confidence = str(payload.confidence)
if payload.reasoning_steps is not None:
# Validate steps
for step in payload.reasoning_steps:
if not isinstance(step.step, int):
raise HTTPException(status_code=400, detail="Each step must have an integer 'step' field")
if not step.thought or len(step.thought) > 2000:
raise HTTPException(status_code=400, detail="thought must be non-empty and max 2000 chars")
doc.reasoning_steps = json.dumps([s.model_dump() for s in payload.reasoning_steps])
if payload.model_source is not None:
doc.model_source = payload.model_source
doc.updated_at = datetime.utcnow()
await db.flush()
return await get_document_reasoning(request, document_id, db)
@router.get("/api/v1/documents/{document_id}/reasoning-panel")
async def get_reasoning_panel(
request: Request,
document_id: str,
db: AsyncSession = Depends(get_db),
):
"""Get reasoning panel data for UI."""
doc, _ = await _get_doc_with_access(request, document_id, db)
# Check if document has reasoning
has_reasoning = any([
doc.reasoning_type is not None,
doc.confidence is not None,
doc.reasoning_steps,
doc.model_source is not None,
])
reasoning_metadata = None
if has_reasoning:
reasoning_steps = []
if doc.reasoning_steps:
try:
reasoning_steps = json.loads(doc.reasoning_steps)
except json.JSONDecodeError:
reasoning_steps = []
confidence = None
if doc.confidence:
try:
confidence = float(doc.confidence)
except (ValueError, TypeError):
confidence = None
reasoning_metadata = ReasoningMetadata(
reasoning_type=doc.reasoning_type,
confidence=confidence,
reasoning_steps=reasoning_steps,
model_source=doc.model_source,
)
return ReasoningPanel(
document_id=document_id,
has_reasoning=has_reasoning,
reasoning=reasoning_metadata,
editable=True, # Agent has write access since they passed _get_doc_with_access
)
@router.post("/api/v1/documents/{document_id}/reasoning-steps", status_code=201)
async def add_reasoning_step(
request: Request,
document_id: str,
payload: ReasoningStepAdd,
db: AsyncSession = Depends(get_db),
):
"""Add a new reasoning step to a document."""
doc, _ = await _get_doc_with_access(request, document_id, db, require_write=True)
# Parse existing steps
steps = []
if doc.reasoning_steps:
try:
steps = json.loads(doc.reasoning_steps)
except json.JSONDecodeError:
steps = []
# Determine next step number
next_step = max([s.get("step", 0) for s in steps], default=0) + 1
# Create new step
new_step = {
"step": next_step,
"thought": payload.thought,
"conclusion": payload.conclusion,
}
steps.append(new_step)
doc.reasoning_steps = json.dumps(steps)
doc.updated_at = datetime.utcnow()
await db.flush()
return new_step
@router.delete("/api/v1/documents/{document_id}/reasoning-steps/{step}", status_code=204)
async def delete_reasoning_step(
request: Request,
document_id: str,
step: int,
db: AsyncSession = Depends(get_db),
):
"""Delete a specific reasoning step from a document."""
doc, _ = await _get_doc_with_access(request, document_id, db, require_write=True)
# Parse existing steps
steps = []
if doc.reasoning_steps:
try:
steps = json.loads(doc.reasoning_steps)
except json.JSONDecodeError:
steps = []
# Find and remove the step
original_len = len(steps)
steps = [s for s in steps if s.get("step") != step]
if len(steps) == original_len:
raise HTTPException(status_code=404, detail="Step not found")
doc.reasoning_steps = json.dumps(steps)
doc.updated_at = datetime.utcnow()
await db.flush()
return None
@router.get("/api/v1/documents/{document_id}/content")
async def get_document_content(
request: Request,
document_id: str,
format: str = Query("tiptap", description="Output format: tiptap or markdown"),
db: AsyncSession = Depends(get_db),
):
"""Get document content in TipTap JSON or Markdown format."""
doc, _ = await _get_doc_with_access(request, document_id, db)
if format == "markdown":
# Try to get tiptap_content and convert
if doc.tiptap_content:
try:
tiptap = json.loads(doc.tiptap_content)
content = tiptap_to_markdown(tiptap)
return TipTapContentResponse(content=content, format="markdown")
except json.JSONDecodeError:
pass
# Fallback to plain content
return TipTapContentResponse(content=doc.content, format="markdown")
else:
# Return tiptap format
if doc.tiptap_content:
try:
tiptap = json.loads(doc.tiptap_content)
return TipTapContentResponse(content=tiptap, format="tiptap")
except json.JSONDecodeError:
pass
# Return default tiptap structure
default_tiptap = {"type": "doc", "content": [{"type": "paragraph", "content": []}]}
return TipTapContentResponse(content=default_tiptap, format="tiptap")

View File

@@ -1,6 +1,15 @@
from datetime import datetime
from enum import Enum
from typing import Any
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, field_validator
class ReasoningType(str, Enum):
CHAIN = "chain"
IDEA = "idea"
CONTEXT = "context"
REFLECTION = "reflection"
class DocumentCreate(BaseModel):
@@ -18,6 +27,19 @@ 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
@@ -26,6 +48,67 @@ class TagInfo(BaseModel):
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
@@ -36,8 +119,14 @@ class DocumentResponse(BaseModel):
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}
model_config = {"from_attributes": True, "protected_namespaces": ()}
class DocumentBriefResponse(BaseModel):

View File

@@ -134,3 +134,361 @@ async def test_remove_tag(client):
headers={"Authorization": f"Bearer {token}"}
)
assert remove_resp.status_code == 204
# =============================================================================
# Phase 2: Reasoning Endpoint Tests
# =============================================================================
@pytest.mark.asyncio
async def test_get_document_reasoning_empty(client):
"""Test getting reasoning from a document with no reasoning set."""
token, proj_id = await setup_project_and_get_token(client)
doc_resp = await client.post(
f"/api/v1/projects/{proj_id}/documents",
json={"title": "No Reasoning Doc"},
headers={"Authorization": f"Bearer {token}"}
)
doc_id = doc_resp.json()["id"]
response = await client.get(
f"/api/v1/documents/{doc_id}/reasoning",
headers={"Authorization": f"Bearer {token}"}
)
assert response.status_code == 200
data = response.json()
assert data["reasoning_type"] is None
assert data["confidence"] is None
assert data["reasoning_steps"] == []
assert data["model_source"] is None
@pytest.mark.asyncio
async def test_patch_document_reasoning(client):
"""Test updating reasoning metadata."""
token, proj_id = await setup_project_and_get_token(client)
doc_resp = await client.post(
f"/api/v1/projects/{proj_id}/documents",
json={"title": "Reasoning Doc"},
headers={"Authorization": f"Bearer {token}"}
)
doc_id = doc_resp.json()["id"]
# Update reasoning
response = await client.patch(
f"/api/v1/documents/{doc_id}/reasoning",
json={
"reasoning_type": "chain",
"confidence": 0.87,
"model_source": "claude-3",
"reasoning_steps": [
{"step": 1, "thought": "First thought", "conclusion": "First conclusion"}
]
},
headers={"Authorization": f"Bearer {token}"}
)
assert response.status_code == 200
data = response.json()
assert data["reasoning_type"] == "chain"
assert data["confidence"] == 0.87
assert data["model_source"] == "claude-3"
assert len(data["reasoning_steps"]) == 1
@pytest.mark.asyncio
async def test_patch_reasoning_invalid_confidence(client):
"""Test that invalid confidence values are rejected."""
token, proj_id = await setup_project_and_get_token(client)
doc_resp = await client.post(
f"/api/v1/projects/{proj_id}/documents",
json={"title": "Reasoning Doc 2"},
headers={"Authorization": f"Bearer {token}"}
)
doc_id = doc_resp.json()["id"]
# Invalid confidence > 1.0 - Pydantic validation returns 422
response = await client.patch(
f"/api/v1/documents/{doc_id}/reasoning",
json={"confidence": 1.5},
headers={"Authorization": f"Bearer {token}"}
)
assert response.status_code == 422
# Invalid confidence < 0.0 - Pydantic validation returns 422
response = await client.patch(
f"/api/v1/documents/{doc_id}/reasoning",
json={"confidence": -0.1},
headers={"Authorization": f"Bearer {token}"}
)
assert response.status_code == 422
@pytest.mark.asyncio
async def test_get_reasoning_panel(client):
"""Test getting reasoning panel data."""
token, proj_id = await setup_project_and_get_token(client)
doc_resp = await client.post(
f"/api/v1/projects/{proj_id}/documents",
json={"title": "Panel Doc"},
headers={"Authorization": f"Bearer {token}"}
)
doc_id = doc_resp.json()["id"]
# Initially no reasoning
panel_resp = await client.get(
f"/api/v1/documents/{doc_id}/reasoning-panel",
headers={"Authorization": f"Bearer {token}"}
)
assert panel_resp.status_code == 200
panel = panel_resp.json()
assert panel["has_reasoning"] is False
assert panel["reasoning"] is None
assert panel["editable"] is True
# Add reasoning
await client.patch(
f"/api/v1/documents/{doc_id}/reasoning",
json={"reasoning_type": "idea", "confidence": 0.95},
headers={"Authorization": f"Bearer {token}"}
)
# Now has reasoning
panel_resp = await client.get(
f"/api/v1/documents/{doc_id}/reasoning-panel",
headers={"Authorization": f"Bearer {token}"}
)
panel = panel_resp.json()
assert panel["has_reasoning"] is True
assert panel["reasoning"]["reasoning_type"] == "idea"
@pytest.mark.asyncio
async def test_add_reasoning_step(client):
"""Test adding a reasoning step."""
token, proj_id = await setup_project_and_get_token(client)
doc_resp = await client.post(
f"/api/v1/projects/{proj_id}/documents",
json={"title": "Steps Doc"},
headers={"Authorization": f"Bearer {token}"}
)
doc_id = doc_resp.json()["id"]
# Add first step
step1_resp = await client.post(
f"/api/v1/documents/{doc_id}/reasoning-steps",
json={"thought": "First step thought", "conclusion": "First conclusion"},
headers={"Authorization": f"Bearer {token}"}
)
assert step1_resp.status_code == 201
step1 = step1_resp.json()
assert step1["step"] == 1
assert step1["thought"] == "First step thought"
# Add second step
step2_resp = await client.post(
f"/api/v1/documents/{doc_id}/reasoning-steps",
json={"thought": "Second step thought"},
headers={"Authorization": f"Bearer {token}"}
)
assert step2_resp.status_code == 201
step2 = step2_resp.json()
assert step2["step"] == 2
@pytest.mark.asyncio
async def test_delete_reasoning_step(client):
"""Test deleting a reasoning step."""
token, proj_id = await setup_project_and_get_token(client)
doc_resp = await client.post(
f"/api/v1/projects/{proj_id}/documents",
json={"title": "Delete Step Doc"},
headers={"Authorization": f"Bearer {token}"}
)
doc_id = doc_resp.json()["id"]
# Add steps
await client.post(
f"/api/v1/documents/{doc_id}/reasoning-steps",
json={"thought": "Step 1"},
headers={"Authorization": f"Bearer {token}"}
)
await client.post(
f"/api/v1/documents/{doc_id}/reasoning-steps",
json={"thought": "Step 2"},
headers={"Authorization": f"Bearer {token}"}
)
# Delete step 1
del_resp = await client.delete(
f"/api/v1/documents/{doc_id}/reasoning-steps/1",
headers={"Authorization": f"Bearer {token}"}
)
assert del_resp.status_code == 204
# Verify step 2 still exists
reasoning_resp = await client.get(
f"/api/v1/documents/{doc_id}/reasoning",
headers={"Authorization": f"Bearer {token}"}
)
steps = reasoning_resp.json()["reasoning_steps"]
assert len(steps) == 1
assert steps[0]["step"] == 2
@pytest.mark.asyncio
async def test_delete_nonexistent_step(client):
"""Test deleting a step that doesn't exist."""
token, proj_id = await setup_project_and_get_token(client)
doc_resp = await client.post(
f"/api/v1/projects/{proj_id}/documents",
json={"title": "No Steps Doc"},
headers={"Authorization": f"Bearer {token}"}
)
doc_id = doc_resp.json()["id"]
del_resp = await client.delete(
f"/api/v1/documents/{doc_id}/reasoning-steps/999",
headers={"Authorization": f"Bearer {token}"}
)
assert del_resp.status_code == 404
# =============================================================================
# Phase 2: TipTap Content Endpoint Tests
# =============================================================================
@pytest.mark.asyncio
async def test_get_content_tiptap_format(client):
"""Test getting content in TipTap format."""
token, proj_id = await setup_project_and_get_token(client)
doc_resp = await client.post(
f"/api/v1/projects/{proj_id}/documents",
json={"title": "TipTap Doc", "content": "Hello world"},
headers={"Authorization": f"Bearer {token}"}
)
doc_id = doc_resp.json()["id"]
response = await client.get(
f"/api/v1/documents/{doc_id}/content",
params={"format": "tiptap"},
headers={"Authorization": f"Bearer {token}"}
)
assert response.status_code == 200
data = response.json()
assert data["format"] == "tiptap"
assert "content" in data
@pytest.mark.asyncio
async def test_get_content_markdown_format(client):
"""Test getting content in Markdown format."""
token, proj_id = await setup_project_and_get_token(client)
doc_resp = await client.post(
f"/api/v1/projects/{proj_id}/documents",
json={"title": "Markdown Doc", "content": "# Hello\n\nWorld"},
headers={"Authorization": f"Bearer {token}"}
)
doc_id = doc_resp.json()["id"]
response = await client.get(
f"/api/v1/documents/{doc_id}/content",
params={"format": "markdown"},
headers={"Authorization": f"Bearer {token}"}
)
assert response.status_code == 200
data = response.json()
assert data["format"] == "markdown"
assert "# Hello" in data["content"]
@pytest.mark.asyncio
async def test_put_content_tiptap(client):
"""Test updating content with TipTap JSON."""
token, proj_id = await setup_project_and_get_token(client)
doc_resp = await client.post(
f"/api/v1/projects/{proj_id}/documents",
json={"title": "Update TipTap Doc"},
headers={"Authorization": f"Bearer {token}"}
)
doc_id = doc_resp.json()["id"]
tiptap_content = {
"type": "doc",
"content": [
{
"type": "heading",
"attrs": {"level": 1},
"content": [{"type": "text", "text": "My Title"}]
},
{
"type": "paragraph",
"content": [{"type": "text", "text": "Hello world"}]
}
]
}
response = await client.put(
f"/api/v1/documents/{doc_id}/content",
json={"content": tiptap_content, "format": "tiptap"},
headers={"Authorization": f"Bearer {token}"}
)
print(f"DEBUG: status={response.status_code} body={response.text}")
assert response.status_code == 200, f"Expected 200, got {response.status_code}: {response.text}"
data = response.json()
assert data["tiptap_content"] is not None
# Verify markdown content was also updated
assert "My Title" in data["content"] or "# My Title" in data["content"]
@pytest.mark.asyncio
async def test_put_content_markdown(client):
"""Test updating content with Markdown."""
token, proj_id = await setup_project_and_get_token(client)
doc_resp = await client.post(
f"/api/v1/projects/{proj_id}/documents",
json={"title": "Update MD Doc"},
headers={"Authorization": f"Bearer {token}"}
)
doc_id = doc_resp.json()["id"]
response = await client.put(
f"/api/v1/documents/{doc_id}/content",
json={"content": "# New Title\n\nNew content here", "format": "markdown"},
headers={"Authorization": f"Bearer {token}"}
)
assert response.status_code == 200
data = response.json()
assert data["content"] == "# New Title\n\nNew content here"
@pytest.mark.asyncio
async def test_document_response_includes_reasoning_fields(client):
"""Test that DocumentResponse includes new reasoning fields."""
token, proj_id = await setup_project_and_get_token(client)
doc_resp = await client.post(
f"/api/v1/projects/{proj_id}/documents",
json={"title": "Full Doc"},
headers={"Authorization": f"Bearer {token}"}
)
doc_id = doc_resp.json()["id"]
# Add reasoning
await client.patch(
f"/api/v1/documents/{doc_id}/reasoning",
json={"reasoning_type": "reflection", "confidence": 0.99},
headers={"Authorization": f"Bearer {token}"}
)
# Get document and verify fields
get_resp = await client.get(
f"/api/v1/documents/{doc_id}",
headers={"Authorization": f"Bearer {token}"}
)
data = get_resp.json()
assert "reasoning_type" in data
assert "confidence" in data
assert "reasoning_steps" in data
assert "model_source" in data
assert "tiptap_content" in data
assert data["reasoning_type"] == "reflection"
assert data["confidence"] == 0.99