diff --git a/frontend/src/lib/components/analysis/AnalysisHistoryTable.svelte b/frontend/src/lib/components/analysis/AnalysisHistoryTable.svelte index 9a9bb91..ef88637 100644 --- a/frontend/src/lib/components/analysis/AnalysisHistoryTable.svelte +++ b/frontend/src/lib/components/analysis/AnalysisHistoryTable.svelte @@ -21,7 +21,6 @@ let total = $state(0); let page = $state(1); let statusFilter = $state<'' | 'done' | 'failed'>(''); - let nsfwOnly = $state(false); let search = $state(''); let loading = $state(true); let error = $state(null); @@ -34,7 +33,6 @@ try { const resp = await listAnalysisHistory({ status: statusFilter || undefined, - nsfw: nsfwOnly, search: search.trim() || undefined, limit: LIMIT, offset: (page - 1) * LIMIT @@ -86,15 +84,6 @@ - {r.status} {r.manga_title} · Ch {r.chapter_number} · p{r.page_number} - {#if r.is_nsfw}⚠ NSFW{/if} {r.model ?? '—'} {fmtDuration(r.duration_ms)} @@ -169,13 +157,6 @@ color: var(--text); font-size: var(--font-sm); } - .nsfw { - display: inline-flex; - align-items: center; - gap: var(--space-1); - font-size: var(--font-sm); - color: var(--text-muted); - } .muted { color: var(--text-muted); } @@ -218,11 +199,6 @@ background: color-mix(in srgb, var(--danger) 16%, transparent); color: var(--danger); } - .nsfw-tag { - font-size: var(--font-xs); - color: #b85e1a; - margin-left: var(--space-1); - } .error { color: var(--danger); } diff --git a/frontend/src/lib/components/analysis/AnalysisHistoryTable.svelte.test.ts b/frontend/src/lib/components/analysis/AnalysisHistoryTable.svelte.test.ts new file mode 100644 index 0000000..a9d7595 --- /dev/null +++ b/frontend/src/lib/components/analysis/AnalysisHistoryTable.svelte.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, cleanup, waitFor } from '@testing-library/svelte'; + +// Mock the admin API so the component's onMount load() is deterministic. +const listAnalysisHistory = vi.fn(); +vi.mock('$lib/api/admin', () => ({ + listAnalysisHistory: (...args: unknown[]) => listAnalysisHistory(...args) +})); + +import AnalysisHistoryTable from './AnalysisHistoryTable.svelte'; + +afterEach(() => { + cleanup(); + listAnalysisHistory.mockReset(); +}); + +function row(over: Record = {}) { + return { + page_id: 'p1', + page_number: 1, + chapter_id: 'c1', + chapter_number: 5, + manga_id: 'm1', + manga_title: 'Berserk', + status: 'done', + is_nsfw: true, // even when the backend reports nsfw, the OCR UI must not surface it + model: 'ocrs', + error: null, + analyzed_at: '2026-01-02T00:00:00Z', + duration_ms: 1234, + ...over + }; +} + +function resolveOnce(rows: ReturnType[]) { + listAnalysisHistory.mockResolvedValue({ + items: rows, + page: { total: rows.length } + }); +} + +describe('AnalysisHistoryTable (OCR)', () => { + it('does not render the NSFW-only filter', async () => { + resolveOnce([row()]); + render(AnalysisHistoryTable, { props: { onOpenDetail: () => {} } }); + await waitFor(() => expect(listAnalysisHistory).toHaveBeenCalled()); + expect(screen.queryByTestId('analysis-history-nsfw')).toBeNull(); + }); + + it('does not pass an nsfw filter to the history query', async () => { + resolveOnce([row()]); + render(AnalysisHistoryTable, { props: { onOpenDetail: () => {} } }); + await waitFor(() => expect(listAnalysisHistory).toHaveBeenCalled()); + const arg = listAnalysisHistory.mock.calls[0][0] as Record; + expect('nsfw' in arg).toBe(false); + }); + + it('never shows an NSFW tag on a row, even if the row is flagged', async () => { + resolveOnce([row({ is_nsfw: true })]); + render(AnalysisHistoryTable, { props: { onOpenDetail: () => {} } }); + await waitFor(() => expect(screen.getByText(/Berserk/)).toBeTruthy()); + expect(screen.queryByText(/NSFW/i)).toBeNull(); + }); +}); diff --git a/frontend/src/lib/components/analysis/AnalysisMetricsPanel.svelte b/frontend/src/lib/components/analysis/AnalysisMetricsPanel.svelte index b76a64c..e4ae37a 100644 --- a/frontend/src/lib/components/analysis/AnalysisMetricsPanel.svelte +++ b/frontend/src/lib/components/analysis/AnalysisMetricsPanel.svelte @@ -105,30 +105,6 @@ {metrics.failed} - -

By model

- {#if metrics.by_model.length === 0} -

No completed analyses in this window.

- {:else} - - - - - - - - - - {#each metrics.by_model as m (m.model)} - - - - - - {/each} - -
ModelAvgN
{m.model ?? '(unknown)'}{fmtDuration(m.avg_ms)}{m.n}
- {/if} {/if} @@ -186,29 +162,6 @@ font-weight: var(--weight-semibold); font-variant-numeric: tabular-nums; } - h2 { - margin: 0 0 var(--space-2); - font-size: var(--font-sm); - color: var(--text-muted); - text-transform: uppercase; - letter-spacing: 0.04em; - } - table { - width: 100%; - border-collapse: collapse; - max-width: 32rem; - } - th, - td { - padding: var(--space-2); - text-align: left; - border-bottom: 1px solid var(--border); - font-size: var(--font-sm); - } - .num { - text-align: right; - font-variant-numeric: tabular-nums; - } .muted { color: var(--text-muted); } diff --git a/frontend/src/lib/components/analysis/AnalysisMetricsPanel.svelte.test.ts b/frontend/src/lib/components/analysis/AnalysisMetricsPanel.svelte.test.ts new file mode 100644 index 0000000..0c86782 --- /dev/null +++ b/frontend/src/lib/components/analysis/AnalysisMetricsPanel.svelte.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, cleanup, waitFor } from '@testing-library/svelte'; + +const getAnalysisMetrics = vi.fn(); +const getAnalysisMetricsSeries = vi.fn(); +vi.mock('$lib/api/admin', () => ({ + getAnalysisMetrics: (...a: unknown[]) => getAnalysisMetrics(...a), + getAnalysisMetricsSeries: (...a: unknown[]) => getAnalysisMetricsSeries(...a) +})); + +import AnalysisMetricsPanel from './AnalysisMetricsPanel.svelte'; + +afterEach(() => { + cleanup(); + getAnalysisMetrics.mockReset(); + getAnalysisMetricsSeries.mockReset(); +}); + +describe('AnalysisMetricsPanel (OCR)', () => { + it('renders the aggregate tiles but not the by-model table', async () => { + getAnalysisMetrics.mockResolvedValue({ + n: 10, + ok: 9, + failed: 1, + avg_ms: 1200, + // Even when the API returns a by-model breakdown, OCR has a single + // engine so the panel must not surface the table. + by_model: [{ model: 'ocrs', avg_ms: 1200, n: 10 }] + }); + getAnalysisMetricsSeries.mockResolvedValue({ buckets: [] }); + + render(AnalysisMetricsPanel); + + await waitFor(() => + expect(screen.getByTestId('analysis-metrics-n').textContent).toContain('10') + ); + expect(screen.queryByText(/By model/i)).toBeNull(); + expect(screen.queryByRole('table')).toBeNull(); + }); +}); diff --git a/frontend/src/routes/admin/analysis/+page.svelte b/frontend/src/routes/admin/analysis/+page.svelte index cdad181..99ead41 100644 --- a/frontend/src/routes/admin/analysis/+page.svelte +++ b/frontend/src/routes/admin/analysis/+page.svelte @@ -533,9 +533,9 @@

- Coverage of the AI analysis worker (OCR, auto-tags, scene description, - NSFW moderation). Browse what's analyzed, queue more, and inspect any - page's result. Updates stream live as pages are queued and processed. + Coverage of the OCR text-extraction worker. Browse which pages have had + their text extracted, queue more, and inspect any page's OCR result. + Updates stream live as pages are queued and processed.

@@ -818,37 +818,12 @@

{detail.error ?? 'Analysis failed.'}

{/if} - {#if detail.is_nsfw || detail.content_warnings.length > 0} -
- ⚠ NSFW - {#each detail.content_warnings as w (w)} - {w} - {/each} -
- {/if} - - {#if detail.scene_description} -
-

Scene

-

{detail.scene_description}

-
- {/if} - - {#if detail.tags.length > 0} -
-

Tags

-
- {#each detail.tags as t (t)}{t}{/each} -
-
- {/if} - {#if detail.ocr.length > 0}

OCR text

    {#each detail.ocr as line, i (i)} -
  • {line.kind} {line.text}
  • +
  • {#if line.kind}{line.kind} {/if}{line.text}
  • {/each}
@@ -1186,28 +1161,6 @@ background: color-mix(in srgb, var(--danger) 16%, transparent); color: var(--danger); } - .warnings { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: var(--space-2); - padding: var(--space-2) var(--space-3); - border-radius: var(--radius-md); - background: color-mix(in srgb, #d4762a 12%, transparent); - margin-bottom: var(--space-3); - } - .cw-label { - font-weight: 600; - color: #b85e1a; - font-size: var(--font-sm); - } - .cw-chip { - font-size: var(--font-sm); - padding: 1px var(--space-2); - border-radius: var(--radius-sm); - background: color-mix(in srgb, #d4762a 22%, transparent); - text-transform: capitalize; - } .block { margin-bottom: var(--space-3); } @@ -1218,17 +1171,6 @@ letter-spacing: 0.04em; margin: 0 0 var(--space-1); } - .tag-row { - display: flex; - flex-wrap: wrap; - gap: var(--space-1); - } - .tag { - font-size: var(--font-sm); - padding: 1px var(--space-2); - border-radius: var(--radius-pill); - background: var(--surface-elevated); - } .ocr { list-style: none; margin: 0; diff --git a/frontend/src/routes/admin/settings/+page.svelte b/frontend/src/routes/admin/settings/+page.svelte index 3de6cbe..07b2661 100644 --- a/frontend/src/routes/admin/settings/+page.svelte +++ b/frontend/src/routes/admin/settings/+page.svelte @@ -9,8 +9,7 @@ updateAnalysisSettings, type CrawlerSettings, type CrawlerEnvOnly, - type AnalysisSettings, - type PromptDefaults + type AnalysisSettings } from '$lib/api/admin'; type Tab = 'crawler' | 'analysis'; @@ -21,10 +20,11 @@ let crawlerEnv = $state(null); let allowlistText = $state(''); // textarea mirror of download_allowlist - // Analysis state + // Analysis state. The full settings object is loaded and saved as-is; + // vision-only fields (endpoint, model, prompts, slicing, sampling) ride + // along unchanged while the OCR backend is active — only the OCR-relevant + // knobs are surfaced below. let analysis = $state(null); - let promptDefaults = $state(null); - let apiKeyConfigured = $state(false); let loading = $state(true); let loadError = $state(null); @@ -46,8 +46,6 @@ crawlerEnv = c.env_only; allowlistText = c.editable.download_allowlist.join('\n'); analysis = a.editable; - promptDefaults = a.prompt_defaults; - apiKeyConfigured = a.env_only.api_key_configured; } catch (e) { loadError = e instanceof Error ? e.message : 'Failed to load settings.'; } finally { @@ -96,8 +94,6 @@ } else if (tab === 'analysis' && analysis) { const r = await updateAnalysisSettings(analysis); analysis = r.editable; - promptDefaults = r.prompt_defaults; - apiKeyConfigured = r.env_only.api_key_configured; } success = 'Saved and applied. The subsystem was restarted with the new settings.'; } catch (e) { @@ -113,9 +109,6 @@ } } - function resetPrompt(which: 'system_prompt' | 'ocr_prompt' | 'grounding_prompt') { - if (analysis) analysis[which] = null; - }

Settings

@@ -322,145 +315,29 @@ {:else if tab === 'analysis' && analysis}
- Endpoint & model - - + Engine

- API key: {apiKeyConfigured - ? 'configured via environment' - : 'not set'} (managed via environment). + Backend: OCR (ocrs) — in-process text extraction. Selected via + environment; vision is temporarily disabled.

- Workers & timeouts + Worker - -
- -
- Sampling & output - - - - -
- -
- Image slicing - - - - -
- -
- Prompts - {#each [['system_prompt', 'System prompt'], ['ocr_prompt', 'OCR prompt (slices)'], ['grounding_prompt', 'Grounding prompt']] as [key, label] (key)} - {@const k = key as 'system_prompt' | 'ocr_prompt' | 'grounding_prompt'} -
-
- {label} - -
- - - {analysis[k] == null - ? 'Using compiled default (shown as placeholder).' - : `${analysis[k]?.length ?? 0} chars (override).`} - -
- {/each} -
{/if} {/if} @@ -564,7 +441,6 @@ color: var(--text-muted); } .field input, - .field select, .field textarea { padding: var(--space-2) var(--space-3); border: 1px solid var(--border); @@ -584,39 +460,6 @@ color: var(--danger, #dc2626); font-size: var(--font-xs); } - .mono { - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; - font-size: var(--font-xs); - width: 100%; - box-sizing: border-box; - padding: var(--space-2); - border: 1px solid var(--border); - border-radius: var(--radius-md); - background: var(--surface); - color: var(--text); - } - .prompt { - display: grid; - gap: var(--space-1); - } - .prompt-head { - display: flex; - justify-content: space-between; - align-items: center; - font-size: var(--font-sm); - } - button.link { - background: none; - border: none; - color: var(--primary); - cursor: pointer; - font-size: var(--font-xs); - padding: 0; - } - button.link:disabled { - color: var(--text-muted); - cursor: not-allowed; - } .env dl { display: grid; grid-template-columns: max-content 1fr;