Convert the ebook-search web app to async end to end and add concurrency to the protected-phrase extraction and judging pipeline so large books no longer block the event loop or the UI. ORM / infra: - Add get_async_postgres_engine and factor shared URL/connect_args building into build_postgres_url (reused by the sync and async engine builders) - Add async FastAPI session helpers (get_async_db, AsyncDbSession) with expire_on_commit=False to avoid implicit IO under asyncio App: - Use AsyncEngine/AsyncSession throughout routes, search, ingest, embeddings, answer, rerank and LLM calls; convert handlers to async - Share a single httpx.AsyncClient in app state for LLM requests; size the connection pool for concurrent phrase-judging workers - Add judge_tasks: run per-book judging as tracked background tasks so a book already being judged isn't double-queued Protected phrases: - Add a process pool (pool.py) and worker-count config (extraction/judge book/phrase workers) to parallelize candidate generation and judging - Split admin actions into all/missing variants for generation and judging Config: - Add protected_phrase_extraction_workers, phrase_judge_book_workers, phrase_judge_phrase_workers
110 lines
4.1 KiB
HTML
110 lines
4.1 KiB
HTML
{% extends "base.html" %}
|
|
|
|
{% block title %}{% if source %}{{ source.title }}{% else %}Book not found{% endif %}{% endblock %}
|
|
|
|
{% block content %}
|
|
{% if source %}
|
|
<h1>{{ source.title }}</h1>
|
|
<p class="meta">{{ source.author or "Unknown author" }}</p>
|
|
{% if phrase_status_message %}
|
|
<p class="status">{{ phrase_status_message }}</p>
|
|
{% endif %}
|
|
<dl class="card">
|
|
<dt>File</dt>
|
|
<dd>{{ source.file_path }}</dd>
|
|
<dt>Chapters</dt>
|
|
<dd>{{ chapter_count }}</dd>
|
|
<dt>Chunks</dt>
|
|
<dd>{{ chunk_count }}</dd>
|
|
<dt>Candidates</dt>
|
|
<dd>{{ candidate_count }}</dd>
|
|
<dt>Judged</dt>
|
|
<dd>{{ judged_candidate_count }}</dd>
|
|
<dt>Protected</dt>
|
|
<dd>{{ protected_count }}</dd>
|
|
</dl>
|
|
<form
|
|
method="post"
|
|
action="/books/{{ source.id }}/recalculate-phrases"
|
|
onsubmit="return confirm('Remove old phrases for this book and generate new candidates?');"
|
|
>
|
|
<button type="submit">Recalculate phrases</button>
|
|
</form>
|
|
<form
|
|
method="post"
|
|
action="/books/{{ source.id }}/judge-phrases"
|
|
onsubmit="return confirm('Judge candidate phrases for this book with the LLM?');"
|
|
>
|
|
<button type="submit"{% if judging_in_progress %} disabled{% endif %}>
|
|
{% if judging_in_progress %}Judging…{% else %}Judge phrases{% endif %}
|
|
</button>
|
|
</form>
|
|
|
|
<section>
|
|
<h2>Candidate n-grams</h2>
|
|
{% if candidates %}
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Phrase</th>
|
|
<th>Status</th>
|
|
<th>Score</th>
|
|
<th>Count</th>
|
|
<th>Chapters</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{% for candidate in candidates %}
|
|
<tr>
|
|
<td>{{ candidate.phrase_text }}</td>
|
|
<td>
|
|
{% if candidate.llm_judged %}
|
|
{% if candidate.llm_keep %}Kept{% else %}Rejected{% endif %}
|
|
{% else %}
|
|
Candidate
|
|
{% endif %}
|
|
</td>
|
|
<td>{{ "%.2f"|format(candidate.candidate_score) }}</td>
|
|
<td>{{ candidate.raw_count }}</td>
|
|
<td>{{ candidate.chapter_count }}</td>
|
|
</tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
{% else %}
|
|
<p>No candidate n-grams.</p>
|
|
{% endif %}
|
|
</section>
|
|
|
|
<section>
|
|
<h2>Protected phrases</h2>
|
|
{% if protected_phrases %}
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Phrase</th>
|
|
<th>Type</th>
|
|
<th>Confidence</th>
|
|
<th>Importance</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{% for phrase in protected_phrases %}
|
|
<tr>
|
|
<td>{{ phrase.phrase_text }}</td>
|
|
<td>{{ phrase.phrase_type or "phrase" }}</td>
|
|
<td>{{ "%.2f"|format(phrase.confidence) }}</td>
|
|
<td>{{ "%.2f"|format(phrase.importance) }}</td>
|
|
</tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
{% else %}
|
|
<p>No protected phrases.</p>
|
|
{% endif %}
|
|
</section>
|
|
{% else %}
|
|
<h1>Book not found</h1>
|
|
{% endif %}
|
|
{% endblock %}
|