diff --git a/python/ebook_search/protected_phrases/judge_ngrams.py b/python/ebook_search/protected_phrases/judge_ngrams.py index b0b49e6..8afbe3f 100644 --- a/python/ebook_search/protected_phrases/judge_ngrams.py +++ b/python/ebook_search/protected_phrases/judge_ngrams.py @@ -5,7 +5,7 @@ from __future__ import annotations import asyncio import json import logging -import re +from dataclasses import replace from time import perf_counter from typing import TYPE_CHECKING @@ -45,9 +45,6 @@ if TYPE_CHECKING: from python.ebook_search.protected_phrases.models import PhraseCandidate from python.orm.richie import EbookProtectedPhrase -JSON_OBJECT_RE = re.compile(r"\{.*\}", re.DOTALL) - - logger = logging.getLogger(__name__) @@ -262,7 +259,9 @@ async def judge_candidate_async( Returns: LLMJudgment: The parsed judgment. """ - content = await request_chat_completion(client, config, build_judge_messages(candidate)) + content = await request_chat_completion( + client, config, build_judge_messages(candidate), response_format={"type": "json_object"} + ) return parse_llm_judgment(content, config) @@ -287,12 +286,23 @@ async def persist_book_judgments( book_started_at = perf_counter() async with AsyncSession(engine, expire_on_commit=False) as session: try: + normalized_book_text = normalize_text(await load_book_text(session, source_id)) protected: list[EbookProtectedPhrase] = [] for candidate_id, candidate, judgment, promote in judged: - candidate_row = await save_candidate_to_db(session, source_id, None, candidate, judgment=judgment) + filtered_judgment = replace( + judgment, + aliases=tuple( + alias for alias in judgment.aliases if alias_occurs_in_book(alias, normalized_book_text) + ), + ) + candidate_row = await save_candidate_to_db( + session, source_id, None, candidate, judgment=filtered_judgment + ) if promote: protected.append( - await upsert_protected_phrase(session, source_id, None, candidate, judgment, candidate_row) + await upsert_protected_phrase( + session, source_id, None, candidate, filtered_judgment, candidate_row + ) ) logger.info( f"ebook_candidate_phrase_judgment_candidate_complete {source_id=} {candidate_id=} " @@ -395,14 +405,14 @@ def parse_llm_judgment(content: str, config: EbookSearchConfig) -> LLMJudgment: if not isinstance(aliases, list | tuple): aliases = () return LLMJudgment( - keep=bool(body.get("keep", False)), + keep=strict_bool(body.get("keep"), default=False), canonical=optional_text(body.get("canonical")), category=optional_text(body.get("category")), aliases=tuple(str(alias) for alias in aliases if isinstance(alias, str) and alias.strip()), confidence=clamped_float(body.get("confidence"), default=0.0), importance=clamped_float(body.get("importance"), default=0.5), - allow_nested=bool(body.get("allow_nested", config.phrase_default_allow_nested)), - suppress_children=bool(body.get("suppress_children", config.phrase_default_suppress_children)), + allow_nested=strict_bool(body.get("allow_nested"), default=config.phrase_default_allow_nested), + suppress_children=strict_bool(body.get("suppress_children"), default=config.phrase_default_suppress_children), reason=optional_text(body.get("reason")), ) @@ -419,14 +429,13 @@ def extract_json_object(content: str) -> str: Raises: ValueError: If no JSON object is found in the response. """ - stripped = content.strip() - if stripped.startswith("{") and stripped.endswith("}"): - return stripped - match = JSON_OBJECT_RE.search(stripped) - if match is None: - msg = "LLM phrase judge response did not contain a JSON object" - raise ValueError(msg) - return match.group(0) + return content.strip() + + +def alias_occurs_in_book(alias: str, normalized_book_text: str) -> bool: + """Return whether a normalized alias occurs as a complete phrase in the source book.""" + alias_norm = normalize_text(alias) + return bool(alias_norm) and f" {alias_norm} " in f" {normalized_book_text} " def optional_text(value: object) -> str | None: @@ -444,6 +453,11 @@ def optional_text(value: object) -> str | None: return stripped or None +def strict_bool(value: object, *, default: bool) -> bool: + """Return a JSON boolean, falling back when the value has another type.""" + return value if isinstance(value, bool) else default + + def clamped_float(value: object, *, default: float) -> float: """Coerce a JSON number into the 0.0 to 1.0 range.