Files
pi-agent-config/scenarios/curator/backend/curator/book_reviews.py
T
Kai 35af26c794 feat(curator): vendor the backend application under scenarios/curator/backend
The curator app (Python backend, tests, systemd units, config, scripts) now lives in this repo under scenarios/curator/backend, exported from the standalone checkout's tracked tree (.pi mirror, venv and caches excluded). 149 unit tests pass from the new location; _SHARED_LIB and eval GOLDEN_DIR resolve unchanged. History not preserved per decision.

verify-no-secrets: the ASSIGN heuristic now requires the value to carry entropy (a digit or uppercase letter), so vendored Python kwargs like token=extraction_token no longer false-positive while real base64/hex/random secrets still trip it.
2026-08-30 07:54:39 -07:00

105 lines
4.4 KiB
Python

from __future__ import annotations
import hashlib
import json
import re
import unicodedata
from typing import Any
from .config import Settings
from .db import Database
from .book_pages import BookPageProvider
class BookReviewProvider:
"""Fetch attributed book metadata and ratings without making them catalog facts."""
def __init__(self, settings: Settings, database: Database):
self.settings = settings
self.database = database
@staticmethod
def _cache_key(plan: dict[str, Any]) -> str:
value = json.dumps(
{
"title": plan.get("title") or "",
"creator": plan.get("creator") or plan.get("author") or "",
"isbn": plan.get("isbn") or "",
"aliases": plan.get("aliases") or [],
},
ensure_ascii=False,
sort_keys=True,
)
return hashlib.sha256(value.encode("utf-8")).hexdigest()
def _public_pages(self, title: str, author: str, isbn: str) -> list[dict[str, Any]]:
return BookPageProvider().search(title, author, isbn, 8)
@staticmethod
def _text_key(value: str) -> str:
folded = unicodedata.normalize("NFKC", value).casefold()
return re.sub(r"[^\w]+", "", folded, flags=re.UNICODE)
@classmethod
def _matched_results(
cls,
results: list[dict[str, Any]],
title: str,
aliases: list[str],
author: str,
isbn: str = "",
) -> list[dict[str, Any]]:
title_keys = {cls._text_key(value) for value in (title, *aliases) if cls._text_key(value)}
author_key = cls._text_key(author)
isbn_key = re.sub(r"[^0-9X]", "", isbn.upper())
matched: list[dict[str, Any]] = []
for raw in results:
item = dict(raw)
result_key = cls._text_key(str(item.get("title") or ""))
if not result_key:
continue
result_isbns = {
re.sub(r"[^0-9X]", "", str(value).upper())
for value in item.get("isbns") or []
}
isbn_match = bool(isbn_key and isbn_key in result_isbns)
exact = result_key in title_keys
partial = any(len(key) >= 6 and (key in result_key or result_key in key) for key in title_keys)
if not isbn_match and not exact and not partial:
continue
author_keys = [cls._text_key(str(value)) for value in item.get("authors") or []]
author_match = bool(author_key and any(author_key in value or value in author_key for value in author_keys if value))
item["match"] = {
"title": "exact" if exact else "partial" if partial else "isbn",
"author": author_match,
"isbn": isbn_match,
"confidence": "high" if isbn_match or (exact and (author_match or not author_key)) else "medium",
}
matched.append(item)
return matched[:5]
def lookup(self, plan: dict[str, Any]) -> dict[str, Any]:
title = str(plan.get("title") or "").strip()
author = str(plan.get("creator") or plan.get("author") or "").strip()
isbn = str(plan.get("isbn") or "").replace("-", "").strip()
aliases = [str(value).strip() for value in plan.get("aliases") or [] if str(value).strip()]
result: dict[str, Any] = {"results": [], "errors": [], "providers_checked": []}
if not title and not isbn:
result["errors"].append("book-reviews: 缺少书名或 ISBN")
return result
cache_key = self._cache_key(plan)
cached = None if plan.get("refresh") else self.database.cache_get("book-reviews", cache_key)
if cached is not None:
cached["cache"] = {"hit": True, "ttl_seconds": self.settings.review_cache_ttl_seconds}
return cached
for name, loader in (("douban-goodreads-pages", self._public_pages),):
result["providers_checked"].append(name)
try:
result["results"].extend(self._matched_results(loader(title, author, isbn), title, aliases, author, isbn))
except Exception as exc:
result["errors"].append(f"{name}: {exc}")
if result["results"]:
self.database.cache_put("book-reviews", cache_key, result, self.settings.review_cache_ttl_seconds)
result["cache"] = {"hit": False, "ttl_seconds": self.settings.review_cache_ttl_seconds}
return result