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.
145 lines
5.3 KiB
Vue
145 lines
5.3 KiB
Vue
<template>
|
|
<div class="doc-card" :class="`status-${currentStatus}`">
|
|
<div class="doc-status-badge" :class="`badge-${currentStatus}`">{{ currentStatus }}</div>
|
|
<div class="doc-title">{{ doc.title }}</div>
|
|
<div class="doc-meta" v-if="displayPageCount != null">{{ displayPageCount }} pages</div>
|
|
<div class="doc-meta path">{{ shortPath }}</div>
|
|
|
|
<div class="shelve-progress" v-if="isProcessing">
|
|
<div class="progress-label">
|
|
<span>{{ progressLabel }}</span>
|
|
<span class="progress-pct" v-if="progressPct != null">{{ progressPct }}%</span>
|
|
</div>
|
|
<div class="progress-bar">
|
|
<div class="progress-fill" :class="{ indeterminate: progressPct == null }" :style="progressPct != null ? { width: `${progressPct}%` } : {}" />
|
|
</div>
|
|
</div>
|
|
|
|
<p class="doc-error" v-if="currentStatus === 'error'">{{ errorMsg ?? 'Indexing failed.' }}</p>
|
|
|
|
<div class="doc-actions">
|
|
<button class="btn-sm" @click="emit('reshelve', doc.id)" :disabled="isProcessing">
|
|
Re-index
|
|
</button>
|
|
<button class="btn-sm danger" @click="emit('delete', doc.id)">Remove</button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed, onMounted, onUnmounted, ref } from "vue"
|
|
import type { Document } from "@/api"
|
|
import { api } from "@/api"
|
|
|
|
const props = defineProps<{ doc: Document }>()
|
|
const emit = defineEmits<{ reshelve: [id: string]; delete: [id: string]; refresh: [] }>()
|
|
|
|
const shortPath = computed(() => {
|
|
const parts = props.doc.file_path.split("/")
|
|
return parts.slice(-2).join("/")
|
|
})
|
|
|
|
// Live-updating fields polled from /api/library/{id}/status
|
|
const currentStatus = ref(props.doc.status)
|
|
const displayPageCount = ref(props.doc.page_count)
|
|
const vecCount = ref(0)
|
|
const errorMsg = ref<string | null>(null)
|
|
|
|
const isProcessing = computed(() => currentStatus.value === "processing")
|
|
|
|
const progressLabel = computed(() => {
|
|
if (displayPageCount.value == null || vecCount.value === 0) return "Extracting text…"
|
|
return `Embedding ${vecCount.value} / ${displayPageCount.value} pages`
|
|
})
|
|
|
|
const progressPct = computed((): number | null => {
|
|
if (displayPageCount.value == null || displayPageCount.value === 0) return null
|
|
if (vecCount.value === 0) return null
|
|
return Math.min(Math.round((vecCount.value / displayPageCount.value) * 100), 99)
|
|
})
|
|
|
|
let timer: ReturnType<typeof setInterval> | null = null
|
|
|
|
async function pollStatus() {
|
|
try {
|
|
const s = await api.getDocumentStatus(props.doc.id)
|
|
currentStatus.value = s.status
|
|
displayPageCount.value = s.page_count
|
|
vecCount.value = s.vec_count
|
|
errorMsg.value = s.error_msg
|
|
if (s.status !== "processing") {
|
|
stopPoll()
|
|
if (s.status === "ready") emit("refresh")
|
|
}
|
|
} catch (_e: unknown) { /* non-fatal — keep polling */ }
|
|
}
|
|
|
|
function stopPoll() {
|
|
if (timer) { clearInterval(timer); timer = null }
|
|
}
|
|
|
|
onMounted(() => {
|
|
if (props.doc.status === "processing") {
|
|
pollStatus()
|
|
timer = setInterval(pollStatus, 3000)
|
|
}
|
|
})
|
|
|
|
onUnmounted(stopPoll)
|
|
</script>
|
|
|
|
<style scoped>
|
|
.doc-card {
|
|
background: var(--color-surface);
|
|
border: 1px solid var(--color-border);
|
|
border-radius: var(--radius-md);
|
|
padding: 1rem;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.4rem;
|
|
box-shadow: var(--shadow-card);
|
|
position: relative;
|
|
}
|
|
.doc-card.status-error { border-color: var(--color-error); }
|
|
.doc-card.status-ready { border-color: var(--color-success); }
|
|
.doc-card.status-processing { border-color: var(--color-accent); }
|
|
.doc-title { font-weight: 600; font-size: 1rem; }
|
|
.doc-meta { font-size: 0.8rem; color: var(--color-text-muted); }
|
|
.doc-meta.path { font-family: var(--font-mono); word-break: break-all; }
|
|
.doc-status-badge {
|
|
position: absolute; top: 0.5rem; right: 0.75rem;
|
|
font-size: 0.7rem; font-weight: 700; text-transform: uppercase;
|
|
padding: 2px 6px; border-radius: var(--radius-sm);
|
|
background: var(--color-surface-alt);
|
|
}
|
|
.badge-processing { background: var(--color-accent); color: #fff; }
|
|
.badge-ready { background: var(--color-success); color: #fff; }
|
|
.badge-error { background: var(--color-error); color: #fff; }
|
|
.doc-actions { display: flex; gap: 0.5rem; margin-top: 0.5rem; }
|
|
.btn-sm {
|
|
padding: 4px 10px; border: 1px solid var(--color-border); border-radius: var(--radius-sm);
|
|
background: var(--color-surface-alt); color: var(--color-text); cursor: pointer; font-size: 0.8rem;
|
|
}
|
|
.btn-sm:hover { border-color: var(--color-accent); }
|
|
.btn-sm.danger:hover { border-color: var(--color-error); color: var(--color-error); }
|
|
.btn-sm:disabled { opacity: 0.4; cursor: default; }
|
|
.doc-error { color: var(--color-error); font-size: 0.8rem; }
|
|
|
|
/* Progress bar */
|
|
.shelve-progress { margin-top: 0.25rem; }
|
|
.progress-label {
|
|
display: flex; justify-content: space-between;
|
|
font-size: 0.78rem; color: var(--color-text-muted); margin-bottom: 4px;
|
|
}
|
|
.progress-pct { font-variant-numeric: tabular-nums; }
|
|
.progress-bar { height: 4px; background: var(--color-border); border-radius: 2px; overflow: hidden; }
|
|
.progress-fill { height: 100%; background: var(--color-accent); transition: width 0.4s ease; }
|
|
.progress-fill.indeterminate {
|
|
width: 40%;
|
|
animation: slide 1.4s ease-in-out infinite;
|
|
}
|
|
@keyframes slide {
|
|
0% { transform: translateX(-100%); }
|
|
100% { transform: translateX(300%); }
|
|
}
|
|
</style>
|