Spaces:
Running
Running
AuthorBot commited on
Commit Β·
772f852
1
Parent(s): ebc0310
Restructure project for HF Spaces deployment
Browse filesThis view is limited to 50 files because it contains too many changes. Β See raw diff
- backend/.env.example β .env.example +0 -0
- app.py +12 -0
- app/__init__.py +1 -0
- app/admin/__init__.py +1 -0
- app/admin/router.py +223 -0
- app/admin/templates/admin.html +367 -0
- app/api/__init__.py +1 -0
- backend/app/api/v1/chatbot.py β app/api/chat.py +95 -44
- app/api/ingest.py +164 -0
- app/api/schemas_router.py +64 -0
- app/api/widget.py +47 -0
- {backend/app β app}/config.py +0 -0
- app/core/__init__.py +1 -0
- {backend/alembic/versions β app/core/access}/__init__.py +0 -0
- {backend/app β app}/core/access/subscription.py +0 -0
- {backend/app β app}/core/access/token_crypto.py +0 -0
- {backend/app β app}/core/access/totp.py +0 -0
- {backend/app β app}/core/chroma_client.py +0 -0
- {backend/app/core/security β app/core}/password.py +0 -0
- app/core/security.py +113 -0
- {backend/app/core/security β app/core}/subscription_crypto.py +0 -0
- {backend/app β app}/dependencies.py +0 -0
- {backend/app β app/exceptions}/__init__.py +0 -0
- {backend/app β app}/exceptions/access.py +0 -0
- {backend/app β app}/exceptions/auth.py +0 -0
- {backend/app β app}/exceptions/base.py +0 -0
- {backend/app β app}/exceptions/ingestion.py +0 -0
- {backend/app β app}/exceptions/rag.py +0 -0
- {backend/app β app}/main.py +101 -83
- {backend/app/api β app/middleware}/__init__.py +0 -0
- {backend/app β app}/middleware/logging_middleware.py +0 -0
- {backend/app β app}/middleware/rate_limit_middleware.py +0 -0
- app/models/__init__.py +14 -0
- {backend/app β app}/models/analytics.py +0 -0
- {backend/app β app}/models/base.py +0 -0
- {backend/app β app}/models/book.py +0 -0
- {backend/app β app}/models/chat_session.py +0 -0
- {backend/app β app}/models/client_access.py +0 -0
- {backend/app β app}/models/document.py +0 -0
- {backend/app β app}/models/link.py +0 -0
- {backend/app β app}/models/user.py +0 -0
- {backend/app/api/v1 β app/repositories}/__init__.py +0 -0
- {backend/app β app}/repositories/access_repo.py +0 -0
- {backend/app β app}/repositories/audit_repo.py +0 -0
- {backend/app β app}/repositories/base.py +0 -0
- {backend/app β app}/repositories/book_repo.py +0 -0
- {backend/app β app}/repositories/document_repo.py +0 -0
- {backend/app β app}/repositories/link_repo.py +0 -0
- {backend/app β app}/repositories/user_repo.py +0 -0
- {backend/app/core β app/schemas}/__init__.py +0 -0
backend/.env.example β .env.example
RENAMED
|
File without changes
|
app.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Author RAG β Application Entry Point.
|
| 2 |
+
|
| 3 |
+
Usage:
|
| 4 |
+
uvicorn app.main:app --reload # Development
|
| 5 |
+
uvicorn app.main:app --host 0.0.0.0 --port 7860 # HF Spaces
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from app.main import app # noqa: F401
|
| 9 |
+
|
| 10 |
+
if __name__ == "__main__":
|
| 11 |
+
import uvicorn
|
| 12 |
+
uvicorn.run("app.main:app", host="0.0.0.0", port=7860, reload=True)
|
app/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# Author RAG β Application Package
|
app/admin/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# Admin package
|
app/admin/router.py
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Author RAG β Per-Author Admin API Router.
|
| 2 |
+
|
| 3 |
+
All routes require a valid Author JWT (Bearer token).
|
| 4 |
+
Routes live at: /api/admin/{author_slug}/*
|
| 5 |
+
|
| 6 |
+
Per implementation plan:
|
| 7 |
+
GET /api/admin/{slug}/dashboard β Stats summary
|
| 8 |
+
GET /api/admin/{slug}/sessions β Reader session list
|
| 9 |
+
GET /api/admin/{slug}/sessions/{id} β Session detail + transcript
|
| 10 |
+
POST /api/admin/{slug}/sessions/{id}/block
|
| 11 |
+
POST /api/admin/{slug}/sessions/{id}/unblock
|
| 12 |
+
POST /api/admin/{slug}/sessions/{id}/reply β Live agent takeover
|
| 13 |
+
GET /api/admin/{slug}/books β Book list
|
| 14 |
+
POST /api/admin/{slug}/books/{id}/delete
|
| 15 |
+
GET /api/admin/{slug}/analytics β Charts data
|
| 16 |
+
GET /api/admin/{slug}/export/chats β Export Excel/JSON
|
| 17 |
+
GET /api/admin/{slug}/widget-config β Get widget settings
|
| 18 |
+
PUT /api/admin/{slug}/widget-config β Update widget settings
|
| 19 |
+
GET /api/admin/{slug}/smart-links β Smart links (buy/preview URLs)
|
| 20 |
+
PUT /api/admin/{slug}/smart-links/{book_id}
|
| 21 |
+
POST /api/admin/{slug}/password β Change password
|
| 22 |
+
"""
|
| 23 |
+
|
| 24 |
+
from fastapi import APIRouter, Depends, Query
|
| 25 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 26 |
+
|
| 27 |
+
from app.dependencies import get_db, get_current_user, get_redis
|
| 28 |
+
from app.repositories.book_repo import BookRepository
|
| 29 |
+
from app.repositories.audit_repo import AuditRepository
|
| 30 |
+
|
| 31 |
+
router = APIRouter()
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
@router.get("/{author_slug}/dashboard")
|
| 35 |
+
async def dashboard(
|
| 36 |
+
author_slug: str,
|
| 37 |
+
current_user=Depends(get_current_user),
|
| 38 |
+
db: AsyncSession = Depends(get_db),
|
| 39 |
+
):
|
| 40 |
+
"""Return high-level dashboard stats for the author."""
|
| 41 |
+
book_repo = BookRepository(db)
|
| 42 |
+
books = await book_repo.list_for_author(current_user.id)
|
| 43 |
+
|
| 44 |
+
from sqlalchemy import func, select
|
| 45 |
+
from app.models.chat_session import ChatSession
|
| 46 |
+
from app.models.analytics import AnalyticsEvent
|
| 47 |
+
|
| 48 |
+
total_sessions = await db.scalar(
|
| 49 |
+
select(func.count()).where(ChatSession.author_id == current_user.id)
|
| 50 |
+
) or 0
|
| 51 |
+
|
| 52 |
+
return {
|
| 53 |
+
"total_books": len(books),
|
| 54 |
+
"active_books": sum(1 for b in books if b.status == "active"),
|
| 55 |
+
"total_sessions": total_sessions,
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
@router.get("/{author_slug}/sessions")
|
| 60 |
+
async def list_sessions(
|
| 61 |
+
author_slug: str,
|
| 62 |
+
limit: int = Query(50, ge=1, le=200),
|
| 63 |
+
offset: int = 0,
|
| 64 |
+
current_user=Depends(get_current_user),
|
| 65 |
+
db: AsyncSession = Depends(get_db),
|
| 66 |
+
):
|
| 67 |
+
"""List reader sessions with pagination."""
|
| 68 |
+
from sqlalchemy import select, desc
|
| 69 |
+
from app.models.chat_session import ChatSession
|
| 70 |
+
|
| 71 |
+
result = await db.execute(
|
| 72 |
+
select(ChatSession)
|
| 73 |
+
.where(ChatSession.author_id == current_user.id)
|
| 74 |
+
.order_by(desc(ChatSession.created_at))
|
| 75 |
+
.offset(offset)
|
| 76 |
+
.limit(limit)
|
| 77 |
+
)
|
| 78 |
+
sessions = result.scalars().all()
|
| 79 |
+
return {
|
| 80 |
+
"sessions": [
|
| 81 |
+
{
|
| 82 |
+
"id": s.id,
|
| 83 |
+
"visitor_fingerprint": s.visitor_fingerprint,
|
| 84 |
+
"country": s.country_name,
|
| 85 |
+
"device_type": s.device_type,
|
| 86 |
+
"rating": s.rating,
|
| 87 |
+
"created_at": s.created_at.isoformat() if s.created_at else None,
|
| 88 |
+
"blocked": s.blocked,
|
| 89 |
+
}
|
| 90 |
+
for s in sessions
|
| 91 |
+
]
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
@router.post("/{author_slug}/sessions/{session_id}/block")
|
| 96 |
+
async def block_session(
|
| 97 |
+
author_slug: str,
|
| 98 |
+
session_id: str,
|
| 99 |
+
current_user=Depends(get_current_user),
|
| 100 |
+
db: AsyncSession = Depends(get_db),
|
| 101 |
+
):
|
| 102 |
+
"""Block a reader session (403 on next message)."""
|
| 103 |
+
from sqlalchemy import update
|
| 104 |
+
from app.models.chat_session import ChatSession
|
| 105 |
+
await db.execute(
|
| 106 |
+
update(ChatSession)
|
| 107 |
+
.where(ChatSession.id == session_id, ChatSession.author_id == current_user.id)
|
| 108 |
+
.values(blocked=True)
|
| 109 |
+
)
|
| 110 |
+
await db.commit()
|
| 111 |
+
return {"message": "Session blocked"}
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
@router.post("/{author_slug}/sessions/{session_id}/unblock")
|
| 115 |
+
async def unblock_session(
|
| 116 |
+
author_slug: str,
|
| 117 |
+
session_id: str,
|
| 118 |
+
current_user=Depends(get_current_user),
|
| 119 |
+
db: AsyncSession = Depends(get_db),
|
| 120 |
+
):
|
| 121 |
+
"""Unblock a reader session."""
|
| 122 |
+
from sqlalchemy import update
|
| 123 |
+
from app.models.chat_session import ChatSession
|
| 124 |
+
await db.execute(
|
| 125 |
+
update(ChatSession)
|
| 126 |
+
.where(ChatSession.id == session_id, ChatSession.author_id == current_user.id)
|
| 127 |
+
.values(blocked=False)
|
| 128 |
+
)
|
| 129 |
+
await db.commit()
|
| 130 |
+
return {"message": "Session unblocked"}
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
@router.get("/{author_slug}/books")
|
| 134 |
+
async def list_books(
|
| 135 |
+
author_slug: str,
|
| 136 |
+
current_user=Depends(get_current_user),
|
| 137 |
+
db: AsyncSession = Depends(get_db),
|
| 138 |
+
):
|
| 139 |
+
"""List all books for the author."""
|
| 140 |
+
book_repo = BookRepository(db)
|
| 141 |
+
books = await book_repo.list_for_author(current_user.id)
|
| 142 |
+
return {
|
| 143 |
+
"books": [
|
| 144 |
+
{
|
| 145 |
+
"id": b.id,
|
| 146 |
+
"title": b.title,
|
| 147 |
+
"genre": b.genre,
|
| 148 |
+
"status": b.status,
|
| 149 |
+
"chunk_count": getattr(b, "chunk_count", 0),
|
| 150 |
+
"ai_summary": getattr(b, "ai_summary", ""),
|
| 151 |
+
}
|
| 152 |
+
for b in books
|
| 153 |
+
]
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
@router.delete("/{author_slug}/books/{book_id}")
|
| 158 |
+
async def delete_book(
|
| 159 |
+
author_slug: str,
|
| 160 |
+
book_id: str,
|
| 161 |
+
current_user=Depends(get_current_user),
|
| 162 |
+
db: AsyncSession = Depends(get_db),
|
| 163 |
+
):
|
| 164 |
+
"""Delete a book and its ChromaDB collection."""
|
| 165 |
+
book_repo = BookRepository(db)
|
| 166 |
+
book = await book_repo.get(book_id)
|
| 167 |
+
if not book or book.author_id != current_user.id:
|
| 168 |
+
from fastapi import HTTPException
|
| 169 |
+
raise HTTPException(404, "Book not found")
|
| 170 |
+
|
| 171 |
+
from app.services.embeddings import delete_book_embeddings
|
| 172 |
+
delete_book_embeddings(current_user.id, book_id)
|
| 173 |
+
|
| 174 |
+
await db.delete(book)
|
| 175 |
+
await db.commit()
|
| 176 |
+
return {"message": "Book deleted"}
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
@router.get("/{author_slug}/analytics")
|
| 180 |
+
async def get_analytics(
|
| 181 |
+
author_slug: str,
|
| 182 |
+
days: int = Query(30, ge=1, le=365),
|
| 183 |
+
current_user=Depends(get_current_user),
|
| 184 |
+
db: AsyncSession = Depends(get_db),
|
| 185 |
+
):
|
| 186 |
+
"""Return analytics data for dashboard charts."""
|
| 187 |
+
from datetime import datetime, timedelta, timezone
|
| 188 |
+
from sqlalchemy import select, func
|
| 189 |
+
from app.models.analytics import AnalyticsEvent
|
| 190 |
+
|
| 191 |
+
since = datetime.now(timezone.utc) - timedelta(days=days)
|
| 192 |
+
result = await db.execute(
|
| 193 |
+
select(
|
| 194 |
+
func.date(AnalyticsEvent.created_at).label("date"),
|
| 195 |
+
func.count().label("count"),
|
| 196 |
+
)
|
| 197 |
+
.where(
|
| 198 |
+
AnalyticsEvent.author_id == current_user.id,
|
| 199 |
+
AnalyticsEvent.created_at >= since,
|
| 200 |
+
)
|
| 201 |
+
.group_by(func.date(AnalyticsEvent.created_at))
|
| 202 |
+
.order_by(func.date(AnalyticsEvent.created_at))
|
| 203 |
+
)
|
| 204 |
+
daily = [{"date": str(row.date), "count": row.count} for row in result]
|
| 205 |
+
return {"daily_sessions": daily, "period_days": days}
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
@router.post("/{author_slug}/password")
|
| 209 |
+
async def change_password(
|
| 210 |
+
author_slug: str,
|
| 211 |
+
current_password: str,
|
| 212 |
+
new_password: str,
|
| 213 |
+
current_user=Depends(get_current_user),
|
| 214 |
+
db: AsyncSession = Depends(get_db),
|
| 215 |
+
):
|
| 216 |
+
"""Self-service password change."""
|
| 217 |
+
from app.core.security import verify_password, hash_password
|
| 218 |
+
if not verify_password(current_password, current_user.password_hash):
|
| 219 |
+
from fastapi import HTTPException
|
| 220 |
+
raise HTTPException(400, "Current password is incorrect")
|
| 221 |
+
current_user.password_hash = hash_password(new_password)
|
| 222 |
+
await db.commit()
|
| 223 |
+
return {"message": "Password updated"}
|
app/admin/templates/admin.html
ADDED
|
@@ -0,0 +1,367 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>AuthorBot β Author Admin Panel</title>
|
| 7 |
+
<meta name="description" content="Manage your books, readers, analytics, and widget configuration for your AI-powered book sales chatbot.">
|
| 8 |
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 9 |
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
| 10 |
+
<style>
|
| 11 |
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
| 12 |
+
:root {
|
| 13 |
+
--bg: #080a12; --surface: #0f1322; --surface2: #161b2e;
|
| 14 |
+
--border: rgba(255,255,255,0.07); --accent: #6366f1; --accent2: #8b5cf6;
|
| 15 |
+
--text: #e2e8f0; --muted: #64748b; --green: #4ade80; --red: #f87171;
|
| 16 |
+
}
|
| 17 |
+
body { font-family: 'Inter', sans-serif; background: var(--bg); color: var(--text); min-height: 100vh; }
|
| 18 |
+
|
| 19 |
+
/* ββ Auth Screen ββ */
|
| 20 |
+
.auth-wrap {
|
| 21 |
+
min-height: 100vh; display: flex; align-items: center; justify-content: center;
|
| 22 |
+
background: radial-gradient(ellipse at 60% 0%, #1e1060 0%, var(--bg) 70%);
|
| 23 |
+
}
|
| 24 |
+
.auth-box {
|
| 25 |
+
background: var(--surface); border: 1px solid var(--border); border-radius: 20px;
|
| 26 |
+
padding: 40px; width: 100%; max-width: 420px;
|
| 27 |
+
box-shadow: 0 32px 64px rgba(0,0,0,0.5);
|
| 28 |
+
}
|
| 29 |
+
.logo { font-size: 1.6rem; font-weight: 700; margin-bottom: 4px;
|
| 30 |
+
background: linear-gradient(135deg, #6366f1, #8b5cf6); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
|
| 31 |
+
.auth-sub { color: var(--muted); font-size: 14px; margin-bottom: 32px; }
|
| 32 |
+
label { display: block; font-size: 12px; font-weight: 500; color: var(--muted); margin-bottom: 6px; letter-spacing: 0.05em; text-transform: uppercase; }
|
| 33 |
+
input[type=email], input[type=password], input[type=text] {
|
| 34 |
+
width: 100%; padding: 12px 14px; border-radius: 10px;
|
| 35 |
+
border: 1px solid var(--border); background: var(--bg);
|
| 36 |
+
color: var(--text); font-size: 14px; margin-bottom: 16px;
|
| 37 |
+
transition: border-color .2s;
|
| 38 |
+
}
|
| 39 |
+
input:focus { outline: none; border-color: var(--accent); }
|
| 40 |
+
.btn {
|
| 41 |
+
width: 100%; padding: 13px; border-radius: 10px; border: none;
|
| 42 |
+
background: linear-gradient(135deg, var(--accent), var(--accent2));
|
| 43 |
+
color: white; font-weight: 600; font-size: 15px; cursor: pointer;
|
| 44 |
+
transition: opacity .2s, transform .15s;
|
| 45 |
+
}
|
| 46 |
+
.btn:hover { opacity: .9; transform: translateY(-1px); }
|
| 47 |
+
.btn:active { transform: translateY(0); }
|
| 48 |
+
.msg { margin-top: 12px; font-size: 13px; min-height: 18px; }
|
| 49 |
+
.msg.ok { color: var(--green); } .msg.err { color: var(--red); }
|
| 50 |
+
|
| 51 |
+
/* ββ Dashboard ββ */
|
| 52 |
+
#dashboard { display: none; min-height: 100vh; }
|
| 53 |
+
.sidebar {
|
| 54 |
+
position: fixed; top: 0; left: 0; bottom: 0; width: 220px;
|
| 55 |
+
background: var(--surface); border-right: 1px solid var(--border);
|
| 56 |
+
display: flex; flex-direction: column; padding: 24px 16px;
|
| 57 |
+
}
|
| 58 |
+
.sidebar .logo { font-size: 1.2rem; margin-bottom: 32px; }
|
| 59 |
+
.nav-item {
|
| 60 |
+
display: flex; align-items: center; gap: 10px; padding: 10px 12px;
|
| 61 |
+
border-radius: 10px; color: var(--muted); text-decoration: none;
|
| 62 |
+
font-size: 14px; font-weight: 500; cursor: pointer; border: none; background: none;
|
| 63 |
+
width: 100%; transition: background .15s, color .15s; margin-bottom: 4px;
|
| 64 |
+
}
|
| 65 |
+
.nav-item:hover, .nav-item.active { background: rgba(99,102,241,.15); color: var(--accent); }
|
| 66 |
+
.main { margin-left: 220px; padding: 32px; }
|
| 67 |
+
.page { display: none; } .page.active { display: block; }
|
| 68 |
+
h1 { font-size: 1.5rem; font-weight: 700; margin-bottom: 4px; }
|
| 69 |
+
.page-sub { color: var(--muted); font-size: 14px; margin-bottom: 28px; }
|
| 70 |
+
.card {
|
| 71 |
+
background: var(--surface); border: 1px solid var(--border); border-radius: 16px;
|
| 72 |
+
padding: 24px; margin-bottom: 20px;
|
| 73 |
+
}
|
| 74 |
+
.stat-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 16px; margin-bottom: 24px; }
|
| 75 |
+
.stat { background: var(--surface2); border: 1px solid var(--border); border-radius: 14px; padding: 20px; }
|
| 76 |
+
.stat-val { font-size: 2rem; font-weight: 700; color: var(--accent); }
|
| 77 |
+
.stat-label { font-size: 12px; color: var(--muted); margin-top: 4px; }
|
| 78 |
+
table { width: 100%; border-collapse: collapse; font-size: 14px; }
|
| 79 |
+
th { text-align: left; padding: 10px 12px; color: var(--muted); font-weight: 500; font-size: 12px; border-bottom: 1px solid var(--border); }
|
| 80 |
+
td { padding: 12px; border-bottom: 1px solid rgba(255,255,255,0.04); }
|
| 81 |
+
tr:last-child td { border-bottom: none; }
|
| 82 |
+
.badge { display: inline-block; padding: 2px 8px; border-radius: 20px; font-size: 11px; font-weight: 600; }
|
| 83 |
+
.badge.active { background: rgba(74,222,128,.15); color: var(--green); }
|
| 84 |
+
.badge.processing { background: rgba(251,191,36,.15); color: #fbbf24; }
|
| 85 |
+
.badge.failed { background: rgba(248,113,113,.15); color: var(--red); }
|
| 86 |
+
.action-btn {
|
| 87 |
+
padding: 5px 12px; border-radius: 6px; border: 1px solid var(--border);
|
| 88 |
+
background: none; color: var(--text); font-size: 12px; cursor: pointer;
|
| 89 |
+
}
|
| 90 |
+
.action-btn:hover { background: rgba(255,255,255,.05); }
|
| 91 |
+
.action-btn.danger { color: var(--red); border-color: rgba(248,113,113,.3); }
|
| 92 |
+
pre.json-out {
|
| 93 |
+
background: var(--bg); border: 1px solid var(--border); border-radius: 10px;
|
| 94 |
+
padding: 16px; font-size: 12px; overflow: auto; max-height: 400px;
|
| 95 |
+
}
|
| 96 |
+
.field-row { margin-bottom: 16px; }
|
| 97 |
+
.logout-btn {
|
| 98 |
+
margin-top: auto; color: var(--muted); font-size: 13px; text-align: left;
|
| 99 |
+
background: none; border: none; cursor: pointer; padding: 8px 12px;
|
| 100 |
+
}
|
| 101 |
+
.logout-btn:hover { color: var(--red); }
|
| 102 |
+
</style>
|
| 103 |
+
</head>
|
| 104 |
+
<body>
|
| 105 |
+
|
| 106 |
+
<!-- ββ Auth Screen ββ -->
|
| 107 |
+
<div class="auth-wrap" id="auth-screen">
|
| 108 |
+
<div class="auth-box">
|
| 109 |
+
<div class="logo">AuthorBot</div>
|
| 110 |
+
<p class="auth-sub">Author Admin Panel β sign in to continue</p>
|
| 111 |
+
<div class="field-row">
|
| 112 |
+
<label>Email</label>
|
| 113 |
+
<input type="email" id="email" placeholder="you@example.com" autocomplete="email">
|
| 114 |
+
</div>
|
| 115 |
+
<div class="field-row">
|
| 116 |
+
<label>Password</label>
|
| 117 |
+
<input type="password" id="password" placeholder="β’β’β’β’β’β’β’β’" autocomplete="current-password">
|
| 118 |
+
</div>
|
| 119 |
+
<button class="btn" id="login-btn">Sign In</button>
|
| 120 |
+
<p class="msg" id="login-msg"></p>
|
| 121 |
+
</div>
|
| 122 |
+
</div>
|
| 123 |
+
|
| 124 |
+
<!-- ββ Dashboard ββ -->
|
| 125 |
+
<div id="dashboard">
|
| 126 |
+
<nav class="sidebar">
|
| 127 |
+
<div class="logo">AuthorBot</div>
|
| 128 |
+
<a class="nav-item active" data-page="overview">π Dashboard</a>
|
| 129 |
+
<a class="nav-item" data-page="books">π Books</a>
|
| 130 |
+
<a class="nav-item" data-page="readers">π₯ Readers</a>
|
| 131 |
+
<a class="nav-item" data-page="analytics">π Analytics</a>
|
| 132 |
+
<a class="nav-item" data-page="widget">π¨ Widget Config</a>
|
| 133 |
+
<a class="nav-item" data-page="settings">βοΈ Settings</a>
|
| 134 |
+
<button class="logout-btn" id="logout-btn">Sign Out</button>
|
| 135 |
+
</nav>
|
| 136 |
+
|
| 137 |
+
<main class="main">
|
| 138 |
+
<!-- Dashboard Overview -->
|
| 139 |
+
<div class="page active" id="page-overview">
|
| 140 |
+
<h1>Dashboard</h1>
|
| 141 |
+
<p class="page-sub">Your AuthorBot at a glance</p>
|
| 142 |
+
<div class="stat-grid">
|
| 143 |
+
<div class="stat"><div class="stat-val" id="stat-books">β</div><div class="stat-label">Books Indexed</div></div>
|
| 144 |
+
<div class="stat"><div class="stat-val" id="stat-sessions">β</div><div class="stat-label">Total Readers</div></div>
|
| 145 |
+
<div class="stat"><div class="stat-val" id="stat-active">β</div><div class="stat-label">Active Books</div></div>
|
| 146 |
+
</div>
|
| 147 |
+
<div class="card">
|
| 148 |
+
<p style="color:var(--muted);font-size:14px">Use the sidebar to manage books, view reader sessions, and configure your widget.</p>
|
| 149 |
+
</div>
|
| 150 |
+
</div>
|
| 151 |
+
|
| 152 |
+
<!-- Books -->
|
| 153 |
+
<div class="page" id="page-books">
|
| 154 |
+
<h1>Books</h1>
|
| 155 |
+
<p class="page-sub">Manage your book catalog</p>
|
| 156 |
+
<div class="card">
|
| 157 |
+
<table id="books-table">
|
| 158 |
+
<thead><tr><th>Title</th><th>Status</th><th>Chunks</th><th></th></tr></thead>
|
| 159 |
+
<tbody id="books-body"><tr><td colspan="4" style="color:var(--muted)">Loading...</td></tr></tbody>
|
| 160 |
+
</table>
|
| 161 |
+
</div>
|
| 162 |
+
</div>
|
| 163 |
+
|
| 164 |
+
<!-- Readers -->
|
| 165 |
+
<div class="page" id="page-readers">
|
| 166 |
+
<h1>Readers</h1>
|
| 167 |
+
<p class="page-sub">View and manage reader sessions</p>
|
| 168 |
+
<div class="card">
|
| 169 |
+
<table>
|
| 170 |
+
<thead><tr><th>Session</th><th>Country</th><th>Device</th><th>Rating</th><th>Date</th><th></th></tr></thead>
|
| 171 |
+
<tbody id="sessions-body"><tr><td colspan="6" style="color:var(--muted)">Loading...</td></tr></tbody>
|
| 172 |
+
</table>
|
| 173 |
+
</div>
|
| 174 |
+
</div>
|
| 175 |
+
|
| 176 |
+
<!-- Analytics -->
|
| 177 |
+
<div class="page" id="page-analytics">
|
| 178 |
+
<h1>Analytics</h1>
|
| 179 |
+
<p class="page-sub">Performance overview for the last 30 days</p>
|
| 180 |
+
<div class="card">
|
| 181 |
+
<pre class="json-out" id="analytics-output">Click Analytics in the sidebar to load data.</pre>
|
| 182 |
+
</div>
|
| 183 |
+
</div>
|
| 184 |
+
|
| 185 |
+
<!-- Widget Config -->
|
| 186 |
+
<div class="page" id="page-widget">
|
| 187 |
+
<h1>Widget Configuration</h1>
|
| 188 |
+
<p class="page-sub">Customize your chat widget appearance</p>
|
| 189 |
+
<div class="card">
|
| 190 |
+
<p style="color:var(--muted);font-size:14px">Widget config endpoints: <code>GET /api/admin/{slug}/widget-config</code></p>
|
| 191 |
+
<br>
|
| 192 |
+
<a href="/widget" target="_blank" class="action-btn">Preview Widget β</a>
|
| 193 |
+
</div>
|
| 194 |
+
</div>
|
| 195 |
+
|
| 196 |
+
<!-- Settings -->
|
| 197 |
+
<div class="page" id="page-settings">
|
| 198 |
+
<h1>Settings</h1>
|
| 199 |
+
<p class="page-sub">Account and security</p>
|
| 200 |
+
<div class="card">
|
| 201 |
+
<h3 style="margin-bottom:16px;font-size:1rem">Change Password</h3>
|
| 202 |
+
<div class="field-row">
|
| 203 |
+
<label>Current Password</label>
|
| 204 |
+
<input type="password" id="cur-pass" style="max-width:320px">
|
| 205 |
+
</div>
|
| 206 |
+
<div class="field-row">
|
| 207 |
+
<label>New Password</label>
|
| 208 |
+
<input type="password" id="new-pass" style="max-width:320px">
|
| 209 |
+
</div>
|
| 210 |
+
<button class="btn" id="change-pass-btn" style="max-width:180px">Update Password</button>
|
| 211 |
+
<p class="msg" id="pass-msg"></p>
|
| 212 |
+
</div>
|
| 213 |
+
</div>
|
| 214 |
+
</main>
|
| 215 |
+
</div>
|
| 216 |
+
|
| 217 |
+
<script>
|
| 218 |
+
const API = window.location.origin + '/api';
|
| 219 |
+
let token = localStorage.getItem('access_token');
|
| 220 |
+
let authorSlug = localStorage.getItem('author_slug');
|
| 221 |
+
|
| 222 |
+
// ββ Auth ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 223 |
+
if (token) showDashboard();
|
| 224 |
+
|
| 225 |
+
document.getElementById('login-btn').onclick = async () => {
|
| 226 |
+
const msg = document.getElementById('login-msg');
|
| 227 |
+
msg.textContent = ''; msg.className = 'msg';
|
| 228 |
+
try {
|
| 229 |
+
const res = await fetch(API + '/auth/login', {
|
| 230 |
+
method: 'POST',
|
| 231 |
+
headers: { 'Content-Type': 'application/json' },
|
| 232 |
+
body: JSON.stringify({
|
| 233 |
+
email: document.getElementById('email').value,
|
| 234 |
+
password: document.getElementById('password').value,
|
| 235 |
+
}),
|
| 236 |
+
});
|
| 237 |
+
const data = await res.json();
|
| 238 |
+
if (!res.ok) throw new Error(data.detail || 'Login failed');
|
| 239 |
+
token = data.access_token;
|
| 240 |
+
localStorage.setItem('access_token', token);
|
| 241 |
+
localStorage.setItem('author_slug', data.author_slug || 'me');
|
| 242 |
+
authorSlug = data.author_slug || 'me';
|
| 243 |
+
showDashboard();
|
| 244 |
+
} catch (e) {
|
| 245 |
+
msg.textContent = e.message; msg.className = 'msg err';
|
| 246 |
+
}
|
| 247 |
+
};
|
| 248 |
+
|
| 249 |
+
document.getElementById('logout-btn').onclick = () => {
|
| 250 |
+
localStorage.clear(); location.reload();
|
| 251 |
+
};
|
| 252 |
+
|
| 253 |
+
function showDashboard() {
|
| 254 |
+
document.getElementById('auth-screen').style.display = 'none';
|
| 255 |
+
document.getElementById('dashboard').style.display = 'block';
|
| 256 |
+
loadDashboard();
|
| 257 |
+
}
|
| 258 |
+
|
| 259 |
+
// ββ Navigation ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 260 |
+
document.querySelectorAll('.nav-item').forEach(el => {
|
| 261 |
+
el.onclick = () => {
|
| 262 |
+
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
|
| 263 |
+
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
|
| 264 |
+
el.classList.add('active');
|
| 265 |
+
const page = document.getElementById('page-' + el.dataset.page);
|
| 266 |
+
if (page) page.classList.add('active');
|
| 267 |
+
if (el.dataset.page === 'books') loadBooks();
|
| 268 |
+
if (el.dataset.page === 'readers') loadSessions();
|
| 269 |
+
if (el.dataset.page === 'analytics') loadAnalytics();
|
| 270 |
+
};
|
| 271 |
+
});
|
| 272 |
+
|
| 273 |
+
// ββ API Calls βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 274 |
+
async function apiGet(path) {
|
| 275 |
+
const res = await fetch(API + path, { headers: { Authorization: 'Bearer ' + token } });
|
| 276 |
+
return res.json();
|
| 277 |
+
}
|
| 278 |
+
|
| 279 |
+
async function loadDashboard() {
|
| 280 |
+
try {
|
| 281 |
+
const data = await apiGet(`/admin/${authorSlug}/dashboard`);
|
| 282 |
+
document.getElementById('stat-books').textContent = data.total_books ?? 'β';
|
| 283 |
+
document.getElementById('stat-sessions').textContent = data.total_sessions ?? 'β';
|
| 284 |
+
document.getElementById('stat-active').textContent = data.active_books ?? 'β';
|
| 285 |
+
} catch {}
|
| 286 |
+
}
|
| 287 |
+
|
| 288 |
+
async function loadBooks() {
|
| 289 |
+
const tbody = document.getElementById('books-body');
|
| 290 |
+
tbody.innerHTML = '<tr><td colspan="4" style="color:var(--muted)">Loading...</td></tr>';
|
| 291 |
+
try {
|
| 292 |
+
const data = await apiGet(`/admin/${authorSlug}/books`);
|
| 293 |
+
tbody.innerHTML = (data.books || []).map(b => `
|
| 294 |
+
<tr>
|
| 295 |
+
<td style="font-weight:500">${b.title}</td>
|
| 296 |
+
<td><span class="badge ${b.status}">${b.status}</span></td>
|
| 297 |
+
<td>${b.chunk_count || 0}</td>
|
| 298 |
+
<td><button class="action-btn danger" onclick="deleteBook('${b.id}')">Delete</button></td>
|
| 299 |
+
</tr>
|
| 300 |
+
`).join('') || '<tr><td colspan="4" style="color:var(--muted)">No books yet. Upload via API.</td></tr>';
|
| 301 |
+
} catch { tbody.innerHTML = '<tr><td colspan="4" style="color:var(--red)">Failed to load</td></tr>'; }
|
| 302 |
+
}
|
| 303 |
+
|
| 304 |
+
async function deleteBook(id) {
|
| 305 |
+
if (!confirm('Delete this book and all its embeddings?')) return;
|
| 306 |
+
await fetch(API + `/admin/${authorSlug}/books/${id}`, {
|
| 307 |
+
method: 'DELETE', headers: { Authorization: 'Bearer ' + token }
|
| 308 |
+
});
|
| 309 |
+
loadBooks();
|
| 310 |
+
}
|
| 311 |
+
|
| 312 |
+
async function loadSessions() {
|
| 313 |
+
const tbody = document.getElementById('sessions-body');
|
| 314 |
+
tbody.innerHTML = '<tr><td colspan="6" style="color:var(--muted)">Loading...</td></tr>';
|
| 315 |
+
try {
|
| 316 |
+
const data = await apiGet(`/admin/${authorSlug}/sessions`);
|
| 317 |
+
tbody.innerHTML = (data.sessions || []).map(s => `
|
| 318 |
+
<tr>
|
| 319 |
+
<td style="font-family:monospace;font-size:11px">${s.id.slice(0,8)}β¦</td>
|
| 320 |
+
<td>${s.country || 'β'}</td>
|
| 321 |
+
<td>${s.device_type || 'β'}</td>
|
| 322 |
+
<td>${s.rating ? 'β'.repeat(s.rating) : 'β'}</td>
|
| 323 |
+
<td style="font-size:12px;color:var(--muted)">${s.created_at ? new Date(s.created_at).toLocaleDateString() : 'β'}</td>
|
| 324 |
+
<td>
|
| 325 |
+
${s.blocked
|
| 326 |
+
? `<button class="action-btn" onclick="unblockSession('${s.id}')">Unblock</button>`
|
| 327 |
+
: `<button class="action-btn danger" onclick="blockSession('${s.id}')">Block</button>`
|
| 328 |
+
}
|
| 329 |
+
</td>
|
| 330 |
+
</tr>
|
| 331 |
+
`).join('') || '<tr><td colspan="6" style="color:var(--muted)">No sessions yet.</td></tr>';
|
| 332 |
+
} catch { tbody.innerHTML = '<tr><td colspan="6" style="color:var(--red)">Failed to load</td></tr>'; }
|
| 333 |
+
}
|
| 334 |
+
|
| 335 |
+
async function blockSession(id) {
|
| 336 |
+
await fetch(API + `/admin/${authorSlug}/sessions/${id}/block`, { method: 'POST', headers: { Authorization: 'Bearer ' + token } });
|
| 337 |
+
loadSessions();
|
| 338 |
+
}
|
| 339 |
+
async function unblockSession(id) {
|
| 340 |
+
await fetch(API + `/admin/${authorSlug}/sessions/${id}/unblock`, { method: 'POST', headers: { Authorization: 'Bearer ' + token } });
|
| 341 |
+
loadSessions();
|
| 342 |
+
}
|
| 343 |
+
|
| 344 |
+
async function loadAnalytics() {
|
| 345 |
+
try {
|
| 346 |
+
const data = await apiGet(`/admin/${authorSlug}/analytics`);
|
| 347 |
+
document.getElementById('analytics-output').textContent = JSON.stringify(data, null, 2);
|
| 348 |
+
} catch { document.getElementById('analytics-output').textContent = 'Failed to load analytics'; }
|
| 349 |
+
}
|
| 350 |
+
|
| 351 |
+
// ββ Password change βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 352 |
+
document.getElementById('change-pass-btn').onclick = async () => {
|
| 353 |
+
const msg = document.getElementById('pass-msg');
|
| 354 |
+
msg.textContent = ''; msg.className = 'msg';
|
| 355 |
+
try {
|
| 356 |
+
const res = await fetch(
|
| 357 |
+
API + `/admin/${authorSlug}/password?current_password=${encodeURIComponent(document.getElementById('cur-pass').value)}&new_password=${encodeURIComponent(document.getElementById('new-pass').value)}`,
|
| 358 |
+
{ method: 'POST', headers: { Authorization: 'Bearer ' + token } }
|
| 359 |
+
);
|
| 360 |
+
const data = await res.json();
|
| 361 |
+
if (!res.ok) throw new Error(data.detail || 'Failed');
|
| 362 |
+
msg.textContent = 'Password updated!'; msg.className = 'msg ok';
|
| 363 |
+
} catch(e) { msg.textContent = e.message; msg.className = 'msg err'; }
|
| 364 |
+
};
|
| 365 |
+
</script>
|
| 366 |
+
</body>
|
| 367 |
+
</html>
|
app/api/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# API package
|
backend/app/api/v1/chatbot.py β app/api/chat.py
RENAMED
|
@@ -1,43 +1,61 @@
|
|
| 1 |
-
"""Author RAG
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
"""
|
| 7 |
|
| 8 |
import uuid
|
|
|
|
| 9 |
|
| 10 |
from fastapi import APIRouter, Depends, Header, Request
|
|
|
|
| 11 |
from sqlalchemy.ext.asyncio import AsyncSession
|
| 12 |
|
| 13 |
from app.dependencies import get_db, get_redis, get_subscription_author
|
| 14 |
-
from app.
|
| 15 |
-
from app.
|
| 16 |
from app.repositories.book_repo import BookRepository
|
| 17 |
from app.schemas.chatbot import ChatRequest, ChatResponse, SessionInitResponse
|
| 18 |
|
| 19 |
router = APIRouter()
|
| 20 |
|
| 21 |
|
| 22 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
async def init_session(
|
|
|
|
| 24 |
request: Request,
|
| 25 |
author=Depends(get_subscription_author),
|
| 26 |
db: AsyncSession = Depends(get_db),
|
| 27 |
redis=Depends(get_redis),
|
| 28 |
):
|
| 29 |
-
"""Initialize a new chat session
|
| 30 |
session_id = str(uuid.uuid4())
|
| 31 |
-
visitor_fp =
|
| 32 |
|
| 33 |
book_repo = BookRepository(db)
|
| 34 |
active_books = await book_repo.list_active_for_author(author.id)
|
| 35 |
|
| 36 |
-
# Create session record in DB
|
| 37 |
-
from app.models.base import generate_uuid
|
| 38 |
from app.models.chat_session import ChatSession
|
| 39 |
-
from app.
|
| 40 |
-
from app.
|
| 41 |
|
| 42 |
geo = await get_geo_info(request)
|
| 43 |
device = parse_device_info(request)
|
|
@@ -75,25 +93,24 @@ async def init_session(
|
|
| 75 |
)
|
| 76 |
|
| 77 |
|
| 78 |
-
@router.post("/
|
| 79 |
async def chat(
|
|
|
|
| 80 |
payload: ChatRequest,
|
| 81 |
author=Depends(get_subscription_author),
|
| 82 |
db: AsyncSession = Depends(get_db),
|
| 83 |
redis=Depends(get_redis),
|
| 84 |
):
|
| 85 |
-
"""Process one chat message through the
|
| 86 |
session_mgr = SessionManager(redis)
|
| 87 |
session_ctx = await session_mgr.load(payload.session_id, author.id)
|
| 88 |
|
| 89 |
-
# Update book selection if provided
|
| 90 |
if payload.selected_book_id is not None:
|
| 91 |
session_ctx.selected_book_id = payload.selected_book_id
|
| 92 |
await session_mgr.set_selected_book(
|
| 93 |
payload.session_id, author.id, payload.selected_book_id
|
| 94 |
)
|
| 95 |
|
| 96 |
-
# Run 12-step pipeline
|
| 97 |
result = await run_pipeline(
|
| 98 |
query=payload.message,
|
| 99 |
author=author,
|
|
@@ -101,15 +118,13 @@ async def chat(
|
|
| 101 |
db=db,
|
| 102 |
)
|
| 103 |
|
| 104 |
-
# Save session
|
| 105 |
await session_mgr.save(
|
| 106 |
context=session_ctx,
|
| 107 |
user_message=payload.message,
|
| 108 |
assistant_message=result.response["text"],
|
| 109 |
)
|
| 110 |
|
| 111 |
-
|
| 112 |
-
from app.core.analytics.tracker import track_turn
|
| 113 |
await track_turn(
|
| 114 |
db=db,
|
| 115 |
redis=redis,
|
|
@@ -130,36 +145,72 @@ async def chat(
|
|
| 130 |
)
|
| 131 |
|
| 132 |
|
| 133 |
-
@router.
|
| 134 |
-
async def
|
|
|
|
| 135 |
session_id: str,
|
| 136 |
-
book_id: str,
|
| 137 |
author=Depends(get_subscription_author),
|
| 138 |
redis=Depends(get_redis),
|
| 139 |
):
|
| 140 |
-
"""
|
| 141 |
session_mgr = SessionManager(redis)
|
| 142 |
-
await session_mgr.
|
| 143 |
-
return {"
|
| 144 |
|
| 145 |
|
| 146 |
-
|
| 147 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 148 |
|
| 149 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 150 |
|
| 151 |
-
Args:
|
| 152 |
-
request: FastAPI request.
|
| 153 |
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Author RAG β Chat API Router.
|
| 2 |
+
|
| 3 |
+
Routes (per implementation plan):
|
| 4 |
+
POST /api/chat/{author_slug} β Main chat endpoint (widget)
|
| 5 |
+
POST /api/chat/{author_slug}/session/init β Initialize session
|
| 6 |
+
GET /api/chat/{author_slug}/session/history/{session_id}
|
| 7 |
+
POST /api/chat/{author_slug}/session/rate β Star rating
|
| 8 |
+
POST /api/chat/{author_slug}/session/reset β Reset session
|
| 9 |
+
GET /api/chat/{author_slug}/events β SSE for ingestion progress
|
| 10 |
+
|
| 11 |
+
RULE: Requires X-Subscription-Token header on all routes.
|
| 12 |
+
RULE: Visitor fingerprint is generated server-side.
|
| 13 |
"""
|
| 14 |
|
| 15 |
import uuid
|
| 16 |
+
import hashlib
|
| 17 |
|
| 18 |
from fastapi import APIRouter, Depends, Header, Request
|
| 19 |
+
from fastapi.responses import StreamingResponse
|
| 20 |
from sqlalchemy.ext.asyncio import AsyncSession
|
| 21 |
|
| 22 |
from app.dependencies import get_db, get_redis, get_subscription_author
|
| 23 |
+
from app.services.rag_pipeline import run_pipeline
|
| 24 |
+
from app.services.session_core.manager import SessionManager
|
| 25 |
from app.repositories.book_repo import BookRepository
|
| 26 |
from app.schemas.chatbot import ChatRequest, ChatResponse, SessionInitResponse
|
| 27 |
|
| 28 |
router = APIRouter()
|
| 29 |
|
| 30 |
|
| 31 |
+
def _fingerprint(request: Request) -> str:
|
| 32 |
+
"""SHA-256 fingerprint from IP + UA (no PII stored)."""
|
| 33 |
+
components = [
|
| 34 |
+
request.client.host if request.client else "unknown",
|
| 35 |
+
request.headers.get("User-Agent", ""),
|
| 36 |
+
request.headers.get("Accept-Language", ""),
|
| 37 |
+
]
|
| 38 |
+
return hashlib.sha256("|".join(components).encode()).hexdigest()[:32]
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
@router.post("/{author_slug}/session/init", response_model=SessionInitResponse, status_code=201)
|
| 42 |
async def init_session(
|
| 43 |
+
author_slug: str,
|
| 44 |
request: Request,
|
| 45 |
author=Depends(get_subscription_author),
|
| 46 |
db: AsyncSession = Depends(get_db),
|
| 47 |
redis=Depends(get_redis),
|
| 48 |
):
|
| 49 |
+
"""Initialize a new chat session for a visitor."""
|
| 50 |
session_id = str(uuid.uuid4())
|
| 51 |
+
visitor_fp = _fingerprint(request)
|
| 52 |
|
| 53 |
book_repo = BookRepository(db)
|
| 54 |
active_books = await book_repo.list_active_for_author(author.id)
|
| 55 |
|
|
|
|
|
|
|
| 56 |
from app.models.chat_session import ChatSession
|
| 57 |
+
from app.services.analytics_core.geo import get_geo_info
|
| 58 |
+
from app.services.analytics_core.tracker import parse_device_info
|
| 59 |
|
| 60 |
geo = await get_geo_info(request)
|
| 61 |
device = parse_device_info(request)
|
|
|
|
| 93 |
)
|
| 94 |
|
| 95 |
|
| 96 |
+
@router.post("/{author_slug}", response_model=ChatResponse)
|
| 97 |
async def chat(
|
| 98 |
+
author_slug: str,
|
| 99 |
payload: ChatRequest,
|
| 100 |
author=Depends(get_subscription_author),
|
| 101 |
db: AsyncSession = Depends(get_db),
|
| 102 |
redis=Depends(get_redis),
|
| 103 |
):
|
| 104 |
+
"""Process one chat message through the 12-step RAG pipeline."""
|
| 105 |
session_mgr = SessionManager(redis)
|
| 106 |
session_ctx = await session_mgr.load(payload.session_id, author.id)
|
| 107 |
|
|
|
|
| 108 |
if payload.selected_book_id is not None:
|
| 109 |
session_ctx.selected_book_id = payload.selected_book_id
|
| 110 |
await session_mgr.set_selected_book(
|
| 111 |
payload.session_id, author.id, payload.selected_book_id
|
| 112 |
)
|
| 113 |
|
|
|
|
| 114 |
result = await run_pipeline(
|
| 115 |
query=payload.message,
|
| 116 |
author=author,
|
|
|
|
| 118 |
db=db,
|
| 119 |
)
|
| 120 |
|
|
|
|
| 121 |
await session_mgr.save(
|
| 122 |
context=session_ctx,
|
| 123 |
user_message=payload.message,
|
| 124 |
assistant_message=result.response["text"],
|
| 125 |
)
|
| 126 |
|
| 127 |
+
from app.services.analytics_core.tracker import track_turn
|
|
|
|
| 128 |
await track_turn(
|
| 129 |
db=db,
|
| 130 |
redis=redis,
|
|
|
|
| 145 |
)
|
| 146 |
|
| 147 |
|
| 148 |
+
@router.get("/{author_slug}/session/history/{session_id}")
|
| 149 |
+
async def get_history(
|
| 150 |
+
author_slug: str,
|
| 151 |
session_id: str,
|
|
|
|
| 152 |
author=Depends(get_subscription_author),
|
| 153 |
redis=Depends(get_redis),
|
| 154 |
):
|
| 155 |
+
"""Return full chat history for a session."""
|
| 156 |
session_mgr = SessionManager(redis)
|
| 157 |
+
ctx = await session_mgr.load(session_id, author.id)
|
| 158 |
+
return {"session_id": session_id, "history": ctx.history, "turn_count": ctx.turn_count}
|
| 159 |
|
| 160 |
|
| 161 |
+
@router.post("/{author_slug}/session/rate")
|
| 162 |
+
async def rate_session(
|
| 163 |
+
author_slug: str,
|
| 164 |
+
session_id: str,
|
| 165 |
+
rating: int,
|
| 166 |
+
author=Depends(get_subscription_author),
|
| 167 |
+
db: AsyncSession = Depends(get_db),
|
| 168 |
+
):
|
| 169 |
+
"""Submit a star rating (1β5) for a chat session."""
|
| 170 |
+
from app.models.chat_session import ChatSession
|
| 171 |
+
from sqlalchemy import update
|
| 172 |
+
await db.execute(
|
| 173 |
+
update(ChatSession)
|
| 174 |
+
.where(ChatSession.id == session_id, ChatSession.author_id == author.id)
|
| 175 |
+
.values(rating=rating)
|
| 176 |
+
)
|
| 177 |
+
await db.commit()
|
| 178 |
+
return {"message": "Rating saved", "rating": rating}
|
| 179 |
+
|
| 180 |
|
| 181 |
+
@router.post("/{author_slug}/session/reset")
|
| 182 |
+
async def reset_session(
|
| 183 |
+
author_slug: str,
|
| 184 |
+
session_id: str,
|
| 185 |
+
author=Depends(get_subscription_author),
|
| 186 |
+
redis=Depends(get_redis),
|
| 187 |
+
):
|
| 188 |
+
"""Clear the session context from Redis (start fresh)."""
|
| 189 |
+
session_mgr = SessionManager(redis)
|
| 190 |
+
await session_mgr.delete(session_id, author.id)
|
| 191 |
+
return {"message": "Session reset"}
|
| 192 |
|
|
|
|
|
|
|
| 193 |
|
| 194 |
+
@router.get("/{author_slug}/events")
|
| 195 |
+
async def ingestion_events(
|
| 196 |
+
author_slug: str,
|
| 197 |
+
author=Depends(get_subscription_author),
|
| 198 |
+
redis=Depends(get_redis),
|
| 199 |
+
):
|
| 200 |
+
"""SSE stream for real-time ingestion progress updates."""
|
| 201 |
+
async def event_stream():
|
| 202 |
+
pubsub = redis.pubsub()
|
| 203 |
+
channel = f"ingestion:{author.id}"
|
| 204 |
+
await pubsub.subscribe(channel)
|
| 205 |
+
try:
|
| 206 |
+
async for message in pubsub.listen():
|
| 207 |
+
if message["type"] == "message":
|
| 208 |
+
yield f"data: {message['data']}\n\n"
|
| 209 |
+
finally:
|
| 210 |
+
await pubsub.unsubscribe(channel)
|
| 211 |
+
|
| 212 |
+
return StreamingResponse(
|
| 213 |
+
event_stream(),
|
| 214 |
+
media_type="text/event-stream",
|
| 215 |
+
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
| 216 |
+
)
|
app/api/ingest.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Author RAG β Ingestion API Router.
|
| 2 |
+
|
| 3 |
+
Routes (per implementation plan):
|
| 4 |
+
POST /api/admin/{author_slug}/books/upload β Upload a book file
|
| 5 |
+
GET /api/admin/{author_slug}/books/upload/progress β SSE ingestion progress
|
| 6 |
+
|
| 7 |
+
RULE: Requires Author JWT (Bearer token).
|
| 8 |
+
RULE: Magic byte validation before any processing.
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
import asyncio
|
| 12 |
+
import json
|
| 13 |
+
|
| 14 |
+
from fastapi import APIRouter, Depends, File, UploadFile, HTTPException
|
| 15 |
+
from fastapi.responses import StreamingResponse
|
| 16 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 17 |
+
|
| 18 |
+
from app.dependencies import get_db, get_current_user, get_redis
|
| 19 |
+
from app.services.file_validator import validate_file
|
| 20 |
+
from app.services.parser import parse_document
|
| 21 |
+
from app.services.chunker import chunk_text
|
| 22 |
+
from app.services.embeddings import embed_and_store
|
| 23 |
+
from app.services.summarizer import generate_summary
|
| 24 |
+
from app.repositories.book_repo import BookRepository
|
| 25 |
+
from app.repositories.document_repo import DocumentRepository
|
| 26 |
+
from app.config import get_settings
|
| 27 |
+
|
| 28 |
+
router = APIRouter()
|
| 29 |
+
cfg = get_settings()
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
@router.post("/{author_slug}/books/upload", status_code=202)
|
| 33 |
+
async def upload_book(
|
| 34 |
+
author_slug: str,
|
| 35 |
+
file: UploadFile = File(...),
|
| 36 |
+
title: str = "",
|
| 37 |
+
genre: str = "",
|
| 38 |
+
price: float = 0.0,
|
| 39 |
+
buy_url: str = "",
|
| 40 |
+
current_user=Depends(get_current_user),
|
| 41 |
+
db: AsyncSession = Depends(get_db),
|
| 42 |
+
redis=Depends(get_redis),
|
| 43 |
+
):
|
| 44 |
+
"""Upload a book file (PDF/EPUB/DOCX/TXT) and start async ingestion.
|
| 45 |
+
|
| 46 |
+
Responds immediately with book_id. Use /progress SSE to track ingestion.
|
| 47 |
+
"""
|
| 48 |
+
# Validate file (magic bytes, not just extension)
|
| 49 |
+
contents = await file.read()
|
| 50 |
+
ext = validate_file(contents, file.filename or "")
|
| 51 |
+
|
| 52 |
+
book_repo = BookRepository(db)
|
| 53 |
+
doc_repo = DocumentRepository(db)
|
| 54 |
+
|
| 55 |
+
book = await book_repo.create(
|
| 56 |
+
author_id=current_user.id,
|
| 57 |
+
title=title or (file.filename or "Untitled").rsplit(".", 1)[0],
|
| 58 |
+
genre=genre,
|
| 59 |
+
price=price,
|
| 60 |
+
buy_url=buy_url,
|
| 61 |
+
status="processing",
|
| 62 |
+
)
|
| 63 |
+
|
| 64 |
+
doc = await doc_repo.create(
|
| 65 |
+
book_id=book.id,
|
| 66 |
+
author_id=current_user.id,
|
| 67 |
+
filename=file.filename or "upload",
|
| 68 |
+
file_extension=ext,
|
| 69 |
+
file_size_bytes=len(contents),
|
| 70 |
+
)
|
| 71 |
+
await db.commit()
|
| 72 |
+
|
| 73 |
+
# Fire async ingestion in background
|
| 74 |
+
asyncio.create_task(
|
| 75 |
+
_run_ingestion(
|
| 76 |
+
contents=contents,
|
| 77 |
+
ext=ext,
|
| 78 |
+
book_id=book.id,
|
| 79 |
+
doc_id=doc.id,
|
| 80 |
+
author_id=current_user.id,
|
| 81 |
+
title=book.title,
|
| 82 |
+
redis=redis,
|
| 83 |
+
db=db,
|
| 84 |
+
)
|
| 85 |
+
)
|
| 86 |
+
|
| 87 |
+
return {"book_id": book.id, "doc_id": doc.id, "status": "processing"}
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
@router.get("/{author_slug}/books/upload/progress")
|
| 91 |
+
async def ingestion_progress(
|
| 92 |
+
author_slug: str,
|
| 93 |
+
book_id: str,
|
| 94 |
+
current_user=Depends(get_current_user),
|
| 95 |
+
redis=Depends(get_redis),
|
| 96 |
+
):
|
| 97 |
+
"""SSE stream for real-time ingestion progress of a specific book."""
|
| 98 |
+
async def event_stream():
|
| 99 |
+
channel = f"ingestion:{current_user.id}:{book_id}"
|
| 100 |
+
pubsub = redis.pubsub()
|
| 101 |
+
await pubsub.subscribe(channel)
|
| 102 |
+
try:
|
| 103 |
+
async for message in pubsub.listen():
|
| 104 |
+
if message["type"] == "message":
|
| 105 |
+
yield f"data: {message['data']}\n\n"
|
| 106 |
+
data = json.loads(message["data"])
|
| 107 |
+
if data.get("stage") in ("complete", "error"):
|
| 108 |
+
break
|
| 109 |
+
finally:
|
| 110 |
+
await pubsub.unsubscribe(channel)
|
| 111 |
+
|
| 112 |
+
return StreamingResponse(
|
| 113 |
+
event_stream(),
|
| 114 |
+
media_type="text/event-stream",
|
| 115 |
+
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
| 116 |
+
)
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
async def _publish(redis, channel: str, stage: str, progress: int, detail: str = "") -> None:
|
| 120 |
+
"""Publish an SSE progress event to Redis pub/sub."""
|
| 121 |
+
await redis.publish(channel, json.dumps({"stage": stage, "progress": progress, "detail": detail}))
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
async def _run_ingestion(
|
| 125 |
+
contents: bytes,
|
| 126 |
+
ext: str,
|
| 127 |
+
book_id: str,
|
| 128 |
+
doc_id: str,
|
| 129 |
+
author_id: str,
|
| 130 |
+
title: str,
|
| 131 |
+
redis,
|
| 132 |
+
db: AsyncSession,
|
| 133 |
+
) -> None:
|
| 134 |
+
"""Full async ingestion: parse β chunk β embed β summarize."""
|
| 135 |
+
channel = f"ingestion:{author_id}:{book_id}"
|
| 136 |
+
book_repo = BookRepository(db)
|
| 137 |
+
|
| 138 |
+
try:
|
| 139 |
+
await _publish(redis, channel, "parsing", 10)
|
| 140 |
+
text = parse_document(contents, ext)
|
| 141 |
+
|
| 142 |
+
await _publish(redis, channel, "chunking", 30)
|
| 143 |
+
chunks = chunk_text(text)
|
| 144 |
+
|
| 145 |
+
await _publish(redis, channel, "embedding", 50)
|
| 146 |
+
collection_name = await embed_and_store(chunks, author_id, book_id, title)
|
| 147 |
+
|
| 148 |
+
await _publish(redis, channel, "summarizing", 80)
|
| 149 |
+
summary = await generate_summary(text[:3000], title)
|
| 150 |
+
|
| 151 |
+
await book_repo.update(book_id, {
|
| 152 |
+
"status": "active",
|
| 153 |
+
"chroma_collection": collection_name,
|
| 154 |
+
"ai_summary": summary,
|
| 155 |
+
"chunk_count": len(chunks),
|
| 156 |
+
})
|
| 157 |
+
await db.commit()
|
| 158 |
+
|
| 159 |
+
await _publish(redis, channel, "complete", 100)
|
| 160 |
+
|
| 161 |
+
except Exception as e:
|
| 162 |
+
await book_repo.update(book_id, {"status": "failed"})
|
| 163 |
+
await db.commit()
|
| 164 |
+
await _publish(redis, channel, "error", 0, str(e))
|
app/api/schemas_router.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Author RAG β Auth + misc schema routes mounted at /api.
|
| 2 |
+
|
| 3 |
+
Routes:
|
| 4 |
+
POST /api/auth/register
|
| 5 |
+
POST /api/auth/login
|
| 6 |
+
POST /api/auth/refresh
|
| 7 |
+
POST /api/auth/logout
|
| 8 |
+
GET /api/auth/me
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from fastapi import APIRouter, Depends, Response
|
| 12 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 13 |
+
|
| 14 |
+
from app.dependencies import get_db, get_current_user
|
| 15 |
+
from app.schemas.auth import LoginRequest, RegisterRequest, TokenResponse, UserResponse, RefreshRequest
|
| 16 |
+
from app.services.auth_service import AuthService
|
| 17 |
+
|
| 18 |
+
router = APIRouter()
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
@router.post("/auth/register", response_model=TokenResponse, status_code=201, tags=["Auth"])
|
| 22 |
+
async def register(payload: RegisterRequest, db: AsyncSession = Depends(get_db)):
|
| 23 |
+
"""Register a new author account."""
|
| 24 |
+
return await AuthService(db).register(
|
| 25 |
+
email=payload.email,
|
| 26 |
+
password=payload.password,
|
| 27 |
+
full_name=payload.full_name,
|
| 28 |
+
)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
@router.post("/auth/login", response_model=TokenResponse, tags=["Auth"])
|
| 32 |
+
async def login(payload: LoginRequest, response: Response, db: AsyncSession = Depends(get_db)):
|
| 33 |
+
"""Authenticate author and return JWT token pair."""
|
| 34 |
+
result = await AuthService(db).login(email=payload.email, password=payload.password)
|
| 35 |
+
response.set_cookie(
|
| 36 |
+
"refresh_token", result["refresh_token"],
|
| 37 |
+
httponly=True, secure=True, samesite="lax",
|
| 38 |
+
max_age=7 * 24 * 60 * 60,
|
| 39 |
+
)
|
| 40 |
+
return result
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
@router.post("/auth/refresh", response_model=TokenResponse, tags=["Auth"])
|
| 44 |
+
async def refresh(payload: RefreshRequest, response: Response, db: AsyncSession = Depends(get_db)):
|
| 45 |
+
"""Issue new JWT token pair."""
|
| 46 |
+
result = await AuthService(db).refresh_tokens(payload.refresh_token)
|
| 47 |
+
response.set_cookie(
|
| 48 |
+
"refresh_token", result["refresh_token"],
|
| 49 |
+
httponly=True, secure=True, samesite="lax",
|
| 50 |
+
max_age=7 * 24 * 60 * 60,
|
| 51 |
+
)
|
| 52 |
+
return result
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
@router.post("/auth/logout", status_code=204, tags=["Auth"])
|
| 56 |
+
async def logout(response: Response, _=Depends(get_current_user)):
|
| 57 |
+
"""Invalidate refresh token cookie."""
|
| 58 |
+
response.delete_cookie("refresh_token")
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
@router.get("/auth/me", response_model=UserResponse, tags=["Auth"])
|
| 62 |
+
async def get_me(current_user=Depends(get_current_user)):
|
| 63 |
+
"""Return current author profile."""
|
| 64 |
+
return current_user
|
app/api/widget.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Author RAG β Widget Token API.
|
| 2 |
+
|
| 3 |
+
Routes (per implementation plan):
|
| 4 |
+
GET /api/widget/token?slug={author_slug}&domain={domain}
|
| 5 |
+
GET /api/widget/{author_slug} β Serves embeddable widget HTML
|
| 6 |
+
|
| 7 |
+
RULE: Token must be verified on every widget chat request.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
|
| 12 |
+
from fastapi import APIRouter, HTTPException, Query
|
| 13 |
+
from fastapi.responses import FileResponse, JSONResponse
|
| 14 |
+
|
| 15 |
+
from app.core.security import sign_widget_token, verify_widget_token
|
| 16 |
+
|
| 17 |
+
router = APIRouter()
|
| 18 |
+
|
| 19 |
+
WIDGET_HTML = Path(__file__).resolve().parent.parent.parent / "static" / "addon.html"
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
@router.get("/token")
|
| 23 |
+
async def get_widget_token(
|
| 24 |
+
slug: str = Query(..., description="Author slug"),
|
| 25 |
+
domain: str = Query(..., description="Author's website domain"),
|
| 26 |
+
):
|
| 27 |
+
"""Generate a signed HMAC widget token for embedding.
|
| 28 |
+
|
| 29 |
+
The token encodes the author slug and allowed domain.
|
| 30 |
+
Embed via: <script src=".../static/widget.js" data-token="TOKEN">
|
| 31 |
+
"""
|
| 32 |
+
token = sign_widget_token(slug, domain)
|
| 33 |
+
return {"token": token, "author_slug": slug, "domain": domain}
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
@router.get("/{author_slug}")
|
| 37 |
+
async def get_widget_page(author_slug: str):
|
| 38 |
+
"""Serve the embeddable widget HTML page for an author."""
|
| 39 |
+
if not WIDGET_HTML.is_file():
|
| 40 |
+
raise HTTPException(404, "Widget not found")
|
| 41 |
+
return FileResponse(
|
| 42 |
+
WIDGET_HTML,
|
| 43 |
+
headers={
|
| 44 |
+
"Access-Control-Allow-Origin": "*",
|
| 45 |
+
"Cache-Control": "no-cache, no-store, must-revalidate",
|
| 46 |
+
},
|
| 47 |
+
)
|
{backend/app β app}/config.py
RENAMED
|
File without changes
|
app/core/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# Core package
|
{backend/alembic/versions β app/core/access}/__init__.py
RENAMED
|
File without changes
|
{backend/app β app}/core/access/subscription.py
RENAMED
|
File without changes
|
{backend/app β app}/core/access/token_crypto.py
RENAMED
|
File without changes
|
{backend/app β app}/core/access/totp.py
RENAMED
|
File without changes
|
{backend/app β app}/core/chroma_client.py
RENAMED
|
File without changes
|
{backend/app/core/security β app/core}/password.py
RENAMED
|
File without changes
|
app/core/security.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Author RAG β Core Security Utilities.
|
| 2 |
+
|
| 3 |
+
JWT creation/verification, TOTP, HMAC widget tokens, bcrypt password hashing.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import hashlib
|
| 7 |
+
import hmac
|
| 8 |
+
import base64
|
| 9 |
+
import json
|
| 10 |
+
from datetime import datetime, timedelta, timezone
|
| 11 |
+
from typing import Any
|
| 12 |
+
|
| 13 |
+
import bcrypt
|
| 14 |
+
import pyotp
|
| 15 |
+
from jose import JWTError, jwt
|
| 16 |
+
|
| 17 |
+
from app.config import get_settings
|
| 18 |
+
|
| 19 |
+
cfg = get_settings()
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
# βββ Password ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 23 |
+
|
| 24 |
+
def hash_password(plain: str) -> str:
|
| 25 |
+
"""Hash a plaintext password with bcrypt."""
|
| 26 |
+
return bcrypt.hashpw(plain.encode(), bcrypt.gensalt()).decode()
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def verify_password(plain: str, hashed: str) -> bool:
|
| 30 |
+
"""Timing-safe bcrypt password verification."""
|
| 31 |
+
return bcrypt.checkpw(plain.encode(), hashed.encode())
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
# βββ JWT βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 35 |
+
|
| 36 |
+
def create_jwt(data: dict[str, Any], expires_minutes: int | None = None) -> str:
|
| 37 |
+
"""Create a signed JWT access token."""
|
| 38 |
+
payload = data.copy()
|
| 39 |
+
expire = datetime.now(timezone.utc) + timedelta(
|
| 40 |
+
minutes=expires_minutes or cfg.ACCESS_TOKEN_EXPIRE_MINUTES
|
| 41 |
+
)
|
| 42 |
+
payload["exp"] = expire
|
| 43 |
+
return jwt.encode(payload, cfg.SECRET_KEY, algorithm=cfg.JWT_ALGORITHM)
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def verify_jwt(token: str) -> dict:
|
| 47 |
+
"""Verify and decode a JWT. Raises JWTError on failure."""
|
| 48 |
+
return jwt.decode(token, cfg.SECRET_KEY, algorithms=[cfg.JWT_ALGORITHM])
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
# βββ TOTP βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 52 |
+
|
| 53 |
+
def generate_totp_secret() -> str:
|
| 54 |
+
"""Generate a new base32 TOTP secret."""
|
| 55 |
+
return pyotp.random_base32()
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def verify_totp(token: str, secret: str) -> bool:
|
| 59 |
+
"""Verify a 6-digit TOTP code against the secret."""
|
| 60 |
+
totp = pyotp.TOTP(secret)
|
| 61 |
+
return totp.verify(token, valid_window=1)
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def get_totp_provisioning_uri(secret: str, account_name: str) -> str:
|
| 65 |
+
"""Get the otpauth:// URI for QR code generation."""
|
| 66 |
+
totp = pyotp.TOTP(secret)
|
| 67 |
+
return totp.provisioning_uri(
|
| 68 |
+
name=account_name,
|
| 69 |
+
issuer_name=cfg.SUPERADMIN_2FA_ISSUER,
|
| 70 |
+
)
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
# βββ HMAC Widget Tokens βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 74 |
+
|
| 75 |
+
def sign_widget_token(author_slug: str, domain: str) -> str:
|
| 76 |
+
"""Generate a signed widget token for embedding on author's site.
|
| 77 |
+
|
| 78 |
+
Format: base64url(payload).base64url(hmac)
|
| 79 |
+
"""
|
| 80 |
+
payload = json.dumps(
|
| 81 |
+
{"author_slug": author_slug, "domain": domain},
|
| 82 |
+
separators=(",", ":"),
|
| 83 |
+
)
|
| 84 |
+
encoded = base64.urlsafe_b64encode(payload.encode()).decode().rstrip("=")
|
| 85 |
+
sig = _hmac_sign(encoded)
|
| 86 |
+
return f"{encoded}.{sig}"
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def verify_widget_token(token: str) -> dict:
|
| 90 |
+
"""Verify a widget token and return the payload.
|
| 91 |
+
|
| 92 |
+
Raises ValueError if invalid.
|
| 93 |
+
"""
|
| 94 |
+
try:
|
| 95 |
+
parts = token.split(".")
|
| 96 |
+
if len(parts) != 2:
|
| 97 |
+
raise ValueError("Malformed token")
|
| 98 |
+
encoded_payload, provided_sig = parts
|
| 99 |
+
expected_sig = _hmac_sign(encoded_payload)
|
| 100 |
+
if not hmac.compare_digest(expected_sig, provided_sig):
|
| 101 |
+
raise ValueError("Invalid signature")
|
| 102 |
+
padded = encoded_payload + "=" * (4 - len(encoded_payload) % 4)
|
| 103 |
+
return json.loads(base64.urlsafe_b64decode(padded).decode())
|
| 104 |
+
except ValueError:
|
| 105 |
+
raise
|
| 106 |
+
except Exception as e:
|
| 107 |
+
raise ValueError(f"Token verification failed: {e}") from e
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def _hmac_sign(payload: str) -> str:
|
| 111 |
+
secret = (cfg.SUBSCRIPTION_SECRET or cfg.SECRET_KEY).encode()
|
| 112 |
+
sig = hmac.new(secret, payload.encode(), hashlib.sha256).digest()
|
| 113 |
+
return base64.urlsafe_b64encode(sig).decode().rstrip("=")
|
{backend/app/core/security β app/core}/subscription_crypto.py
RENAMED
|
File without changes
|
{backend/app β app}/dependencies.py
RENAMED
|
File without changes
|
{backend/app β app/exceptions}/__init__.py
RENAMED
|
File without changes
|
{backend/app β app}/exceptions/access.py
RENAMED
|
File without changes
|
{backend/app β app}/exceptions/auth.py
RENAMED
|
File without changes
|
{backend/app β app}/exceptions/base.py
RENAMED
|
File without changes
|
{backend/app β app}/exceptions/ingestion.py
RENAMED
|
File without changes
|
{backend/app β app}/exceptions/rag.py
RENAMED
|
File without changes
|
{backend/app β app}/main.py
RENAMED
|
@@ -1,26 +1,33 @@
|
|
| 1 |
"""Author RAG Chatbot SaaS β FastAPI Application Factory.
|
| 2 |
|
| 3 |
-
Routing
|
| 4 |
-
/
|
| 5 |
-
/
|
| 6 |
-
/
|
| 7 |
-
/api/
|
| 8 |
-
/
|
| 9 |
-
/
|
|
|
|
|
|
|
|
|
|
| 10 |
"""
|
| 11 |
|
| 12 |
from contextlib import asynccontextmanager
|
| 13 |
from pathlib import Path
|
| 14 |
from typing import AsyncGenerator
|
| 15 |
|
| 16 |
-
import gradio as gr
|
| 17 |
import structlog
|
| 18 |
from fastapi import FastAPI, HTTPException, Request
|
| 19 |
from fastapi.middleware.cors import CORSMiddleware
|
| 20 |
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
|
| 21 |
from fastapi.staticfiles import StaticFiles
|
| 22 |
|
| 23 |
-
from app.api.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
from app.config import get_settings
|
| 25 |
from app.exceptions.base import AppException
|
| 26 |
from app.exceptions.auth import AuthError, InvalidTokenError, ExpiredTokenError, AccountLockedError
|
|
@@ -30,32 +37,40 @@ from app.exceptions.access import (
|
|
| 30 |
)
|
| 31 |
from app.middleware.logging_middleware import LoggingMiddleware
|
| 32 |
from app.middleware.rate_limit_middleware import RateLimitMiddleware
|
| 33 |
-
from app.ui_gradio import demo
|
| 34 |
|
| 35 |
logger = structlog.get_logger(__name__)
|
| 36 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
|
| 38 |
|
| 39 |
@asynccontextmanager
|
| 40 |
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
| 41 |
-
"""
|
| 42 |
cfg = get_settings()
|
| 43 |
-
logger.info("Starting
|
| 44 |
if Path("/data").exists():
|
| 45 |
logger.info("Persistent HF storage detected at /data")
|
| 46 |
else:
|
| 47 |
-
logger.warning("No /data mount β data
|
| 48 |
yield
|
| 49 |
-
logger.info("
|
| 50 |
|
| 51 |
|
| 52 |
-
def
|
| 53 |
-
"""Create and configure the
|
| 54 |
cfg = get_settings()
|
| 55 |
|
| 56 |
app = FastAPI(
|
| 57 |
-
title="
|
| 58 |
-
description=
|
|
|
|
|
|
|
|
|
|
| 59 |
version="1.0.0",
|
| 60 |
docs_url="/docs",
|
| 61 |
redoc_url="/redoc",
|
|
@@ -71,7 +86,7 @@ def create_fastapi_app() -> FastAPI:
|
|
| 71 |
|
| 72 |
|
| 73 |
def _register_middleware(app: FastAPI, cfg) -> None:
|
| 74 |
-
"""Register
|
| 75 |
app.add_middleware(LoggingMiddleware)
|
| 76 |
app.add_middleware(RateLimitMiddleware)
|
| 77 |
app.add_middleware(
|
|
@@ -84,117 +99,120 @@ def _register_middleware(app: FastAPI, cfg) -> None:
|
|
| 84 |
|
| 85 |
|
| 86 |
def _register_exception_handlers(app: FastAPI) -> None:
|
| 87 |
-
"""Map domain exceptions to
|
| 88 |
|
| 89 |
@app.exception_handler(AuthError)
|
| 90 |
-
async def
|
| 91 |
-
return JSONResponse(
|
| 92 |
|
| 93 |
@app.exception_handler(InvalidTokenError)
|
| 94 |
-
async def
|
| 95 |
-
return JSONResponse(
|
| 96 |
|
| 97 |
@app.exception_handler(ExpiredTokenError)
|
| 98 |
-
async def
|
| 99 |
-
return JSONResponse(
|
| 100 |
|
| 101 |
@app.exception_handler(AccountLockedError)
|
| 102 |
-
async def
|
| 103 |
-
return JSONResponse(
|
| 104 |
|
| 105 |
@app.exception_handler(SubscriptionExpiredError)
|
| 106 |
-
async def
|
| 107 |
-
return JSONResponse(
|
| 108 |
|
| 109 |
@app.exception_handler(AccessRevokedError)
|
| 110 |
-
async def
|
| 111 |
-
return JSONResponse(
|
| 112 |
|
| 113 |
@app.exception_handler(InvalidSubscriptionTokenError)
|
| 114 |
-
async def
|
| 115 |
-
return JSONResponse(
|
| 116 |
|
| 117 |
@app.exception_handler(SubscriptionNotFoundError)
|
| 118 |
-
async def
|
| 119 |
-
return JSONResponse(
|
| 120 |
|
| 121 |
@app.exception_handler(BudgetExhaustedError)
|
| 122 |
-
async def
|
| 123 |
-
return JSONResponse(
|
| 124 |
-
status_code=200,
|
| 125 |
-
content={"message": "I'm taking a short break to recharge! Check back soon."},
|
| 126 |
-
)
|
| 127 |
|
| 128 |
@app.exception_handler(AppException)
|
| 129 |
-
async def
|
| 130 |
-
logger.error("
|
| 131 |
-
return JSONResponse(
|
| 132 |
|
| 133 |
@app.exception_handler(Exception)
|
| 134 |
-
async def
|
| 135 |
-
logger.error("Unhandled exception", error=str(
|
| 136 |
-
return JSONResponse(
|
| 137 |
|
| 138 |
|
| 139 |
def _register_routers(app: FastAPI) -> None:
|
| 140 |
-
"""Register all API routers
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
app.include_router(
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
app.include_router(
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
app.include_router(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 150 |
|
| 151 |
|
| 152 |
def _register_public_routes(app: FastAPI) -> None:
|
| 153 |
-
"""
|
| 154 |
|
| 155 |
if STATIC_DIR.is_dir():
|
| 156 |
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
| 157 |
|
| 158 |
@app.get("/", include_in_schema=False)
|
| 159 |
async def root() -> RedirectResponse:
|
| 160 |
-
|
| 161 |
-
return RedirectResponse(url="/ui")
|
| 162 |
|
| 163 |
-
@app.get("/
|
| 164 |
-
async def
|
| 165 |
-
"""
|
| 166 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 167 |
|
| 168 |
@app.get("/widget", include_in_schema=False)
|
| 169 |
async def get_widget() -> FileResponse:
|
| 170 |
-
"""Embeddable
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
raise HTTPException(status_code=404, detail="Widget not found")
|
| 174 |
return FileResponse(
|
| 175 |
-
|
| 176 |
headers={
|
| 177 |
"Access-Control-Allow-Origin": "*",
|
| 178 |
"Cache-Control": "no-cache, no-store, must-revalidate",
|
| 179 |
},
|
| 180 |
)
|
| 181 |
|
| 182 |
-
@app.get("/admin", include_in_schema=False)
|
| 183 |
-
async def get_admin() -> FileResponse:
|
| 184 |
-
"""Author admin portal."""
|
| 185 |
-
path = STATIC_DIR / "admin.html"
|
| 186 |
-
if not path.is_file():
|
| 187 |
-
raise HTTPException(status_code=404, detail="Admin panel not found")
|
| 188 |
-
return FileResponse(path)
|
| 189 |
-
|
| 190 |
@app.get("/health", tags=["Health"])
|
| 191 |
async def health_check() -> dict:
|
| 192 |
-
"""System health
|
| 193 |
from app.dependencies import check_health
|
| 194 |
return await check_health()
|
| 195 |
|
| 196 |
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
# Mount Gradio at /ui β MUST be last (wraps the FastAPI app for HF Spaces)
|
| 200 |
-
app = gr.mount_gradio_app(fastapi_app, demo, path="/ui")
|
|
|
|
| 1 |
"""Author RAG Chatbot SaaS β FastAPI Application Factory.
|
| 2 |
|
| 3 |
+
Routing (per implementation plan):
|
| 4 |
+
/api/chat/{author_slug} β Widget chat endpoint
|
| 5 |
+
/api/admin/{author_slug}/* β Per-author admin endpoints
|
| 6 |
+
/api/super/* β SuperAdmin endpoints (TOTP required)
|
| 7 |
+
/api/widget/token β Signed widget token generation
|
| 8 |
+
/admin β Author Admin SPA
|
| 9 |
+
/superadmin β SuperAdmin SPA
|
| 10 |
+
/widget β Embeddable widget test page
|
| 11 |
+
/health β Health check
|
| 12 |
+
/ β Redirect to /admin
|
| 13 |
"""
|
| 14 |
|
| 15 |
from contextlib import asynccontextmanager
|
| 16 |
from pathlib import Path
|
| 17 |
from typing import AsyncGenerator
|
| 18 |
|
|
|
|
| 19 |
import structlog
|
| 20 |
from fastapi import FastAPI, HTTPException, Request
|
| 21 |
from fastapi.middleware.cors import CORSMiddleware
|
| 22 |
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
|
| 23 |
from fastapi.staticfiles import StaticFiles
|
| 24 |
|
| 25 |
+
from app.api.chat import router as chat_router
|
| 26 |
+
from app.api.ingest import router as ingest_router
|
| 27 |
+
from app.api.widget import router as widget_router
|
| 28 |
+
from app.api.schemas_router import router as schemas_router
|
| 29 |
+
from app.admin.router import router as admin_router
|
| 30 |
+
from app.superadmin.router import router as superadmin_router
|
| 31 |
from app.config import get_settings
|
| 32 |
from app.exceptions.base import AppException
|
| 33 |
from app.exceptions.auth import AuthError, InvalidTokenError, ExpiredTokenError, AccountLockedError
|
|
|
|
| 37 |
)
|
| 38 |
from app.middleware.logging_middleware import LoggingMiddleware
|
| 39 |
from app.middleware.rate_limit_middleware import RateLimitMiddleware
|
|
|
|
| 40 |
|
| 41 |
logger = structlog.get_logger(__name__)
|
| 42 |
+
|
| 43 |
+
# Directories
|
| 44 |
+
ROOT_DIR = Path(__file__).resolve().parent.parent
|
| 45 |
+
STATIC_DIR = ROOT_DIR / "static"
|
| 46 |
+
ADMIN_TEMPLATE = ROOT_DIR / "app" / "admin" / "templates" / "admin.html"
|
| 47 |
+
SUPERADMIN_TEMPLATE = ROOT_DIR / "app" / "superadmin" / "templates" / "superadmin.html"
|
| 48 |
+
WIDGET_TEMPLATE = ROOT_DIR / "static" / "addon.html"
|
| 49 |
|
| 50 |
|
| 51 |
@asynccontextmanager
|
| 52 |
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
| 53 |
+
"""Application startup / shutdown lifecycle."""
|
| 54 |
cfg = get_settings()
|
| 55 |
+
logger.info("Starting AuthorRAG", env=cfg.APP_ENV)
|
| 56 |
if Path("/data").exists():
|
| 57 |
logger.info("Persistent HF storage detected at /data")
|
| 58 |
else:
|
| 59 |
+
logger.warning("No /data mount β data will not persist across rebuilds")
|
| 60 |
yield
|
| 61 |
+
logger.info("AuthorRAG shutting down")
|
| 62 |
|
| 63 |
|
| 64 |
+
def create_app() -> FastAPI:
|
| 65 |
+
"""Create and configure the FastAPI application."""
|
| 66 |
cfg = get_settings()
|
| 67 |
|
| 68 |
app = FastAPI(
|
| 69 |
+
title="Author RAG β AI Book Sales Chatbot SaaS",
|
| 70 |
+
description=(
|
| 71 |
+
"Multi-tenant SaaS platform that deploys a persuasive AI chatbot "
|
| 72 |
+
"on any author's website. Powered by a 12-step RAG pipeline."
|
| 73 |
+
),
|
| 74 |
version="1.0.0",
|
| 75 |
docs_url="/docs",
|
| 76 |
redoc_url="/redoc",
|
|
|
|
| 86 |
|
| 87 |
|
| 88 |
def _register_middleware(app: FastAPI, cfg) -> None:
|
| 89 |
+
"""Register middleware (outermost first)."""
|
| 90 |
app.add_middleware(LoggingMiddleware)
|
| 91 |
app.add_middleware(RateLimitMiddleware)
|
| 92 |
app.add_middleware(
|
|
|
|
| 99 |
|
| 100 |
|
| 101 |
def _register_exception_handlers(app: FastAPI) -> None:
|
| 102 |
+
"""Map domain exceptions to HTTP responses."""
|
| 103 |
|
| 104 |
@app.exception_handler(AuthError)
|
| 105 |
+
async def _(r: Request, e: AuthError) -> JSONResponse:
|
| 106 |
+
return JSONResponse(401, {"detail": str(e)})
|
| 107 |
|
| 108 |
@app.exception_handler(InvalidTokenError)
|
| 109 |
+
async def _(r: Request, e: InvalidTokenError) -> JSONResponse:
|
| 110 |
+
return JSONResponse(401, {"detail": "Invalid or malformed token"})
|
| 111 |
|
| 112 |
@app.exception_handler(ExpiredTokenError)
|
| 113 |
+
async def _(r: Request, e: ExpiredTokenError) -> JSONResponse:
|
| 114 |
+
return JSONResponse(401, {"detail": "Token has expired"})
|
| 115 |
|
| 116 |
@app.exception_handler(AccountLockedError)
|
| 117 |
+
async def _(r: Request, e: AccountLockedError) -> JSONResponse:
|
| 118 |
+
return JSONResponse(423, {"detail": str(e)})
|
| 119 |
|
| 120 |
@app.exception_handler(SubscriptionExpiredError)
|
| 121 |
+
async def _(r: Request, e: SubscriptionExpiredError) -> JSONResponse:
|
| 122 |
+
return JSONResponse(403, {"detail": "This chatbot service is currently unavailable"})
|
| 123 |
|
| 124 |
@app.exception_handler(AccessRevokedError)
|
| 125 |
+
async def _(r: Request, e: AccessRevokedError) -> JSONResponse:
|
| 126 |
+
return JSONResponse(403, {"detail": "This chatbot service is currently unavailable"})
|
| 127 |
|
| 128 |
@app.exception_handler(InvalidSubscriptionTokenError)
|
| 129 |
+
async def _(r: Request, e: InvalidSubscriptionTokenError) -> JSONResponse:
|
| 130 |
+
return JSONResponse(401, {"detail": "Invalid or expired subscription token"})
|
| 131 |
|
| 132 |
@app.exception_handler(SubscriptionNotFoundError)
|
| 133 |
+
async def _(r: Request, e: SubscriptionNotFoundError) -> JSONResponse:
|
| 134 |
+
return JSONResponse(403, {"detail": "No active subscription. Contact support."})
|
| 135 |
|
| 136 |
@app.exception_handler(BudgetExhaustedError)
|
| 137 |
+
async def _(r: Request, e: BudgetExhaustedError) -> JSONResponse:
|
| 138 |
+
return JSONResponse(200, {"message": "I'm taking a short break to recharge! Check back soon."})
|
|
|
|
|
|
|
|
|
|
| 139 |
|
| 140 |
@app.exception_handler(AppException)
|
| 141 |
+
async def _(r: Request, e: AppException) -> JSONResponse:
|
| 142 |
+
logger.error("AppException", error=str(e), path=r.url.path)
|
| 143 |
+
return JSONResponse(500, {"detail": "An internal error occurred"})
|
| 144 |
|
| 145 |
@app.exception_handler(Exception)
|
| 146 |
+
async def _(r: Request, e: Exception) -> JSONResponse:
|
| 147 |
+
logger.error("Unhandled exception", error=str(e), path=r.url.path, exc_info=True)
|
| 148 |
+
return JSONResponse(500, {"detail": "An internal error occurred"})
|
| 149 |
|
| 150 |
|
| 151 |
def _register_routers(app: FastAPI) -> None:
|
| 152 |
+
"""Register all API routers with plan-correct prefixes."""
|
| 153 |
+
|
| 154 |
+
# Chat widget API: /api/chat/{author_slug}
|
| 155 |
+
app.include_router(chat_router, prefix="/api/chat", tags=["Chat"])
|
| 156 |
+
|
| 157 |
+
# Ingestion API: /api/admin/{author_slug}/books/*
|
| 158 |
+
app.include_router(ingest_router, prefix="/api/admin", tags=["Ingestion"])
|
| 159 |
+
|
| 160 |
+
# Widget token API: /api/widget/*
|
| 161 |
+
app.include_router(widget_router, prefix="/api/widget", tags=["Widget"])
|
| 162 |
+
|
| 163 |
+
# Author admin API: /api/admin/{author_slug}/*
|
| 164 |
+
app.include_router(admin_router, prefix="/api/admin", tags=["Admin"])
|
| 165 |
+
|
| 166 |
+
# SuperAdmin API: /api/super/*
|
| 167 |
+
app.include_router(superadmin_router, prefix="/api/super", tags=["SuperAdmin"])
|
| 168 |
+
|
| 169 |
+
# Misc schema/auth routes: /api/*
|
| 170 |
+
app.include_router(schemas_router, prefix="/api", tags=["Auth"])
|
| 171 |
|
| 172 |
|
| 173 |
def _register_public_routes(app: FastAPI) -> None:
|
| 174 |
+
"""Static files and HTML page routes."""
|
| 175 |
|
| 176 |
if STATIC_DIR.is_dir():
|
| 177 |
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
| 178 |
|
| 179 |
@app.get("/", include_in_schema=False)
|
| 180 |
async def root() -> RedirectResponse:
|
| 181 |
+
return RedirectResponse(url="/admin")
|
|
|
|
| 182 |
|
| 183 |
+
@app.get("/admin", include_in_schema=False)
|
| 184 |
+
async def get_admin() -> FileResponse:
|
| 185 |
+
"""Author Admin SPA."""
|
| 186 |
+
if not ADMIN_TEMPLATE.is_file():
|
| 187 |
+
raise HTTPException(404, "Admin panel not found")
|
| 188 |
+
return FileResponse(ADMIN_TEMPLATE)
|
| 189 |
+
|
| 190 |
+
@app.get("/superadmin", include_in_schema=False)
|
| 191 |
+
async def get_superadmin() -> FileResponse:
|
| 192 |
+
"""SuperAdmin SPA (TOTP required β enforced client-side and API-side)."""
|
| 193 |
+
if not SUPERADMIN_TEMPLATE.is_file():
|
| 194 |
+
raise HTTPException(404, "SuperAdmin panel not found")
|
| 195 |
+
return FileResponse(SUPERADMIN_TEMPLATE)
|
| 196 |
|
| 197 |
@app.get("/widget", include_in_schema=False)
|
| 198 |
async def get_widget() -> FileResponse:
|
| 199 |
+
"""Embeddable widget test/demo page."""
|
| 200 |
+
if not WIDGET_TEMPLATE.is_file():
|
| 201 |
+
raise HTTPException(404, "Widget not found")
|
|
|
|
| 202 |
return FileResponse(
|
| 203 |
+
WIDGET_TEMPLATE,
|
| 204 |
headers={
|
| 205 |
"Access-Control-Allow-Origin": "*",
|
| 206 |
"Cache-Control": "no-cache, no-store, must-revalidate",
|
| 207 |
},
|
| 208 |
)
|
| 209 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 210 |
@app.get("/health", tags=["Health"])
|
| 211 |
async def health_check() -> dict:
|
| 212 |
+
"""System health β verifies DB, Redis, ChromaDB."""
|
| 213 |
from app.dependencies import check_health
|
| 214 |
return await check_health()
|
| 215 |
|
| 216 |
|
| 217 |
+
# Application instance
|
| 218 |
+
app = create_app()
|
|
|
|
|
|
{backend/app/api β app/middleware}/__init__.py
RENAMED
|
File without changes
|
{backend/app β app}/middleware/logging_middleware.py
RENAMED
|
File without changes
|
{backend/app β app}/middleware/rate_limit_middleware.py
RENAMED
|
File without changes
|
app/models/__init__.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Author RAG β SQLAlchemy model exports."""
|
| 2 |
+
from app.models.base import Base
|
| 3 |
+
from app.models.user import User
|
| 4 |
+
from app.models.book import Book
|
| 5 |
+
from app.models.document import Document
|
| 6 |
+
from app.models.chat_session import ChatSession
|
| 7 |
+
from app.models.link import Link
|
| 8 |
+
from app.models.client_access import ClientAccess
|
| 9 |
+
from app.models.analytics import AnalyticsEvent
|
| 10 |
+
|
| 11 |
+
__all__ = [
|
| 12 |
+
"Base", "User", "Book", "Document",
|
| 13 |
+
"ChatSession", "Link", "ClientAccess", "AnalyticsEvent",
|
| 14 |
+
]
|
{backend/app β app}/models/analytics.py
RENAMED
|
File without changes
|
{backend/app β app}/models/base.py
RENAMED
|
File without changes
|
{backend/app β app}/models/book.py
RENAMED
|
File without changes
|
{backend/app β app}/models/chat_session.py
RENAMED
|
File without changes
|
{backend/app β app}/models/client_access.py
RENAMED
|
File without changes
|
{backend/app β app}/models/document.py
RENAMED
|
File without changes
|
{backend/app β app}/models/link.py
RENAMED
|
File without changes
|
{backend/app β app}/models/user.py
RENAMED
|
File without changes
|
{backend/app/api/v1 β app/repositories}/__init__.py
RENAMED
|
File without changes
|
{backend/app β app}/repositories/access_repo.py
RENAMED
|
File without changes
|
{backend/app β app}/repositories/audit_repo.py
RENAMED
|
File without changes
|
{backend/app β app}/repositories/base.py
RENAMED
|
File without changes
|
{backend/app β app}/repositories/book_repo.py
RENAMED
|
File without changes
|
{backend/app β app}/repositories/document_repo.py
RENAMED
|
File without changes
|
{backend/app β app}/repositories/link_repo.py
RENAMED
|
File without changes
|
{backend/app β app}/repositories/user_repo.py
RENAMED
|
File without changes
|
{backend/app/core β app/schemas}/__init__.py
RENAMED
|
File without changes
|