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:
@@ -185,7 +185,12 @@ def _create_schema(sync_conn):
|
|||||||
deleted_at TIMESTAMP NULL,
|
deleted_at TIMESTAMP NULL,
|
||||||
deleted_by TEXT NULL,
|
deleted_by TEXT NULL,
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT (datetime('now')),
|
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
|
||||||
)
|
)
|
||||||
"""))
|
"""))
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text
|
from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
@@ -11,6 +12,13 @@ def generate_uuid() -> str:
|
|||||||
return str(uuid.uuid4())
|
return str(uuid.uuid4())
|
||||||
|
|
||||||
|
|
||||||
|
class ReasoningType(str, Enum):
|
||||||
|
CHAIN = "chain"
|
||||||
|
IDEA = "idea"
|
||||||
|
CONTEXT = "context"
|
||||||
|
REFLECTION = "reflection"
|
||||||
|
|
||||||
|
|
||||||
class Document(Base):
|
class Document(Base):
|
||||||
__tablename__ = "documents"
|
__tablename__ = "documents"
|
||||||
|
|
||||||
@@ -25,3 +33,9 @@ class Document(Base):
|
|||||||
deleted_by: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
deleted_by: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
|
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)
|
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
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
|
import json
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
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 import delete, select, text
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.database import get_db
|
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.folder import Folder
|
||||||
from app.models.project import Project
|
from app.models.project import Project
|
||||||
from app.models.tag import DocumentTag, Tag
|
from app.models.tag import DocumentTag, Tag
|
||||||
@@ -18,7 +19,14 @@ from app.schemas.document import (
|
|||||||
DocumentListResponse,
|
DocumentListResponse,
|
||||||
DocumentResponse,
|
DocumentResponse,
|
||||||
DocumentUpdate,
|
DocumentUpdate,
|
||||||
|
ReasoningMetadata,
|
||||||
|
ReasoningPanel,
|
||||||
|
ReasoningStep,
|
||||||
|
ReasoningStepAdd,
|
||||||
|
ReasoningUpdate,
|
||||||
TagInfo,
|
TagInfo,
|
||||||
|
TipTapContentResponse,
|
||||||
|
TipTapContentUpdate,
|
||||||
)
|
)
|
||||||
from app.schemas.tag import DocumentTagsAssign
|
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}"
|
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""
|
||||||
|
|
||||||
|
return ""
|
||||||
|
|
||||||
|
result = process_node(tiptap)
|
||||||
|
return result.strip()
|
||||||
|
|
||||||
|
|
||||||
async def get_document_tags(db: AsyncSession, doc_id: str) -> list[TagInfo]:
|
async def get_document_tags(db: AsyncSession, doc_id: str) -> list[TagInfo]:
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
text("""
|
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:
|
async def document_to_response(db: AsyncSession, doc: Document) -> DocumentResponse:
|
||||||
tags = await get_document_tags(db, doc.id)
|
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(
|
return DocumentResponse(
|
||||||
id=doc.id,
|
id=doc.id,
|
||||||
title=doc.title,
|
title=doc.title,
|
||||||
@@ -58,6 +170,11 @@ async def document_to_response(db: AsyncSession, doc: Document) -> DocumentRespo
|
|||||||
tags=tags,
|
tags=tags,
|
||||||
created_at=doc.created_at,
|
created_at=doc.created_at,
|
||||||
updated_at=doc.updated_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(
|
async def update_document_content(
|
||||||
request: Request,
|
request: Request,
|
||||||
document_id: str,
|
document_id: str,
|
||||||
payload: DocumentContentUpdate,
|
payload: TipTapContentUpdate,
|
||||||
db: AsyncSession = Depends(get_db),
|
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)
|
agent = await get_current_agent(request, db)
|
||||||
|
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
@@ -301,7 +423,37 @@ async def update_document_content(
|
|||||||
if not proj_result.scalar_one_or_none():
|
if not proj_result.scalar_one_or_none():
|
||||||
raise HTTPException(status_code=403, detail="Forbidden")
|
raise HTTPException(status_code=403, detail="Forbidden")
|
||||||
|
|
||||||
|
# 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
|
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()
|
doc.updated_at = datetime.utcnow()
|
||||||
await db.flush()
|
await db.flush()
|
||||||
return await document_to_response(db, doc)
|
return await document_to_response(db, doc)
|
||||||
@@ -433,3 +585,258 @@ async def remove_tag(
|
|||||||
)
|
)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
return None
|
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")
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
from datetime import datetime
|
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):
|
class DocumentCreate(BaseModel):
|
||||||
@@ -18,6 +27,19 @@ class DocumentContentUpdate(BaseModel):
|
|||||||
content: str = Field(..., max_length=1_000_000) # 1MB limit
|
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):
|
class TagInfo(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
name: str
|
name: str
|
||||||
@@ -26,6 +48,67 @@ class TagInfo(BaseModel):
|
|||||||
model_config = {"from_attributes": True}
|
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):
|
class DocumentResponse(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
title: str
|
title: str
|
||||||
@@ -36,8 +119,14 @@ class DocumentResponse(BaseModel):
|
|||||||
tags: list[TagInfo] = []
|
tags: list[TagInfo] = []
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_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):
|
class DocumentBriefResponse(BaseModel):
|
||||||
|
|||||||
@@ -134,3 +134,361 @@ async def test_remove_tag(client):
|
|||||||
headers={"Authorization": f"Bearer {token}"}
|
headers={"Authorization": f"Bearer {token}"}
|
||||||
)
|
)
|
||||||
assert remove_resp.status_code == 204
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user