- Introduced dataclasses for phrase candidates, judgments, and matches in `models.py`. - Implemented database operations for candidate and protected phrases in `store.py`, including loading, saving, and deleting phrases. - Enhanced text normalization functions in `text_normalization.py` with detailed docstrings. - Refactored search functionality to utilize new models and methods for detecting protected phrases.
162 lines
6.0 KiB
Python
162 lines
6.0 KiB
Python
"""Page routes for the EPUB search web UI."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
|
|
from fastapi import APIRouter, HTTPException, Request
|
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
|
from sqlalchemy import func, select
|
|
|
|
from python.ebook_search.api.dependencies import (
|
|
AppConfig, # noqa: TC001 FastAPI resolves this annotated dependency at runtime
|
|
)
|
|
from python.ebook_search.api.web import templates
|
|
from python.ebook_search.protected_phrases.generate_ngrams import recalculate_candidate_phrases_for_book
|
|
from python.fastapi_tools import DbSession # noqa: TC001 FastAPI resolves this annotated dependency at runtime
|
|
from python.orm.richie import EbookCandidatePhrase, EbookProtectedPhrase, EbookSource
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
@router.get("/", response_class=HTMLResponse)
|
|
def index(request: Request, config: AppConfig) -> HTMLResponse:
|
|
"""Render the search page."""
|
|
return templates.TemplateResponse(request, "search.html", {"config": config})
|
|
|
|
|
|
@router.get("/books", response_class=HTMLResponse)
|
|
def books(request: Request, session: DbSession) -> HTMLResponse:
|
|
"""Render the indexed books page."""
|
|
sources = list(session.scalars(select(EbookSource).order_by(EbookSource.title)).all())
|
|
logger.info("ebook_books_page_loaded count=%s", len(sources))
|
|
return templates.TemplateResponse(request, "books.html", {"sources": sources})
|
|
|
|
|
|
def get_candidate_count(session: DbSession, book_id: int) -> int:
|
|
"""Return the number of indexed candidates for one book."""
|
|
return (
|
|
session.scalar(select(func.count(EbookCandidatePhrase.id)).where(EbookCandidatePhrase.book_id == book_id)) or 0
|
|
)
|
|
|
|
|
|
def get_judged_candidate_count(session: DbSession, book_id: int) -> int:
|
|
"""Return the number of judged candidates for one book."""
|
|
return (
|
|
session.scalar(
|
|
select(func.count(EbookCandidatePhrase.id)).where(
|
|
EbookCandidatePhrase.book_id == book_id,
|
|
EbookCandidatePhrase.llm_judged.is_(True),
|
|
)
|
|
)
|
|
or 0
|
|
)
|
|
|
|
|
|
def get_protected_count(session: DbSession, book_id: int) -> int:
|
|
"""Return the number of protected phrases for one book."""
|
|
return (
|
|
session.scalar(select(func.count(EbookProtectedPhrase.id)).where(EbookProtectedPhrase.book_id == book_id)) or 0
|
|
)
|
|
|
|
|
|
def get_candidates(session: DbSession, book_id: int) -> list[EbookCandidatePhrase]:
|
|
"""Return the indexed candidates for one book."""
|
|
return list(
|
|
session.scalars(
|
|
select(EbookCandidatePhrase)
|
|
.where(EbookCandidatePhrase.book_id == book_id)
|
|
.order_by(EbookCandidatePhrase.candidate_score.desc())
|
|
.limit(100)
|
|
)
|
|
)
|
|
|
|
|
|
def get_protected_phrases(session: DbSession, book_id: int) -> list[EbookProtectedPhrase]:
|
|
"""Return the protected phrases for one book."""
|
|
return list(
|
|
session.scalars(
|
|
select(EbookProtectedPhrase)
|
|
.where(EbookProtectedPhrase.book_id == book_id)
|
|
.order_by(EbookProtectedPhrase.importance.desc())
|
|
.limit(100)
|
|
)
|
|
)
|
|
|
|
|
|
@router.get("/books/{source_id}", response_class=HTMLResponse)
|
|
def book_detail(source_id: int, request: Request, session: DbSession) -> HTMLResponse:
|
|
"""Render details for one indexed book."""
|
|
source = session.get(EbookSource, source_id)
|
|
phrase_status_message = None
|
|
recalculated = request.query_params.get("phrases_recalculated")
|
|
if recalculated is not None:
|
|
phrase_status_message = f"Recalculated phrases; {recalculated} candidates generated"
|
|
if source is not None:
|
|
chapter_count = len(source.chapters)
|
|
chunk_count = len(source.chunks)
|
|
candidate_count = get_candidate_count(session, source.id)
|
|
judged_candidate_count = get_judged_candidate_count(session, source.id)
|
|
protected_count = get_protected_count(session, source.id)
|
|
candidates = get_candidates(session, source.id)
|
|
protected_phrases = get_protected_phrases(session, source.id)
|
|
else:
|
|
chapter_count = 0
|
|
chunk_count = 0
|
|
candidate_count = 0
|
|
judged_candidate_count = 0
|
|
protected_count = 0
|
|
candidates = []
|
|
protected_phrases = []
|
|
logger.info(
|
|
"ebook_book_detail_loaded source_id=%s found=%s chapters=%s chunks=%s candidates=%s judged=%s protected=%s",
|
|
source_id,
|
|
source is not None,
|
|
chapter_count,
|
|
chunk_count,
|
|
candidate_count,
|
|
judged_candidate_count,
|
|
protected_count,
|
|
)
|
|
return templates.TemplateResponse(
|
|
request,
|
|
"book_detail.html",
|
|
{
|
|
"candidate_count": candidate_count,
|
|
"candidates": candidates,
|
|
"chapter_count": chapter_count,
|
|
"chunk_count": chunk_count,
|
|
"judged_candidate_count": judged_candidate_count,
|
|
"protected_count": protected_count,
|
|
"protected_phrases": protected_phrases,
|
|
"phrase_status_message": phrase_status_message,
|
|
"source": source,
|
|
},
|
|
)
|
|
|
|
|
|
@router.post("/books/{source_id}/recalculate-phrases")
|
|
def recalculate_book_phrases(source_id: int, config: AppConfig, session: DbSession) -> RedirectResponse:
|
|
"""Clear and regenerate candidate phrases for one indexed book."""
|
|
source = session.get(EbookSource, source_id)
|
|
if source is None:
|
|
raise HTTPException(status_code=404, detail="Book not found")
|
|
|
|
result = recalculate_candidate_phrases_for_book(session, source, config)
|
|
logger.info(
|
|
"ebook_book_phrase_recalculation_complete source_id=%s candidates=%s deleted_candidates=%s "
|
|
"deleted_protected=%s deleted_aliases=%s deleted_mentions=%s",
|
|
source_id,
|
|
result.candidate_phrases,
|
|
result.deleted_candidates,
|
|
result.deleted_protected_phrases,
|
|
result.deleted_aliases,
|
|
result.deleted_mentions,
|
|
)
|
|
return RedirectResponse(
|
|
url=f"/books/{source_id}?phrases_recalculated={result.candidate_phrases}",
|
|
status_code=303,
|
|
)
|