feat(analysis): add ocrs OCR backend and OCR-driven page text search
All checks were successful
deploy / test-backend (push) Successful in 28m40s
deploy / test-frontend (push) Successful in 10m36s
deploy / build-and-push (push) Successful in 11m8s
deploy / deploy (push) Successful in 12s

Adds an in-process ocrs OCR backend as the active analysis engine
(vision left dormant), enables OCR text search on the page-tag
aggregation endpoints, and reshapes the admin analysis/settings UI to
present the OCR-only surface. Bumps version 0.89.0 -> 0.90.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-30 19:52:43 +02:00
parent 4fb98e4a1e
commit 83c2899373
24 changed files with 1335 additions and 443 deletions

View File

@@ -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<string | null>(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 @@
<option value="done">done</option>
<option value="failed">failed</option>
</select>
<label class="nsfw">
<input
type="checkbox"
bind:checked={nsfwOnly}
onchange={applyFilters}
data-testid="analysis-history-nsfw"
/>
NSFW only
</label>
<input
class="search"
type="text"
@@ -136,7 +125,6 @@
<td><span class="status-pill {r.status}">{r.status}</span></td>
<td>
{r.manga_title} · Ch {r.chapter_number} · p{r.page_number}
{#if r.is_nsfw}<span class="nsfw-tag">⚠ NSFW</span>{/if}
</td>
<td class="muted">{r.model ?? '—'}</td>
<td class="num">{fmtDuration(r.duration_ms)}</td>
@@ -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);
}

View File

@@ -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<string, unknown> = {}) {
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<typeof row>[]) {
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<string, unknown>;
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();
});
});

View File

@@ -105,30 +105,6 @@
<span class="value">{metrics.failed}</span>
</div>
</div>
<h2>By model</h2>
{#if metrics.by_model.length === 0}
<p class="muted">No completed analyses in this window.</p>
{:else}
<table>
<thead>
<tr>
<th>Model</th>
<th class="num">Avg</th>
<th class="num">N</th>
</tr>
</thead>
<tbody>
{#each metrics.by_model as m (m.model)}
<tr>
<td>{m.model ?? '(unknown)'}</td>
<td class="num">{fmtDuration(m.avg_ms)}</td>
<td class="num">{m.n}</td>
</tr>
{/each}
</tbody>
</table>
{/if}
{/if}
</section>
@@ -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);
}

View File

@@ -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();
});
});