Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 66 additions & 19 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,33 @@ class ConversationDetailResponse(BaseModel):
offset: int


class DocumentSummary(BaseModel):
id: int
title: str
source: str
created_at: Optional[datetime] = None
content_preview: Optional[str] = None


class DocumentListResponse(BaseModel):
items: List[DocumentSummary]
total: int
limit: int
offset: int


class DocumentDetail(BaseModel):
id: int
title: str
content: str
source: str
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None

class Config:
from_attributes = True


# Startup/Shutdown Events
@app.on_event("startup")
async def startup_event():
Expand Down Expand Up @@ -196,25 +223,45 @@ async def add_document(doc: DocumentRequest, db: Session = Depends(get_db)):
raise HTTPException(status_code=500, detail=str(e))


@app.get("/api/documents")
async def list_documents(db: Session = Depends(get_db)):
"""
List all documents in the knowledge base
"""
try:
documents = db.query(Document).all()
return [
{
"id": doc.id,
"title": doc.title,
"source": doc.source,
"created_at": doc.created_at
}
for doc in documents
]
except Exception as e:
logger.error(f"Error listing documents: {e}")
raise HTTPException(status_code=500, detail=str(e))
@app.get("/api/documents", response_model=DocumentListResponse)
async def list_documents(
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
source: str = None,
db: Session = Depends(get_db),
):
"""List documents in the knowledge base, most recently created first."""
base = db.query(Document)
if source:
base = base.filter(Document.source == source)

total = base.count()
documents = (
base.order_by(Document.created_at.desc(), Document.id.desc())
.offset(offset)
.limit(limit)
.all()
)
items = [
DocumentSummary(
id=doc.id,
title=doc.title,
source=doc.source,
created_at=doc.created_at,
content_preview=doc.content[:120] if doc.content else None,
)
for doc in documents
]
return DocumentListResponse(items=items, total=total, limit=limit, offset=offset)


@app.get("/api/documents/{document_id}", response_model=DocumentDetail)
async def get_document(document_id: int, db: Session = Depends(get_db)):
"""Return one document with its full content."""
doc = db.query(Document).filter(Document.id == document_id).first()
if doc is None:
raise HTTPException(status_code=404, detail="Document not found")
return doc


@app.get("/api/messages", response_model=List[MessageResponse])
Expand Down
151 changes: 151 additions & 0 deletions backend/tests/test_documents.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
"""Tests for the document list and detail endpoints."""
import os

os.environ.setdefault("SLACK_BOT_TOKEN", "[REDACTED]")
os.environ.setdefault("SLACK_SIGNING_SECRET", "test-signing-secret")

from datetime import datetime, timedelta

from unittest.mock import patch

import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool

from app.database import Base, Document, get_db

with patch("slack_sdk.WebClient.auth_test", return_value={
"ok": True, "bot_id": "BTEST", "user_id": "UTEST", "team_id": "TTEST"
}):
from app.main import app

engine = create_engine(
"sqlite://",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
TestingSession = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base.metadata.create_all(bind=engine)


def override_get_db():
db = TestingSession()
try:
yield db
finally:
db.close()


client = TestClient(app, raise_server_exceptions=False)


@pytest.fixture(autouse=True)
def use_document_db():
previous = app.dependency_overrides.get(get_db)
app.dependency_overrides[get_db] = override_get_db
yield
if previous is None:
app.dependency_overrides.pop(get_db, None)
else:
app.dependency_overrides[get_db] = previous

BASE_TIME = datetime(2026, 1, 1, 9, 0, 0)


def _doc(title, content, source, minutes):
return Document(
title=title,
content=content,
source=source,
created_at=BASE_TIME + timedelta(minutes=minutes),
updated_at=BASE_TIME + timedelta(minutes=minutes),
)


@pytest.fixture(autouse=True)
def seed():
db = TestingSession()
db.query(Document).delete()
db.commit()
db.add_all([
_doc("VPN guide", "Use the IT portal to reset VPN.", "api", 0),
_doc("Office hours", "Nine to five.", "docs", 2),
_doc("Long guide", "x" * 200, "api", 10),
])
db.commit()
seeded_ids = [doc.id for doc in db.query(Document).order_by(Document.id).all()]
db.close()
yield seeded_ids
db = TestingSession()
db.query(Document).delete()
db.commit()
db.close()


class TestListDocuments:
def test_lists_documents_most_recent_first(self):
response = client.get("/api/documents")
assert response.status_code == 200
data = response.json()
assert data["total"] == 3
assert data["limit"] == 50
assert data["offset"] == 0
assert [item["title"] for item in data["items"]] == [
"Long guide", "Office hours", "VPN guide",
]
first = data["items"][0]
assert first["source"] == "api"
assert first["content_preview"] == "x" * 120

def test_preview_and_fields(self):
data = client.get("/api/documents").json()
by_title = {i["title"]: i for i in data["items"]}
assert by_title["VPN guide"]["content_preview"] == "Use the IT portal to reset VPN."
assert by_title["VPN guide"]["created_at"] is not None
assert "content" not in by_title["VPN guide"]

def test_pagination(self):
page_one = client.get("/api/documents?limit=2&offset=0").json()
page_two = client.get("/api/documents?limit=2&offset=2").json()
assert page_one["total"] == 3
assert len(page_one["items"]) == 2
assert len(page_two["items"]) == 1
assert page_one["items"][0]["id"] != page_two["items"][0]["id"]

def test_source_filter(self):
data = client.get("/api/documents?source=docs").json()
assert data["total"] == 1
assert data["items"][0]["title"] == "Office hours"

def test_invalid_pagination_rejected(self):
assert client.get("/api/documents?limit=0").status_code == 422
assert client.get("/api/documents?limit=201").status_code == 422
assert client.get("/api/documents?offset=-1").status_code == 422

def test_empty_database(self):
db = TestingSession()
db.query(Document).delete()
db.commit()
db.close()
data = client.get("/api/documents").json()
assert data == {"items": [], "total": 0, "limit": 50, "offset": 0}


class TestDocumentDetail:
def test_returns_full_content(self, seed):
doc_id = seed[0]
response = client.get(f"/api/documents/{doc_id}")
assert response.status_code == 200
data = response.json()
assert data["id"] == doc_id
assert data["title"] == "VPN guide"
assert data["content"] == "Use the IT portal to reset VPN."
assert data["source"] == "api"
assert data["created_at"] is not None

def test_missing_document_is_404(self):
response = client.get("/api/documents/999999")
assert response.status_code == 404
assert response.json()["detail"] == "Document not found"
37 changes: 35 additions & 2 deletions frontend/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,19 @@ export interface Document {
title: string
source: string
created_at: string
content_preview?: string | null
}

export interface DocumentDetail extends Document {
content: string
updated_at?: string | null
}

export interface DocumentListResponse {
items: Document[]
total: number
limit: number
offset: number
}

export interface DocumentRequest {
Expand Down Expand Up @@ -55,8 +68,28 @@ export async function askQuestion(
return response.json()
}

export async function listDocuments(): Promise<Document[]> {
const response = await fetch(`${API_BASE_URL}/api/documents`)
export async function listDocuments(
limit?: number,
offset?: number,
source?: string
): Promise<DocumentListResponse> {
const params = new URLSearchParams()
if (limit !== undefined) params.append('limit', limit.toString())
if (offset !== undefined) params.append('offset', offset.toString())
if (source) params.append('source', source)

const url = `${API_BASE_URL}/api/documents${params.toString() ? `?${params}` : ''}`
const response = await fetch(url)

if (!response.ok) {
throw new Error(`API error: ${response.status}`)
}

return response.json()
}

export async function getDocument(id: number): Promise<DocumentDetail> {
const response = await fetch(`${API_BASE_URL}/api/documents/${id}`)

if (!response.ok) {
throw new Error(`API error: ${response.status}`)
Expand Down
2 changes: 1 addition & 1 deletion mise.toml
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ run = "docker compose ps"
[tasks.test-backend]
description = "Run backend tests"
dir = "backend"
run = "uv run pytest tests/test_file_extraction_units.py tests/test_logging_config.py tests/test_logging.py tests/test_agent_runtime.py tests/test_rules.py tests/test_conversations.py -v"
run = "uv run pytest tests/test_file_extraction_units.py tests/test_logging_config.py tests/test_logging.py tests/test_agent_runtime.py tests/test_rules.py tests/test_conversations.py tests/test_documents.py -v"

[tasks.migrate]
description = "Run database migrations (upgrade to latest)"
Expand Down
Loading