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:
@@ -533,9 +533,9 @@
|
||||
</span>
|
||||
</div>
|
||||
<p class="lede">
|
||||
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.
|
||||
</p>
|
||||
|
||||
<div class="viewtabs">
|
||||
@@ -818,37 +818,12 @@
|
||||
<p class="error">{detail.error ?? 'Analysis failed.'}</p>
|
||||
{/if}
|
||||
|
||||
{#if detail.is_nsfw || detail.content_warnings.length > 0}
|
||||
<div class="warnings" data-testid="admin-analysis-detail-warnings">
|
||||
<span class="cw-label">⚠ NSFW</span>
|
||||
{#each detail.content_warnings as w (w)}
|
||||
<span class="cw-chip">{w}</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if detail.scene_description}
|
||||
<section class="block">
|
||||
<h3>Scene</h3>
|
||||
<p>{detail.scene_description}</p>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if detail.tags.length > 0}
|
||||
<section class="block">
|
||||
<h3>Tags</h3>
|
||||
<div class="tag-row">
|
||||
{#each detail.tags as t (t)}<span class="tag">{t}</span>{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if detail.ocr.length > 0}
|
||||
<section class="block">
|
||||
<h3>OCR text</h3>
|
||||
<ul class="ocr">
|
||||
{#each detail.ocr as line, i (i)}
|
||||
<li><span class="ocr-kind">{line.kind}</span> {line.text}</li>
|
||||
<li>{#if line.kind}<span class="ocr-kind">{line.kind}</span> {/if}{line.text}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
@@ -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;
|
||||
|
||||
@@ -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<CrawlerEnvOnly | null>(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<AnalysisSettings | null>(null);
|
||||
let promptDefaults = $state<PromptDefaults | null>(null);
|
||||
let apiKeyConfigured = $state(false);
|
||||
|
||||
let loading = $state(true);
|
||||
let loadError = $state<string | null>(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;
|
||||
}
|
||||
</script>
|
||||
|
||||
<h1>Settings</h1>
|
||||
@@ -322,145 +315,29 @@
|
||||
{:else if tab === 'analysis' && analysis}
|
||||
<div class="form">
|
||||
<fieldset>
|
||||
<legend>Endpoint & model</legend>
|
||||
<label class="field">
|
||||
<span>Vision endpoint URL</span>
|
||||
<input type="text" bind:value={analysis.endpoint} />
|
||||
{#if fieldErrors.endpoint}<small class="fe">{fieldErrors.endpoint}</small>{/if}
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Model</span>
|
||||
<input type="text" bind:value={analysis.model} />
|
||||
{#if fieldErrors.model}<small class="fe">{fieldErrors.model}</small>{/if}
|
||||
</label>
|
||||
<legend>Engine</legend>
|
||||
<p class="env-note muted">
|
||||
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.
|
||||
</p>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Workers & timeouts</legend>
|
||||
<legend>Worker</legend>
|
||||
<label class="field">
|
||||
<span>Workers</span>
|
||||
<input type="number" min="1" bind:value={analysis.workers} />
|
||||
{#if fieldErrors.workers}<small class="fe">{fieldErrors.workers}</small>{/if}
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Request timeout (seconds)</span>
|
||||
<input type="number" min="1" bind:value={analysis.request_timeout_secs} />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Job timeout (seconds)</span>
|
||||
<input type="number" min="1" bind:value={analysis.job_timeout_secs} />
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Sampling & output</legend>
|
||||
<label class="field">
|
||||
<span>Temperature</span>
|
||||
<input type="number" min="0" step="0.05" bind:value={analysis.temperature} />
|
||||
{#if fieldErrors.temperature}<small class="fe"
|
||||
>{fieldErrors.temperature}</small
|
||||
>{/if}
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Frequency penalty</span>
|
||||
<input type="number" step="0.05" bind:value={analysis.frequency_penalty} />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Max output tokens</span>
|
||||
<input type="number" min="1" bind:value={analysis.max_tokens} />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Response format</span>
|
||||
<select bind:value={analysis.response_format}>
|
||||
<option value="json_schema">json_schema</option>
|
||||
<option value="json_object">json_object</option>
|
||||
<option value="none">none</option>
|
||||
</select>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Image slicing</legend>
|
||||
<label class="field">
|
||||
<span>Max pixels per slice</span>
|
||||
<input type="number" min="1" bind:value={analysis.max_pixels} />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Min slice height (px)</span>
|
||||
<input type="number" min="1" bind:value={analysis.min_slice_height} />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Slice overlap (0–0.9)</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="0.9"
|
||||
step="0.01"
|
||||
bind:value={analysis.slice_overlap}
|
||||
/>
|
||||
{#if fieldErrors.slice_overlap}<small class="fe"
|
||||
>{fieldErrors.slice_overlap}</small
|
||||
>{/if}
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Tall aspect threshold (≥1.0)</span>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
step="0.1"
|
||||
bind:value={analysis.tall_aspect_threshold}
|
||||
/>
|
||||
{#if fieldErrors.tall_aspect_threshold}<small class="fe"
|
||||
>{fieldErrors.tall_aspect_threshold}</small
|
||||
>{/if}
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Max slices</span>
|
||||
<input type="number" min="1" bind:value={analysis.max_slices} />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Max image bytes</span>
|
||||
<input type="number" min="0" bind:value={analysis.max_image_bytes} />
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Prompts</legend>
|
||||
{#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'}
|
||||
<div class="prompt">
|
||||
<div class="prompt-head">
|
||||
<span>{label}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="link"
|
||||
disabled={analysis[k] == null}
|
||||
onclick={() => resetPrompt(k)}>Reset to default</button
|
||||
>
|
||||
</div>
|
||||
<textarea
|
||||
rows="6"
|
||||
class="mono"
|
||||
value={analysis[k] ?? ''}
|
||||
placeholder={promptDefaults?.[k] ?? ''}
|
||||
oninput={(e) => {
|
||||
const v = (e.currentTarget as HTMLTextAreaElement).value;
|
||||
if (analysis) analysis[k] = v.trim() === '' ? null : v;
|
||||
}}
|
||||
></textarea>
|
||||
<small class="muted">
|
||||
{analysis[k] == null
|
||||
? 'Using compiled default (shown as placeholder).'
|
||||
: `${analysis[k]?.length ?? 0} chars (override).`}
|
||||
</small>
|
||||
</div>
|
||||
{/each}
|
||||
</fieldset>
|
||||
</div>
|
||||
{/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;
|
||||
|
||||
Reference in New Issue
Block a user