pagepiper/web/src/api.ts
pyr0ball f941ebdeeb feat: add ODT and Apple Pages document support, wire DOCX into UI
Extends Pagepiper's document shelving pipeline (renamed from "ingest" —
see below) to cover the formats most likely to appear in a real-world
engineering document corpus, prompted by scoping a STERIS licensing pitch
that needs DOCX/ODT coverage.

- Rename the ingest pipeline to "shelve" throughout (scripts/, app/api,
  tests, docs, frontend). "Glean" (Turnstone's term) was considered and
  rejected — that's a harvest metaphor for log/knowledge extraction,
  not a fit for documents entering a library. Documented as a general
  CF naming principle in the org-level CLAUDE.md.
- Wire DOCX into the upload/scan UI, README, and docs — the extraction
  logic (heading-based chunking, table serialization) already existed
  but wasn't exposed to users or covered by tests.
- Add ODT support via odfpy, mirroring DOCX's chunking strategy.
- Add Apple Pages support via headless LibreOffice conversion to ODT.
  No maintained Python library parses the IWA format directly; libreoffice
  bundles libetonyek, the only real open-source Pages parser. Adds
  libreoffice-writer to the Docker image (~300-400MB) for this.
- 24 new/updated tests across shelve_docx, shelve_odt, and shelve_pages;
  full suite (72 tests) passing.

Known gaps not addressed here: no Windchill/DocPortal connector exists
yet (metadata-only PowerShell recon only), Excel/.xlsx is unsupported,
and circuitforge_core.tasks.dispatch_task does not currently exist in
circuitforge-core — cf-orch dispatch is dead code, always falling
through to local BackgroundTasks. See
circuitforge-plans/pagepiper/superpowers/plans/2026-07-10-steris-licensing-pitch.md
for the full writeup.
2026-07-10 13:58:43 -07:00

139 lines
4.5 KiB
TypeScript

// web/src/api.ts
const BASE = import.meta.env.VITE_API_BASE ?? ""
export interface Document {
id: string
title: string
file_path: string
status: "pending" | "processing" | "ready" | "error"
task_id: string | null
page_count: number | null
created_at: string
}
export interface SearchResult {
chunk_id: string
doc_id: string
page_number: number
text_snippet: string
bm25_score: number
}
export interface Citation {
doc_id: string
page_number: number
snippet: string
bm25_score: number | null
}
export interface ChatResponse {
answer: string
citations: Citation[]
}
export interface TaskStatus {
status: string
progress?: number
error?: string
}
export interface DocumentStatus {
id: string
status: "pending" | "processing" | "ready" | "error"
task_id: string | null
page_count: number | null
vec_count: number
error_msg: string | null
}
export interface ChatMessage {
role: string
content: string
}
export const api = {
async getLibrary(): Promise<Document[]> {
const r = await fetch(`${BASE}/api/library`)
if (!r.ok) throw new Error(await r.text())
return r.json()
},
async scanLibrary(): Promise<{ discovered: number; queued: number; tasks: { doc_id: string; task_id: string }[] }> {
const r = await fetch(`${BASE}/api/library/scan`, { method: "POST" })
if (!r.ok) throw new Error(await r.text())
return r.json()
},
async reshelveDocument(docId: string): Promise<{ task_id: string }> {
const r = await fetch(`${BASE}/api/library/${docId}/reshelve`, { method: "POST" })
if (!r.ok) throw new Error(await r.text())
return r.json()
},
async deleteDocument(docId: string): Promise<void> {
const r = await fetch(`${BASE}/api/library/${docId}`, { method: "DELETE" })
if (!r.ok) throw new Error(await r.text())
},
async uploadDocument(file: File): Promise<{ doc_id: string; task_id: string | null; filename: string; status: string }> {
const form = new FormData()
form.append("file", file)
const r = await fetch(`${BASE}/api/library/upload`, { method: "POST", body: form })
if (!r.ok) throw new Error(await r.text())
return r.json()
},
async getTaskStatus(taskId: string): Promise<TaskStatus> {
const r = await fetch(`${BASE}/api/shelve/${taskId}`)
if (!r.ok) throw new Error(await r.text())
return r.json()
},
async getDocumentStatus(docId: string): Promise<DocumentStatus> {
const r = await fetch(`${BASE}/api/library/${docId}/status`)
if (!r.ok) throw new Error(await r.text())
return r.json()
},
async search(query: string, topK = 10, docIds?: string[]): Promise<SearchResult[]> {
const r = await fetch(`${BASE}/api/search`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query, top_k: topK, doc_ids: docIds ?? null }),
})
if (!r.ok) throw new Error(await r.text())
return r.json()
},
async chat(
message: string,
history: ChatMessage[],
docIds?: string[],
topK = 5,
): Promise<ChatResponse> {
const r = await fetch(`${BASE}/api/chat`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message, history, doc_ids: docIds ?? null, top_k: topK }),
})
if (!r.ok) {
const body = await r.json().catch(() => ({}))
const err: Error & { status?: number; detail?: unknown } = new Error(
(body as { detail?: { message?: string } }).detail?.message ?? "Request failed"
)
err.status = r.status
err.detail = (body as { detail?: unknown }).detail
throw err
}
return r.json()
},
async chatFeedbackStatus(): Promise<{ enabled: boolean }> {
const r = await fetch(`${BASE}/api/chat/feedback/status`)
if (!r.ok) return { enabled: false }
return r.json()
},
async submitChatFeedback(
rating: 1 | -1,
question: string,
answer: string,
docIds: string[],
): Promise<void> {
await fetch(`${BASE}/api/chat/feedback`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ rating, question, answer, doc_ids: docIds }),
})
},
}