AuthorBot commited on
Commit
772f852
Β·
1 Parent(s): ebc0310

Restructure project for HF Spaces deployment

Browse files
This view is limited to 50 files because it contains too many changes. Β  See raw diff
Files changed (50) hide show
  1. backend/.env.example β†’ .env.example +0 -0
  2. app.py +12 -0
  3. app/__init__.py +1 -0
  4. app/admin/__init__.py +1 -0
  5. app/admin/router.py +223 -0
  6. app/admin/templates/admin.html +367 -0
  7. app/api/__init__.py +1 -0
  8. backend/app/api/v1/chatbot.py β†’ app/api/chat.py +95 -44
  9. app/api/ingest.py +164 -0
  10. app/api/schemas_router.py +64 -0
  11. app/api/widget.py +47 -0
  12. {backend/app β†’ app}/config.py +0 -0
  13. app/core/__init__.py +1 -0
  14. {backend/alembic/versions β†’ app/core/access}/__init__.py +0 -0
  15. {backend/app β†’ app}/core/access/subscription.py +0 -0
  16. {backend/app β†’ app}/core/access/token_crypto.py +0 -0
  17. {backend/app β†’ app}/core/access/totp.py +0 -0
  18. {backend/app β†’ app}/core/chroma_client.py +0 -0
  19. {backend/app/core/security β†’ app/core}/password.py +0 -0
  20. app/core/security.py +113 -0
  21. {backend/app/core/security β†’ app/core}/subscription_crypto.py +0 -0
  22. {backend/app β†’ app}/dependencies.py +0 -0
  23. {backend/app β†’ app/exceptions}/__init__.py +0 -0
  24. {backend/app β†’ app}/exceptions/access.py +0 -0
  25. {backend/app β†’ app}/exceptions/auth.py +0 -0
  26. {backend/app β†’ app}/exceptions/base.py +0 -0
  27. {backend/app β†’ app}/exceptions/ingestion.py +0 -0
  28. {backend/app β†’ app}/exceptions/rag.py +0 -0
  29. {backend/app β†’ app}/main.py +101 -83
  30. {backend/app/api β†’ app/middleware}/__init__.py +0 -0
  31. {backend/app β†’ app}/middleware/logging_middleware.py +0 -0
  32. {backend/app β†’ app}/middleware/rate_limit_middleware.py +0 -0
  33. app/models/__init__.py +14 -0
  34. {backend/app β†’ app}/models/analytics.py +0 -0
  35. {backend/app β†’ app}/models/base.py +0 -0
  36. {backend/app β†’ app}/models/book.py +0 -0
  37. {backend/app β†’ app}/models/chat_session.py +0 -0
  38. {backend/app β†’ app}/models/client_access.py +0 -0
  39. {backend/app β†’ app}/models/document.py +0 -0
  40. {backend/app β†’ app}/models/link.py +0 -0
  41. {backend/app β†’ app}/models/user.py +0 -0
  42. {backend/app/api/v1 β†’ app/repositories}/__init__.py +0 -0
  43. {backend/app β†’ app}/repositories/access_repo.py +0 -0
  44. {backend/app β†’ app}/repositories/audit_repo.py +0 -0
  45. {backend/app β†’ app}/repositories/base.py +0 -0
  46. {backend/app β†’ app}/repositories/book_repo.py +0 -0
  47. {backend/app β†’ app}/repositories/document_repo.py +0 -0
  48. {backend/app β†’ app}/repositories/link_repo.py +0 -0
  49. {backend/app β†’ app}/repositories/user_repo.py +0 -0
  50. {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 Chatbot SaaS β€” Chatbot API Router.
2
-
3
- Public-facing endpoint used by the embedded widget.
4
- RULE: Requires X-Subscription-Token header β€” validated by get_subscription_author.
5
- RULE: Visitor fingerprint is generated server-side (not trusted from client).
 
 
 
 
 
 
 
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.core.rag.pipeline import run_pipeline
15
- from app.core.session.manager import SessionManager
16
  from app.repositories.book_repo import BookRepository
17
  from app.schemas.chatbot import ChatRequest, ChatResponse, SessionInitResponse
18
 
19
  router = APIRouter()
20
 
21
 
22
- @router.post("/session", response_model=SessionInitResponse, status_code=201)
 
 
 
 
 
 
 
 
 
 
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. Returns session ID and active book list."""
30
  session_id = str(uuid.uuid4())
31
- visitor_fp = _generate_fingerprint(request)
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.core.analytics.geo import get_geo_info
40
- from app.core.analytics.tracker import parse_device_info
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("/chat", response_model=ChatResponse)
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 full RAG pipeline."""
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
- # Persist message to DB + fire analytics
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.post("/chat/{session_id}/select-book", status_code=200)
134
- async def select_book(
 
135
  session_id: str,
136
- book_id: str,
137
  author=Depends(get_subscription_author),
138
  redis=Depends(get_redis),
139
  ):
140
- """Update the book selection for a session."""
141
  session_mgr = SessionManager(redis)
142
- await session_mgr.set_selected_book(session_id, author.id, book_id)
143
- return {"message": "Book selected", "book_id": book_id}
144
 
145
 
146
- def _generate_fingerprint(request: Request) -> str:
147
- """Generate an anonymous visitor fingerprint from request attributes.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
148
 
149
- No PII β€” combines IP hash + User-Agent hash + Accept-Language.
 
 
 
 
 
 
 
 
 
 
150
 
151
- Args:
152
- request: FastAPI request.
153
 
154
- Returns:
155
- Short hex fingerprint string.
156
- """
157
- import hashlib
158
- components = [
159
- request.client.host if request.client else "unknown",
160
- request.headers.get("User-Agent", ""),
161
- request.headers.get("Accept-Language", ""),
162
- request.headers.get("Accept-Encoding", ""),
163
- ]
164
- combined = "|".join(components)
165
- return hashlib.sha256(combined.encode()).hexdigest()[:32]
 
 
 
 
 
 
 
 
 
 
 
 
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 follows the Hugging Face Space pattern:
4
- /ui β€” Gradio chat (main entry for visitors)
5
- /widget β€” Embeddable chat widget (CORS-friendly)
6
- /admin β€” Admin portal
7
- /api/v1 β€” REST API
8
- /health β€” Health check
9
- / β€” Redirect to /ui
 
 
 
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.v1 import auth, books, documents, chatbot, analytics, settings, links, superadmin
 
 
 
 
 
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
- STATIC_DIR = Path(__file__).resolve().parent.parent / "static"
 
 
 
 
 
 
37
 
38
 
39
  @asynccontextmanager
40
  async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
41
- """Handle application startup and shutdown lifecycle."""
42
  cfg = get_settings()
43
- logger.info("Starting AuthorBot API", env=cfg.APP_ENV)
44
  if Path("/data").exists():
45
  logger.info("Persistent HF storage detected at /data")
46
  else:
47
- logger.warning("No /data mount β€” data may not persist across rebuilds")
48
  yield
49
- logger.info("AuthorBot API shutting down")
50
 
51
 
52
- def create_fastapi_app() -> FastAPI:
53
- """Create and configure the inner FastAPI application."""
54
  cfg = get_settings()
55
 
56
  app = FastAPI(
57
- title="AuthorBot API",
58
- description="RAG Chatbot SaaS for Author Websites",
 
 
 
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 all middleware in correct order (outermost first)."""
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 clean HTTP responses."""
88
 
89
  @app.exception_handler(AuthError)
90
- async def auth_error_handler(request: Request, exc: AuthError) -> JSONResponse:
91
- return JSONResponse(status_code=401, content={"detail": str(exc)})
92
 
93
  @app.exception_handler(InvalidTokenError)
94
- async def invalid_token_handler(request: Request, exc: InvalidTokenError) -> JSONResponse:
95
- return JSONResponse(status_code=401, content={"detail": "Invalid or malformed token"})
96
 
97
  @app.exception_handler(ExpiredTokenError)
98
- async def expired_token_handler(request: Request, exc: ExpiredTokenError) -> JSONResponse:
99
- return JSONResponse(status_code=401, content={"detail": "Token has expired"})
100
 
101
  @app.exception_handler(AccountLockedError)
102
- async def account_locked_handler(request: Request, exc: AccountLockedError) -> JSONResponse:
103
- return JSONResponse(status_code=423, content={"detail": str(exc)})
104
 
105
  @app.exception_handler(SubscriptionExpiredError)
106
- async def subscription_expired_handler(request: Request, exc: SubscriptionExpiredError) -> JSONResponse:
107
- return JSONResponse(status_code=403, content={"detail": "This chatbot service is currently unavailable"})
108
 
109
  @app.exception_handler(AccessRevokedError)
110
- async def access_revoked_handler(request: Request, exc: AccessRevokedError) -> JSONResponse:
111
- return JSONResponse(status_code=403, content={"detail": "This chatbot service is currently unavailable"})
112
 
113
  @app.exception_handler(InvalidSubscriptionTokenError)
114
- async def invalid_subscription_token_handler(request: Request, exc: InvalidSubscriptionTokenError) -> JSONResponse:
115
- return JSONResponse(status_code=401, content={"detail": "Invalid or expired subscription token"})
116
 
117
  @app.exception_handler(SubscriptionNotFoundError)
118
- async def subscription_not_found_handler(request: Request, exc: SubscriptionNotFoundError) -> JSONResponse:
119
- return JSONResponse(status_code=403, content={"detail": "No active subscription. Contact support."})
120
 
121
  @app.exception_handler(BudgetExhaustedError)
122
- async def budget_exhausted_handler(request: Request, exc: BudgetExhaustedError) -> JSONResponse:
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 app_exception_handler(request: Request, exc: AppException) -> JSONResponse:
130
- logger.error("Unhandled AppException", error=str(exc), path=request.url.path)
131
- return JSONResponse(status_code=500, content={"detail": "An internal error occurred"})
132
 
133
  @app.exception_handler(Exception)
134
- async def unhandled_exception_handler(request: Request, exc: Exception) -> JSONResponse:
135
- logger.error("Unhandled exception", error=str(exc), path=request.url.path, exc_info=True)
136
- return JSONResponse(status_code=500, content={"detail": "An internal error occurred"})
137
 
138
 
139
  def _register_routers(app: FastAPI) -> None:
140
- """Register all API routers under /api/v1."""
141
- prefix = "/api/v1"
142
- app.include_router(auth.router, prefix=f"{prefix}/auth", tags=["Authentication"])
143
- app.include_router(books.router, prefix=f"{prefix}/books", tags=["Books"])
144
- app.include_router(documents.router, prefix=f"{prefix}/documents", tags=["Documents"])
145
- app.include_router(chatbot.router, prefix=f"{prefix}/chatbot", tags=["Chatbot"])
146
- app.include_router(analytics.router, prefix=f"{prefix}/analytics", tags=["Analytics"])
147
- app.include_router(settings.router, prefix=f"{prefix}/settings", tags=["Settings"])
148
- app.include_router(links.router, prefix=f"{prefix}/links", tags=["Links"])
149
- app.include_router(superadmin.router, prefix=f"{prefix}/superadmin", tags=["Super Admin"])
 
 
 
 
 
 
 
 
 
150
 
151
 
152
  def _register_public_routes(app: FastAPI) -> None:
153
- """Public-facing pages and static assets (registered before Gradio mount)."""
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
- """Space home β†’ Gradio chat UI."""
161
- return RedirectResponse(url="/ui")
162
 
163
- @app.get("/demo", include_in_schema=False)
164
- async def demo_redirect() -> RedirectResponse:
165
- """Legacy demo URL β†’ widget."""
166
- return RedirectResponse(url="/widget")
 
 
 
 
 
 
 
 
 
167
 
168
  @app.get("/widget", include_in_schema=False)
169
  async def get_widget() -> FileResponse:
170
- """Embeddable chat widget with CORS headers for external sites."""
171
- path = STATIC_DIR / "addon.html"
172
- if not path.is_file():
173
- raise HTTPException(status_code=404, detail="Widget not found")
174
  return FileResponse(
175
- path,
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 check β€” verifies DB, Redis, and ChromaDB."""
193
  from app.dependencies import check_health
194
  return await check_health()
195
 
196
 
197
- fastapi_app = create_fastapi_app()
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