Spaces:
Running
Running
AuthorBot
fix: heal chatbot_is_active on valid subscription (403 on session/init); add Contact Us form and SuperAdmin inbox
8c0bfef | """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), | |
| } | |
| 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, | |
| } | |
| 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.", | |
| } | |
| 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"} | |