add new concept report

This commit is contained in:
Adrien
2026-04-18 17:54:54 +02:00
parent 5f03e1f41b
commit c7a77af2f4
29 changed files with 1892 additions and 41 deletions
+68 -2
View File
@@ -32,6 +32,13 @@
<span>{{ book.status === 'PENDING' ? 'Queued for processing...' : 'Embedding in progress...' }}</span>
</div>
<div v-if="enrichProgress && enrichProgress.status === 'RUNNING'" class="processing-indicator">
<div class="spinner spinner-dark"></div>
<span>Enriching chunks {{ enrichProgress.chunksEnriched }} / {{ enrichProgress.chunksTotal }}</span>
</div>
<div v-if="enrichFeedback" class="enrich-feedback">{{ enrichFeedback }}</div>
<div class="book-actions">
<router-link
v-if="book.status === 'READY'"
@@ -40,6 +47,15 @@
>
Read
</router-link>
<button
v-if="book.status === 'READY'"
class="btn btn-secondary"
:disabled="enrichRunning"
@click="handleEnrich"
title="Enrich chunks with concept metadata"
>
{{ enrichRunning ? 'Enriching...' : 'Enrich' }}
</button>
<button
v-if="deleteEnabled"
class="btn btn-danger"
@@ -54,8 +70,9 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { Book } from '@/stores/bookStore'
import { computed, onUnmounted, ref } from 'vue'
import type { Book, EnrichmentProgress } from '@/stores/bookStore'
import { useBookStore } from '@/stores/bookStore'
const props = defineProps<{
book: Book
@@ -67,6 +84,46 @@ defineEmits<{
(e: 'delete', id: string): void
}>()
const bookStore = useBookStore()
const enrichProgress = ref<EnrichmentProgress | null>(null)
const enrichFeedback = ref<string | null>(null)
let pollTimer: ReturnType<typeof setInterval> | null = null
const enrichRunning = computed(() => enrichProgress.value?.status === 'RUNNING')
async function handleEnrich() {
enrichFeedback.value = null
const started = await bookStore.startEnrichment(props.book.id)
if (!started) {
enrichFeedback.value = bookStore.error ?? 'Enrichment failed to start.'
return
}
enrichProgress.value = started
startPolling()
}
function startPolling() {
stopPolling()
pollTimer = setInterval(async () => {
const status = await bookStore.fetchEnrichmentStatus(props.book.id)
if (!status) return
enrichProgress.value = status
if (status.status === 'COMPLETED') {
stopPolling()
enrichFeedback.value = `Enriched ${status.chunksEnriched} / ${status.chunksTotal} chunks.`
}
}, 2000)
}
function stopPolling() {
if (pollTimer != null) {
clearInterval(pollTimer)
pollTimer = null
}
}
onUnmounted(stopPolling)
const statusClass = computed(() => {
switch (props.book.status) {
case 'READY':
@@ -193,4 +250,13 @@ function formatDate(iso: string): string {
gap: 0.5rem;
margin-top: 0.25rem;
}
.enrich-feedback {
font-size: 0.8rem;
color: #22543d;
background: #f0fff4;
border: 1px solid #c6f6d5;
border-radius: 6px;
padding: 0.4rem 0.6rem;
}
</style>
+38 -1
View File
@@ -77,5 +77,42 @@ export const useBookStore = defineStore('books', () => {
}
}
return { books, loading, uploading, error, fetchBooks, uploadBook, refreshBook, deleteBook }
async function startEnrichment(id: string): Promise<EnrichmentProgress | null> {
try {
const response = await api.post<EnrichmentProgress>(`/admin/books/${id}/enrich`)
return response.data
} catch (err: any) {
error.value = err.message
return null
}
}
async function fetchEnrichmentStatus(id: string): Promise<EnrichmentProgress | null> {
try {
const response = await api.get<EnrichmentProgress>(`/admin/books/${id}/enrich`)
return response.data
} catch {
return null
}
}
return {
books,
loading,
uploading,
error,
fetchBooks,
uploadBook,
refreshBook,
deleteBook,
startEnrichment,
fetchEnrichmentStatus
}
})
export interface EnrichmentProgress {
status: 'IDLE' | 'RUNNING' | 'COMPLETED'
chunksTotal: number
chunksEnriched: number
errorMessage: string | null
}
+82 -1
View File
@@ -39,6 +39,29 @@ export interface SavedSummaryItem {
generatedAt: string
}
export interface FacetSection {
facetKey: string
title: string
markdown: string
refLabels: string[]
}
export interface ConceptReport {
id: string
reportNumber: number
topicId: string
topicName: string
facets: FacetSection[]
sources: SourceReference[]
generatedAt: string
}
export interface SavedConceptReportItem {
id: string
reportNumber: number
generatedAt: string
}
export const useTopicStore = defineStore('topics', () => {
const topics = ref<Topic[]>([])
const activeSummary = ref<TopicSummary | null>(null)
@@ -49,6 +72,11 @@ export const useTopicStore = defineStore('topics', () => {
const summaryListLoading = ref(false)
const error = ref<string | null>(null)
const activeConceptReport = ref<ConceptReport | null>(null)
const conceptReportList = ref<SavedConceptReportItem[]>([])
const conceptReportLoading = ref(false)
const conceptReportListLoading = ref(false)
async function fetchTopics() {
loading.value = true
error.value = null
@@ -110,6 +138,52 @@ export const useTopicStore = defineStore('topics', () => {
}
}
async function fetchConceptReports(topicId: string) {
conceptReportListLoading.value = true
conceptReportList.value = []
error.value = null
try {
const response = await api.get<SavedConceptReportItem[]>(`/topics/${topicId}/concept-reports`)
conceptReportList.value = response.data
} catch (err: any) {
error.value = err.message
} finally {
conceptReportListLoading.value = false
}
}
async function fetchConceptReportDetail(topicId: string, reportId: string): Promise<ConceptReport | null> {
conceptReportLoading.value = true
activeConceptReport.value = null
error.value = null
try {
const response = await api.get<ConceptReport>(`/topics/${topicId}/concept-reports/${reportId}`)
activeConceptReport.value = response.data
return response.data
} catch (err: any) {
error.value = err.message
return null
} finally {
conceptReportLoading.value = false
}
}
async function generateConceptReport(topicId: string): Promise<ConceptReport | null> {
conceptReportLoading.value = true
activeConceptReport.value = null
error.value = null
try {
const response = await api.post<ConceptReport>(`/topics/${topicId}/concept-reports`)
activeConceptReport.value = response.data
return response.data
} catch (err: any) {
error.value = err.message
return null
} finally {
conceptReportLoading.value = false
}
}
return {
topics,
activeSummary,
@@ -119,9 +193,16 @@ export const useTopicStore = defineStore('topics', () => {
summaryLoading,
summaryListLoading,
error,
activeConceptReport,
conceptReportList,
conceptReportLoading,
conceptReportListLoading,
fetchTopics,
fetchSummaries,
fetchSummaryDetail,
generateSummary
generateSummary,
fetchConceptReports,
fetchConceptReportDetail,
generateConceptReport
}
})
+256 -27
View File
@@ -20,8 +20,22 @@
<div v-else class="topics-layout">
<div class="topics-main">
<!-- Mode toggle: Summary vs Concept Report -->
<div v-if="selectedTopicId" class="mode-toggle">
<button
class="mode-tab"
:class="{ 'mode-tab--active': mode === 'summary' }"
@click="setMode('summary')"
>Summary</button>
<button
class="mode-tab"
:class="{ 'mode-tab--active': mode === 'concept' }"
@click="setMode('concept')"
>Concept Report</button>
</div>
<!-- Summary history list -->
<div v-if="selectedTopicId" class="history-panel card">
<div v-if="selectedTopicId && mode === 'summary'" class="history-panel card">
<div class="history-header">
<span class="history-title">Saved summaries</span>
<button class="btn btn-primary btn-sm" :disabled="topicStore.summaryLoading" @click="handleGenerate(selectedTopicId!)">
@@ -53,8 +67,41 @@
</div>
</div>
<!-- Concept report history list -->
<div v-if="selectedTopicId && mode === 'concept'" class="history-panel card">
<div class="history-header">
<span class="history-title">Saved concept reports</span>
<button class="btn btn-primary btn-sm" :disabled="topicStore.conceptReportLoading" @click="handleGenerateConcept(selectedTopicId!)">
<span v-if="topicStore.conceptReportLoading" class="spinner" style="width:14px;height:14px;display:inline-block;vertical-align:middle;margin-right:4px;"></span>
Generate New
</button>
</div>
<div v-if="topicStore.conceptReportListLoading" class="history-loading">
<div class="spinner spinner-dark" style="width:20px;height:20px;margin-right:8px;display:inline-block;vertical-align:middle;"></div>
Loading...
</div>
<div v-else-if="topicStore.conceptReportList.length === 0" class="history-empty">
No concept reports yet. Click "Generate New" to create one.
</div>
<div v-else class="history-list">
<button
v-for="item in topicStore.conceptReportList"
:key="item.id"
class="history-chip"
:class="{ 'history-chip--active': topicStore.activeConceptReport?.id === item.id }"
@click="handleLoadConceptReport(item)"
>
Report #{{ item.reportNumber }}
<span class="history-chip-date">· {{ formatDateShort(item.generatedAt) }}</span>
</button>
</div>
</div>
<!-- Summary Panel -->
<div v-if="topicStore.summaryLoading" class="summary-panel card">
<div v-if="mode === 'summary' && topicStore.summaryLoading" class="summary-panel card">
<div class="summary-loading">
<div class="spinner spinner-dark" style="width:36px;height:36px;margin:0 auto 1rem;"></div>
<p class="summary-loading-text">Generating summary from uploaded books...</p>
@@ -62,7 +109,7 @@
</div>
</div>
<div v-else-if="summaryError" class="summary-panel card summary-error">
<div v-else-if="mode === 'summary' && summaryError" class="summary-panel card summary-error">
<h2 class="summary-topic-name">Summary Error</h2>
<p class="error-text">{{ summaryError }}</p>
<p v-if="isNoBooks" class="no-books-hint">
@@ -72,13 +119,13 @@
</p>
</div>
<div v-else-if="!topicStore.activeSummary" class="summary-panel card summary-placeholder">
<div v-else-if="mode === 'summary' && !topicStore.activeSummary" class="summary-panel card summary-placeholder">
<p class="summary-placeholder-text">
{{ selectedTopicId ? 'Select a saved summary or generate a new one.' : 'Select a topic to get started.' }}
</p>
</div>
<div v-else class="summary-panel card">
<div v-else-if="mode === 'summary'" class="summary-panel card">
<div class="summary-header">
<h2 class="summary-topic-name">{{ topicStore.activeSummary.topicName }}</h2>
<div class="summary-meta">
@@ -117,6 +164,77 @@
</div>
</div>
<!-- Concept Report panel -->
<div v-if="mode === 'concept' && topicStore.conceptReportLoading" class="summary-panel card">
<div class="summary-loading">
<div class="spinner spinner-dark" style="width:36px;height:36px;margin:0 auto 1rem;"></div>
<p class="summary-loading-text">Generating facet-organized concept report...</p>
<p class="summary-loading-hint">This may take up to 60 seconds.</p>
</div>
</div>
<div v-else-if="mode === 'concept' && conceptError" class="summary-panel card summary-error">
<h2 class="summary-topic-name">Concept Report Error</h2>
<p class="error-text">{{ conceptError }}</p>
<p v-if="isNoBooks" class="no-books-hint">
Please
<RouterLink to="/">upload and process at least one book</RouterLink>
first.
</p>
</div>
<div v-else-if="mode === 'concept' && !topicStore.activeConceptReport" class="summary-panel card summary-placeholder">
<p class="summary-placeholder-text">
{{ selectedTopicId ? 'Select a saved concept report or generate a new one.' : 'Select a topic to get started.' }}
</p>
</div>
<div v-else-if="mode === 'concept'" class="summary-panel card">
<div class="summary-header">
<h2 class="summary-topic-name">{{ topicStore.activeConceptReport!.topicName }}</h2>
<div class="summary-meta">
<span class="summary-number">
Concept Report #{{ topicStore.activeConceptReport!.reportNumber }}
</span>
<span class="summary-timestamp">{{ formatDate(topicStore.activeConceptReport!.generatedAt) }}</span>
</div>
</div>
<div
v-for="facet in topicStore.activeConceptReport!.facets"
:key="facet.facetKey"
class="concept-facet"
>
<h3 class="concept-facet-title">{{ facet.title }}</h3>
<div class="summary-text summary-text--markdown" v-html="renderFacetMarkdown(facet.markdown)" @click="handleSummaryClick"></div>
</div>
<div ref="sourcesSection" v-if="topicStore.activeConceptReport!.sources.length > 0" class="sources-section">
<button class="sources-toggle" @click="showSources = !showSources">
Sources ({{ topicStore.activeConceptReport!.sources.length }})
<span>{{ showSources ? '▲' : '▼' }}</span>
</button>
<SourceList
v-if="showSources"
:sources="topicStore.activeConceptReport!.sources"
@open-source="(bookId: string, page: number) => handleOpenSource(bookId, page)"
/>
<BookPagePanel
v-if="readerPanel"
:book-id="readerPanel.bookId"
:page="readerPanel.page"
:book-title="readerPanel.bookTitle"
class="reader-panel"
@close="readerPanel = null"
@navigate="(p) => readerPanel && (readerPanel.page = p)"
/>
</div>
<div v-else class="no-sources">
No source citations available for this concept report.
</div>
</div>
<!-- Topic Grid -->
<div class="topic-grid">
<TopicCard
@@ -137,7 +255,7 @@
import { ref, computed, onMounted, inject } from 'vue'
import { marked } from 'marked'
import { RouterLink } from 'vue-router'
import { useTopicStore, type SavedSummaryItem, type SourceReference } from '@/stores/topicStore'
import { useTopicStore, type SavedSummaryItem, type SavedConceptReportItem, type SourceReference } from '@/stores/topicStore'
import { useBookStore } from '@/stores/bookStore'
import TopicCard from '@/components/TopicCard.vue'
import BookPagePanel from '@/components/BookPagePanel.vue'
@@ -149,9 +267,11 @@ const showToast = inject<(msg: string, type?: 'error' | 'success') => void>('sho
const showSources = ref(true)
const summaryError = ref<string | null>(null)
const conceptError = ref<string | null>(null)
const isNoBooks = ref(false)
const sourcesSection = ref<HTMLElement | null>(null)
const selectedTopicId = ref<string | null>(null)
const mode = ref<'summary' | 'concept'>('summary')
interface ReaderPanel { bookId: string; page: number; bookTitle?: string }
const readerPanel = ref<ReaderPanel | null>(null)
@@ -162,6 +282,35 @@ function escapeHtml(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
}
function renderOneCitation(label: string, figureMap: Map<string, SourceReference>): string {
const badge = `<span class="source-ref" data-ref="${label}" title="Jump to source ${label}">[${label}]</span>`
const fig = figureMap.get(label)
if (fig?.imageUrl) {
const alt = escapeHtml(fig.caption || fig.label || 'Figure')
const captionText = [fig.label, fig.caption].filter(Boolean).map(escapeHtml).join(' — ')
const captionHtml = captionText
? `<figcaption class="inline-figure-caption">${captionText}</figcaption>`
: ''
return `${badge}<figure class="inline-figure"><img src="${fig.imageUrl}" alt="${alt}" class="inline-figure-img" loading="lazy" onerror="this.parentElement.style.display='none'" />${captionHtml}</figure>`
}
return badge
}
// Matches [S1], [F2], and tolerates multi-label malformed output like [S26 1], [S1, S2], [S1 F3].
// Inside each bracket we extract every ([SF]?)(\d+) token; bare numbers inherit the last seen prefix.
function replaceCitations(html: string, figureMap: Map<string, SourceReference>): string {
return html.replace(/\[([SF]\d+(?:[\s,]+[SF]?\d+)*)\]/g, (_match, inner: string) => {
const tokens: string[] = []
let lastType: 'S' | 'F' = 'S'
for (const m of inner.matchAll(/([SF]?)(\d+)/g)) {
const prefix = (m[1] || lastType) as 'S' | 'F'
lastType = prefix
tokens.push(`${prefix}${m[2]}`)
}
return tokens.map(label => renderOneCitation(label, figureMap)).join(' ')
})
}
const renderedSummary = computed(() => {
if (!topicStore.activeSummary) return ''
const html = marked.parse(topicStore.activeSummary.summary) as string
@@ -173,22 +322,7 @@ const renderedSummary = computed(() => {
}
}
return html.replace(/\[(S|F)\d+\]/g, (match) => {
const inner = match.slice(1, -1)
const badge = `<span class="source-ref" data-ref="${inner}" title="Jump to source ${inner}">${match}</span>`
const fig = figureMap.get(inner)
if (fig?.imageUrl) {
const alt = escapeHtml(fig.caption || fig.label || 'Figure')
const captionText = [fig.label, fig.caption].filter(Boolean).map(escapeHtml).join(' — ')
const captionHtml = captionText
? `<figcaption class="inline-figure-caption">${captionText}</figcaption>`
: ''
return `${badge}<figure class="inline-figure"><img src="${fig.imageUrl}" alt="${alt}" class="inline-figure-img" loading="lazy" onerror="this.parentElement.style.display='none'" />${captionHtml}</figure>`
}
return badge
})
return replaceCitations(html, figureMap)
})
function handleSummaryClick(e: MouseEvent) {
@@ -208,12 +342,21 @@ async function handleTopicClick(topicId: string) {
if (selectedTopicId.value !== topicId) {
selectedTopicId.value = topicId
topicStore.activeSummary = null
topicStore.activeConceptReport = null
summaryError.value = null
await topicStore.fetchSummaries(topicId)
// Auto-load the latest summary if any exist
const list = topicStore.summaryList
if (list.length > 0) {
await topicStore.fetchSummaryDetail(topicId, list[list.length - 1].id)
conceptError.value = null
if (mode.value === 'summary') {
await topicStore.fetchSummaries(topicId)
const list = topicStore.summaryList
if (list.length > 0) {
await topicStore.fetchSummaryDetail(topicId, list[list.length - 1].id)
}
} else {
await topicStore.fetchConceptReports(topicId)
const list = topicStore.conceptReportList
if (list.length > 0) {
await topicStore.fetchConceptReportDetail(topicId, list[list.length - 1].id)
}
}
}
}
@@ -224,6 +367,52 @@ async function handleLoadSummary(item: SavedSummaryItem) {
await topicStore.fetchSummaryDetail(selectedTopicId.value, item.id)
}
async function setMode(next: 'summary' | 'concept') {
if (mode.value === next) return
mode.value = next
readerPanel.value = null
if (next === 'concept' && selectedTopicId.value) {
await topicStore.fetchConceptReports(selectedTopicId.value)
const list = topicStore.conceptReportList
if (list.length > 0) {
await topicStore.fetchConceptReportDetail(selectedTopicId.value, list[list.length - 1].id)
}
}
}
function renderFacetMarkdown(md: string): string {
if (!md) return ''
const html = marked.parse(md) as string
const figureMap = new Map<string, SourceReference>()
const sources = topicStore.activeConceptReport?.sources ?? []
for (const src of sources) {
if (src.type === 'FIGURE' && src.refLabel) figureMap.set(src.refLabel, src)
}
return replaceCitations(html, figureMap)
}
async function handleLoadConceptReport(item: SavedConceptReportItem) {
if (!selectedTopicId.value) return
conceptError.value = null
await topicStore.fetchConceptReportDetail(selectedTopicId.value, item.id)
}
async function handleGenerateConcept(topicId: string) {
conceptError.value = null
isNoBooks.value = false
showSources.value = true
const result = await topicStore.generateConceptReport(topicId)
if (!result) {
conceptError.value = topicStore.error ?? 'Failed to generate concept report.'
isNoBooks.value =
conceptError.value.toLowerCase().includes('no books') ||
conceptError.value.toLowerCase().includes('knowledge source')
showToast?.(conceptError.value, 'error')
} else {
await topicStore.fetchConceptReports(topicId)
}
}
onMounted(async () => {
await topicStore.fetchTopics()
if (bookStore.books.length === 0) {
@@ -286,6 +475,46 @@ function formatDateShort(iso: string): string {
gap: 1rem;
}
.mode-toggle {
display: flex;
gap: 0.5rem;
margin-bottom: 0.25rem;
}
.mode-tab {
background: transparent;
border: 1px solid #cbd5e0;
color: #4a5568;
padding: 0.4rem 1rem;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
border-radius: 999px;
}
.mode-tab:hover {
background: #edf2f7;
}
.mode-tab--active {
background: #553c9a;
color: white;
border-color: #553c9a;
}
.concept-facet {
margin-bottom: 1.5rem;
}
.concept-facet-title {
font-size: 1.1rem;
font-weight: 600;
color: #553c9a;
margin: 0 0 0.5rem 0;
padding-bottom: 0.25rem;
border-bottom: 1px solid #e2e8f0;
}
/* History panel */
.history-panel {
border-top: 3px solid #805ad5;