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
2 changes: 1 addition & 1 deletion apps/api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,6 @@ app.onError(errorHandler)
app.route("/api", healthRoutes)
app.route("/api", createBookRoutes(booksDir))
app.route("/api", createPipelineRoutes(pipelineService, booksDir, promptsDir, configPath))
app.route("/api", createPageRoutes(booksDir))
app.route("/api", createPageRoutes(booksDir, promptsDir, configPath))

export default app
113 changes: 112 additions & 1 deletion apps/api/src/routes/pages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ describe("Page routes", () => {
storage.close()
}

const routes = createPageRoutes(tmpDir)
const routes = createPageRoutes(tmpDir, tmpDir)
app = new Hono()
app.onError(errorHandler)
app.route("/api", routes)
Expand All @@ -112,6 +112,7 @@ describe("Page routes", () => {
expect(body[0].pageId).toBe(`${label}_p1`)
expect(body[0].pageNumber).toBe(1)
expect(body[0].hasRendering).toBe(true)
expect(body[0].textPreview).toBe("Page one text content")
expect(body[1].pageId).toBe(`${label}_p2`)
expect(body[1].pageNumber).toBe(2)
expect(body[1].hasRendering).toBe(false)
Expand Down Expand Up @@ -185,4 +186,114 @@ describe("Page routes", () => {
expect(res.status).toBe(404)
})
})

describe("PUT /api/books/:label/pages/:pageId/text-classification", () => {
it("saves text classification and returns version", async () => {
const data = {
reasoning: "updated reasoning",
groups: [
{
groupId: "g1",
groupType: "body",
texts: [
{ textType: "paragraph", text: "Updated text", isPruned: false },
],
},
],
}

const res = await app.request(
`/api/books/${label}/pages/${label}_p1/text-classification`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
}
)

expect(res.status).toBe(200)
const body = await res.json()
expect(body.version).toBe(2) // version 1 was set in beforeEach
})

it("returns 400 for invalid body", async () => {
const res = await app.request(
`/api/books/${label}/pages/${label}_p1/text-classification`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ bad: "data" }),
}
)

expect(res.status).toBe(400)
})

it("returns 404 for nonexistent page", async () => {
const data = {
reasoning: "test",
groups: [],
}

const res = await app.request(
`/api/books/${label}/pages/fake-page/text-classification`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
}
)

expect(res.status).toBe(404)
})
})

describe("PUT /api/books/:label/pages/:pageId/image-classification", () => {
it("saves image classification and returns version", async () => {
const data = {
images: [
{ imageId: "img1", isPruned: false, reason: "kept" },
],
}

const res = await app.request(
`/api/books/${label}/pages/${label}_p1/image-classification`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
}
)

expect(res.status).toBe(200)
const body = await res.json()
expect(body.version).toBe(2) // version 1 was set in beforeEach
})

it("returns 400 for invalid body", async () => {
const res = await app.request(
`/api/books/${label}/pages/${label}_p1/image-classification`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ bad: "data" }),
}
)

expect(res.status).toBe(400)
})
})

describe("POST /api/books/:label/pages/:pageId/re-render", () => {
it("returns 400 when X-OpenAI-Key header is missing", async () => {
const res = await app.request(
`/api/books/${label}/pages/${label}_p1/re-render`,
{ method: "POST" }
)

expect(res.status).toBe(400)
const body = await res.json()
expect(body.error).toContain("X-OpenAI-Key")
})
})
})
106 changes: 102 additions & 4 deletions apps/api/src/routes/pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,16 @@ import fs from "node:fs"
import path from "node:path"
import { Hono } from "hono"
import { HTTPException } from "hono/http-exception"
import { parseBookLabel } from "@adt/types"
import { parseBookLabel, TextClassificationOutput, ImageClassificationOutput } from "@adt/types"
import { openBookDb } from "@adt/storage"
import { createBookStorage } from "@adt/storage"
import { reRenderPage } from "../services/page-edit-service.js"

interface PageSummary {
pageId: string
pageNumber: number
hasRendering: boolean
textPreview: string
}

interface PageDetail {
Expand All @@ -27,7 +29,11 @@ function getDbPath(label: string, booksDir: string): string {
return path.join(path.resolve(booksDir), safeLabel, `${safeLabel}.db`)
}

export function createPageRoutes(booksDir: string): Hono {
export function createPageRoutes(
booksDir: string,
promptsDir: string,
configPath?: string
): Hono {
const app = new Hono()

// GET /books/:label/pages — List pages with pipeline status
Expand All @@ -45,8 +51,8 @@ export function createPageRoutes(booksDir: string): Hono {
const db = openBookDb(dbPath)
try {
const pages = db.all(
"SELECT page_id, page_number FROM pages ORDER BY page_number"
) as Array<{ page_id: string; page_number: number }>
"SELECT page_id, page_number, text FROM pages ORDER BY page_number"
) as Array<{ page_id: string; page_number: number; text: string }>

// Check which pages have web-rendering output
const rendered = new Set<string>()
Expand All @@ -62,6 +68,7 @@ export function createPageRoutes(booksDir: string): Hono {
pageId: p.page_id,
pageNumber: p.page_number,
hasRendering: rendered.has(p.page_id),
textPreview: p.text.slice(0, 150),
}))

return c.json(result)
Expand Down Expand Up @@ -166,5 +173,96 @@ export function createPageRoutes(booksDir: string): Hono {
}
})

// PUT /books/:label/pages/:pageId/text-classification — Update text classification
app.put("/books/:label/pages/:pageId/text-classification", async (c) => {
const { label, pageId } = c.req.param()
const safeLabel = parseBookLabel(label)

const body = await c.req.json()
const parsed = TextClassificationOutput.safeParse(body)
if (!parsed.success) {
throw new HTTPException(400, {
message: `Invalid text-classification data: ${parsed.error.message}`,
})
}

const storage = createBookStorage(safeLabel, booksDir)
try {
const pages = storage.getPages()
const page = pages.find((p) => p.pageId === pageId)
if (!page) {
throw new HTTPException(404, { message: `Page not found: ${pageId}` })
}

const version = storage.putNodeData("text-classification", pageId, parsed.data)
return c.json({ version })
} finally {
storage.close()
}
})

// PUT /books/:label/pages/:pageId/image-classification — Update image classification
app.put("/books/:label/pages/:pageId/image-classification", async (c) => {
const { label, pageId } = c.req.param()
const safeLabel = parseBookLabel(label)

const body = await c.req.json()
const parsed = ImageClassificationOutput.safeParse(body)
if (!parsed.success) {
throw new HTTPException(400, {
message: `Invalid image-classification data: ${parsed.error.message}`,
})
}

const storage = createBookStorage(safeLabel, booksDir)
try {
const pages = storage.getPages()
const page = pages.find((p) => p.pageId === pageId)
if (!page) {
throw new HTTPException(404, { message: `Page not found: ${pageId}` })
}

const version = storage.putNodeData("image-classification", pageId, parsed.data)
return c.json({ version })
} finally {
storage.close()
}
})

// POST /books/:label/pages/:pageId/re-render — Re-render page with current pipeline data
app.post("/books/:label/pages/:pageId/re-render", async (c) => {
const { label, pageId } = c.req.param()
const safeLabel = parseBookLabel(label)

const apiKey = c.req.header("X-OpenAI-Key")
if (!apiKey) {
throw new HTTPException(400, {
message: "Missing X-OpenAI-Key header",
})
}

const storage = createBookStorage(safeLabel, booksDir)
try {
const pages = storage.getPages()
const page = pages.find((p) => p.pageId === pageId)
if (!page) {
throw new HTTPException(404, { message: `Page not found: ${pageId}` })
}
} finally {
storage.close()
}

const result = await reRenderPage({
label: safeLabel,
pageId,
booksDir,
promptsDir,
configPath,
apiKey,
})

return c.json(result)
})

return app
}
56 changes: 56 additions & 0 deletions apps/api/src/services/page-edit-service.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest"
import fs from "node:fs"
import path from "node:path"
import os from "node:os"
import { createBookStorage } from "@adt/storage"
import { reRenderPage } from "./page-edit-service.js"

describe("page-edit-service", () => {
let tmpDir: string
const label = "test-book"

beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "page-edit-svc-"))

// Create a book with extracted pages but no pipeline data
const storage = createBookStorage(label, tmpDir)
try {
const fakeImage = {
imageId: `${label}_p1_page`,
pngBuffer: Buffer.from("fake-png-data"),
hash: "abc123",
width: 800,
height: 600,
}
storage.putExtractedPage({
pageId: `${label}_p1`,
pageNumber: 1,
text: "Page one text content",
pageImage: fakeImage,
images: [],
})
} finally {
storage.close()
}
})

afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true })
})

describe("reRenderPage", () => {
it("throws when pipeline data is missing", async () => {
await expect(
reRenderPage({
label,
pageId: `${label}_p1`,
booksDir: tmpDir,
promptsDir: tmpDir,
apiKey: "test-key",
})
).rejects.toThrow(
"Page must have text-classification, image-classification, and page-sectioning data before re-rendering"
)
})
})
})
Loading