From bbbe42358dc20d5698c75db90b932c3e4f87d187 Mon Sep 17 00:00:00 2001 From: Motoko Date: Mon, 30 Mar 2026 23:11:44 +0000 Subject: [PATCH] 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 --- app/database.py | 7 +- app/models/document.py | 14 ++ app/routers/documents.py | 415 ++++++++++++++++++++++++++++++++++++++- app/schemas/document.py | 93 ++++++++- tests/test_documents.py | 358 +++++++++++++++++++++++++++++++++ 5 files changed, 880 insertions(+), 7 deletions(-) diff --git a/app/database.py b/app/database.py index 407b795..0f329c4 100644 --- a/app/database.py +++ b/app/database.py @@ -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 ) """)) diff --git a/app/models/document.py b/app/models/document.py index 52860ed..b92f066 100644 --- a/app/models/document.py +++ b/app/models/document.py @@ -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 diff --git a/app/routers/documents.py b/app/routers/documents.py index 0745faa..c5db19d 100644 --- a/app/routers/documents.py +++ b/app/routers/documents.py @@ -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") diff --git a/app/schemas/document.py b/app/schemas/document.py index afe936e..d2968da 100644 --- a/app/schemas/document.py +++ b/app/schemas/document.py @@ -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): diff --git a/tests/test_documents.py b/tests/test_documents.py index 54210b0..ce488bd 100644 --- a/tests/test_documents.py +++ b/tests/test_documents.py @@ -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