Arag / app /services /book_service.py
AuthorBot
fix: heal chatbot_is_active on valid subscription (403 on session/init); add Contact Us form and SuperAdmin inbox
8c0bfef
Raw
History Blame Contribute Delete
18.5 kB
"""Author RAG — Book Service.
Business logic for admin book management: CRUD, covers, URL import.
Delegates DB access to BookRepository and PlatformListingRepository.
"""
import io
import os
import structlog
from fastapi import HTTPException, UploadFile
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.book import Book
from app.models.platform_listing import BookPlatformListing
from app.models.base import generate_uuid
from app.repositories.book_repo import BookRepository
from app.repositories.platform_listing_repo import PlatformListingRepository
from app.services.book_url_scraper import (
PLATFORM_DISPLAY,
fetch_book_metadata,
)
from app.services.embeddings import delete_book_embeddings
from app.services.platform_presence import (
PLATFORM_BENEFITS,
PLATFORM_ICONS,
SCORED_PLATFORMS,
PlatformListing,
compute_score,
count_missing_listings,
load_scan_from_redis,
platform_presence_to_dict,
presence_dict_from_db_listings,
save_scan_to_redis,
scan_platform_presence,
)
logger = structlog.get_logger(__name__)
def _platform_stats_from_listings(listings: list[BookPlatformListing]) -> dict:
"""Compute missing_count and platform_score from listing rows."""
if not listings:
return {"missing_count": 0, "platform_score": 0.0}
cfg_by_id = {p["platform_id"]: p for p in SCORED_PLATFORMS}
platforms: list[PlatformListing] = []
for row in listings:
pid = row.platform_id
cfg = cfg_by_id.get(pid, {})
platforms.append(PlatformListing(
platform_id=pid,
display_name=cfg.get("display_name", pid.replace("_", " ").title()),
status=row.status,
confidence=row.confidence,
listing_url=row.listing_url,
benefit=PLATFORM_BENEFITS.get(pid, ""),
weight=cfg.get("weight", 0.5),
icon=PLATFORM_ICONS.get(pid, "🔗"),
price=row.list_price,
price_currency=row.price_currency,
))
return {
"missing_count": count_missing_listings(listings),
"platform_score": compute_score(platforms),
}
def _book_to_dict(b: Book, listings: list[BookPlatformListing]) -> dict:
"""Serialize a book with platform stats for list response."""
return {
"id": b.id,
"title": b.title,
"genre": b.genre,
"status": b.status,
"chunk_count": getattr(b, "chunk_count", 0),
"ai_summary": getattr(b, "ai_summary", ""),
"cover_path": getattr(b, "cover_path", None),
"cover_url": getattr(b, "cover_url", None),
"cover_thumbnail": getattr(b, "cover_thumbnail_path", None),
"cover_medium": getattr(b, "cover_medium_path", None),
"buy_url": getattr(b, "buy_url", None),
"isbn": getattr(b, "isbn", None),
"book_author": getattr(b, "book_author", None),
"source_url": getattr(b, "source_url", None),
"publish_year": getattr(b, "publish_year", None),
"is_active": getattr(b, "is_active", True),
"display_order": getattr(b, "display_order", 0),
"error_message": getattr(b, "error_message", None),
**_platform_stats_from_listings(listings),
}
def _build_corpus_from_meta(
title: str,
author: str,
genre: str,
description: str,
isbn: str | None,
cover_url: str | None,
url: str,
meta,
) -> str:
"""Build RAG corpus text from scraped book metadata."""
parts = [f"Book Title: {title}"]
if author:
parts.append(f"Author: {author}")
if genre:
parts.append(f"Genre: {genre}")
if meta.publisher:
parts.append(f"Publisher: {meta.publisher}")
if meta.publish_year:
parts.append(f"Publication Year: {meta.publish_year}")
if meta.page_count:
parts.append(f"Pages: {meta.page_count}")
if meta.language:
parts.append(f"Language: {meta.language}")
if isbn:
parts.append(f"ISBN: {isbn}")
if meta.rating:
rating_line = f"Reader Rating: {meta.rating}/5"
if meta.rating_count:
rating_line += f" based on {meta.rating_count} reviews"
parts.append(rating_line)
if description:
parts.append(f"\nAbout the Book:\n{description}")
if meta.about_author:
parts.append(f"\nAbout the Author:\n{meta.about_author}")
if url:
parts.append(f"\nBook URL: {url}")
if cover_url:
parts.append(f"Cover Image: {cover_url}")
return "\n".join(parts)
def _build_corpus_from_book(book: Book) -> str:
"""Rebuild RAG corpus text from stored book fields (for re-indexing)."""
parts = [f"Book Title: {book.title}"]
if book.book_author:
parts.append(f"Author: {book.book_author}")
if book.genre:
parts.append(f"Genre: {book.genre}")
if book.publish_year:
parts.append(f"Publication Year: {book.publish_year}")
if book.language:
parts.append(f"Language: {book.language}")
if book.isbn:
parts.append(f"ISBN: {book.isbn}")
if book.rating:
rating_line = f"Reader Rating: {book.rating}/5"
if book.rating_count:
rating_line += f" based on {book.rating_count} reviews"
parts.append(rating_line)
if book.description:
parts.append(f"\nAbout the Book:\n{book.description}")
if book.about_author:
parts.append(f"\nAbout the Author:\n{book.about_author}")
if book.source_url:
parts.append(f"\nBook URL: {book.source_url}")
if book.cover_url:
parts.append(f"Cover Image: {book.cover_url}")
return "\n".join(parts)
class BookService:
"""Orchestrates book management for admin panel."""
def __init__(self, db: AsyncSession) -> None:
self._db = db
self._books = BookRepository(db)
self._listings = PlatformListingRepository(db)
async def list_books(self, author_id: str) -> dict:
"""List all books with platform presence stats."""
books = await self._books.list_for_author(author_id)
listings_map = await self._listings.list_for_books(author_id, [b.id for b in books])
return {
"books": [
_book_to_dict(b, listings_map.get(b.id, []))
for b in books
],
}
async def get_platform_presence(self, author_id: str, book_id: str) -> dict | None:
"""Return stored platform presence scan. None if book not found."""
book = await self._books.get_by_id(book_id)
if not book or book.author_id != author_id:
return None
listings = await self._listings.list_for_book(book_id, author_id)
if not listings:
return {"platform_presence": None, "book": self._book_meta(book)}
return {
"platform_presence": presence_dict_from_db_listings(listings, source_isbn=book.isbn),
"book": self._book_meta(book),
}
@staticmethod
def _book_meta(book: Book) -> dict:
return {
"id": book.id,
"title": book.title,
"isbn": book.isbn,
"book_author": book.book_author,
"source_url": book.source_url,
}
async def delete_book(self, author_id: str, book_id: str) -> dict | None:
"""Delete a book and its ChromaDB collection.
Returns None if not found, else dict with chatbot_disabled flag.
"""
book = await self._books.get_by_id(book_id)
if not book or book.author_id != author_id:
return None
active_count = await self._books.count_active(author_id)
was_last_active = book.is_active and active_count <= 1
delete_book_embeddings(author_id, book_id)
await self._db.delete(book)
await self._db.commit()
return {
"message": "Book deleted",
"chatbot_disabled": was_last_active,
"note": (
"No active books remain — add or activate a book for readers to chat."
if was_last_active else None
),
}
async def set_book_active(self, author_id: str, book_id: str, is_active: bool) -> dict | None:
"""Activate or deactivate a book for RAG retrieval."""
book = await self._books.get_by_id(book_id)
if not book or book.author_id != author_id:
return None
book.is_active = is_active
await self._db.commit()
return {"book_id": book_id, "is_active": is_active}
async def reorder_books(self, author_id: str, ordered_ids: list[str]) -> dict:
"""Update widget display order for books."""
await self._books.update_display_order(author_id, ordered_ids)
await self._db.commit()
return {"message": "Book order updated", "count": len(ordered_ids)}
async def upload_cover(
self, author_id: str, book_id: str, file: UploadFile,
) -> dict | None:
"""Upload and process a book cover image. None if book not found."""
book = await self._books.get_by_id(book_id)
if not book or book.author_id != author_id:
return None
allowed_types = {"image/jpeg", "image/png", "image/webp"}
if file.content_type not in allowed_types:
raise HTTPException(400, "Invalid file type. Allowed: JPG, PNG, WebP")
contents = await file.read()
if len(contents) > 5 * 1024 * 1024:
raise HTTPException(400, "File too large. Maximum: 5MB")
if contents[:2] not in (b'\xff\xd8', b'\x89P') and contents[:4] != b'RIFF':
raise HTTPException(400, "File does not appear to be a valid image")
cover_dir = f"/data/covers/{author_id}/{book_id}"
os.makedirs(cover_dir, exist_ok=True)
ext = file.filename.rsplit('.', 1)[-1].lower() if file.filename else 'jpg'
if ext not in ('jpg', 'jpeg', 'png', 'webp'):
ext = 'jpg'
original_path = f"{cover_dir}/original.{ext}"
with open(original_path, "wb") as f:
f.write(contents)
thumbnail_path = original_path
medium_path = original_path
try:
from PIL import Image
img = Image.open(io.BytesIO(contents)).convert('RGB')
thumb = img.copy()
thumb.thumbnail((80, 120), Image.LANCZOS)
thumbnail_path = f"{cover_dir}/thumb.webp"
thumb.save(thumbnail_path, 'WEBP', quality=80)
med = img.copy()
med.thumbnail((300, 450), Image.LANCZOS)
medium_path = f"{cover_dir}/medium.webp"
med.save(medium_path, 'WEBP', quality=85)
except ImportError:
pass
book.cover_path = original_path
book.cover_thumbnail_path = thumbnail_path
book.cover_medium_path = medium_path
await self._db.commit()
return {
"message": "Cover uploaded",
"cover_path": original_path,
"cover_thumbnail": thumbnail_path,
"cover_medium": medium_path,
}
@staticmethod
async def preview_url_import(
url: str, redis, force_refresh: bool = False,
) -> dict:
"""Fetch and preview book metadata from a platform URL."""
meta = await fetch_book_metadata(url, redis=redis, force_refresh=force_refresh)
platform_presence = None
if meta.title:
scan = await scan_platform_presence(
title=meta.title,
author=meta.author or "",
isbn=meta.isbn13 or meta.isbn or None,
source_platform=meta.platform if meta.platform != "unknown" else None,
source_url=url,
buy_url=meta.buy_url or url,
redis=redis,
)
await save_scan_to_redis(redis, url, scan)
platform_presence = platform_presence_to_dict(scan)
platform_display = PLATFORM_DISPLAY.get(
meta.platform, meta.platform.replace("_", " ").title(),
)
return {
"platform": meta.platform,
"platform_display": platform_display,
"title": meta.title,
"author": meta.author,
"about_author": meta.about_author,
"description": meta.description,
"cover_url": meta.cover_url,
"isbn": meta.isbn,
"isbn10": meta.isbn10,
"isbn13": meta.isbn13,
"asin": meta.asin,
"publisher": meta.publisher,
"publish_year": meta.publish_year,
"edition": meta.edition,
"format": meta.book_format,
"series": meta.series,
"language": meta.language,
"page_count": meta.page_count,
"price": meta.price,
"genre": meta.genre,
"rating": meta.rating,
"rating_count": meta.rating_count,
"buy_url": meta.buy_url or url,
"source_url": url,
"confidence": meta.confidence,
"error": meta.error or None,
"warnings": meta.warnings or [],
"supported_platforms": list(PLATFORM_DISPLAY.values()),
"platform_presence": platform_presence,
}
async def import_from_url(
self,
author_id: str,
url: str,
title_override: str,
author_override: str,
description_override: str,
cover_url_override: str,
isbn_override: str,
genre_override: str,
redis,
) -> dict:
"""Create a book from URL metadata and start background indexing."""
meta = await fetch_book_metadata(url, redis=redis)
title = (title_override.strip() or meta.title or "").strip()
author = (author_override.strip() or meta.author or "").strip()
genre = (genre_override.strip() or meta.genre or "").strip()
description = (description_override.strip() or meta.description or "").strip()
cover_url = (cover_url_override.strip() or meta.cover_url or None)
isbn = (isbn_override.strip() or meta.isbn or None)
if not title:
raise HTTPException(400, "Could not determine book title. Please enter it manually.")
book = Book(
id=generate_uuid(),
author_id=author_id,
title=title[:500],
tagline=None,
genre=(genre[:300] if genre else None),
description=description or None,
cover_path=None,
cover_url=(cover_url[:1000] if cover_url else None),
ai_summary=None,
status="processing",
buy_url=meta.buy_url or url,
source_url=url,
isbn=isbn,
book_author=author or None,
about_author=(meta.about_author or None),
language=(meta.language or None),
page_count=(meta.page_count or 0),
publish_year=(meta.publish_year or None),
rating=(meta.rating or None),
rating_count=(meta.rating_count or None),
)
self._db.add(book)
await self._db.commit()
await self._db.refresh(book)
scan = await load_scan_from_redis(redis, url)
if scan is None and title:
scan = await scan_platform_presence(
title=title,
author=author or "",
isbn=isbn,
source_platform=meta.platform if meta.platform != "unknown" else None,
source_url=url,
buy_url=meta.buy_url or url,
db=self._db,
redis=redis,
)
if scan is not None:
await self._listings.replace_from_scan(author_id, book.id, scan)
await self._db.commit()
corpus_text = _build_corpus_from_meta(
title, author, genre, description, isbn, cover_url, url, meta,
)
self._schedule_text_ingestion(corpus_text, book.id, author_id, title, redis)
return {
"message": "Book imported and indexing started",
"book_id": book.id,
"title": book.title,
"platform": PLATFORM_DISPLAY.get(meta.platform, meta.platform),
"cover_url": cover_url,
"status": "processing",
"next_steps": "Book is being indexed from scraped metadata. It will be ready in seconds.",
}
@staticmethod
def _schedule_text_ingestion(
text: str, book_id: str, author_id: str, title: str, redis,
) -> None:
"""Dispatch text ingestion via Celery (with asyncio fallback)."""
from app.services.ingestion_service import schedule_text_ingestion
schedule_text_ingestion(text, book_id, author_id, title, redis)
async def reindex_book(self, author_id: str, book_id: str, redis) -> dict | None:
"""Re-run indexing for a failed or stuck book."""
import os
from app.repositories.document_repo import DocumentRepository
from app.services.ingestion_service import schedule_file_ingestion, schedule_text_ingestion
book = await self._books.get_by_id(book_id)
if not book or book.author_id != author_id:
return None
await self._books.update_status(book_id, author_id, "processing", error="")
await self._db.commit()
docs = await DocumentRepository(self._db).list_for_book(book_id, author_id)
if docs and os.path.exists(docs[0].storage_path):
doc = docs[0]
schedule_file_ingestion(
file_path=doc.storage_path,
book_id=book_id,
author_id=author_id,
title=book.title,
redis=redis,
doc_id=doc.id,
file_extension=doc.file_extension,
)
else:
corpus = _build_corpus_from_book(book)
if not corpus.strip() or len(corpus.strip()) < 20:
await self._books.update_status(
book_id, author_id, "error",
error="Not enough content to index. Upload a PDF/EPUB or re-import from URL.",
)
await self._db.commit()
return {"book_id": book_id, "status": "error", "message": "Not enough content to index."}
schedule_text_ingestion(corpus, book_id, author_id, book.title, redis)
return {"book_id": book_id, "status": "processing", "message": "Re-indexing started"}