feat(analysis): add ocrs OCR backend and OCR-driven page text search
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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user