Spaces:
Running
Running
AuthorBot commited on
Commit ·
685ab2e
1
Parent(s): 70337a5
Refactor: 10/10 modularity — pipeline/ package, admin/routers/ split, guards single source of truth
Browse files- app/admin/router.py +14 -1336
- app/admin/routers/__init__.py +1 -0
- app/admin/routers/analytics.py +137 -0
- app/admin/routers/books.py +136 -0
- app/admin/routers/dashboard.py +347 -0
- app/admin/routers/exports.py +162 -0
- app/admin/routers/links.py +48 -0
- app/admin/routers/qa.py +268 -0
- app/admin/routers/settings.py +268 -0
- app/services/pipeline/__init__.py +17 -0
- app/services/pipeline/cache.py +52 -0
- app/services/pipeline/core.py +364 -0
- app/services/pipeline/dedup.py +47 -0
- app/services/pipeline/guards.py +87 -0
- app/services/pipeline/handlers.py +280 -0
- app/services/pipeline/helpers.py +249 -0
- app/services/rag_pipeline.py +9 -903
app/admin/router.py
CHANGED
|
@@ -1,1344 +1,22 @@
|
|
| 1 |
-
"""
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
| 5 |
|
| 6 |
-
|
| 7 |
-
|
| 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
|
| 25 |
-
from sqlalchemy import Integer
|
| 26 |
-
from sqlalchemy.ext.asyncio import AsyncSession
|
| 27 |
|
| 28 |
-
from app.
|
| 29 |
-
from app.repositories.book_repo import BookRepository
|
| 30 |
-
from app.repositories.audit_repo import AuditRepository
|
| 31 |
-
from app.schemas.admin import (
|
| 32 |
-
AnnotateRequest,
|
| 33 |
-
FlagRequest,
|
| 34 |
-
PasswordChangeRequest,
|
| 35 |
-
ProfileUpdate,
|
| 36 |
-
WidgetConfigUpdate,
|
| 37 |
-
)
|
| 38 |
|
| 39 |
router = APIRouter()
|
| 40 |
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
)
|
| 48 |
-
"""Return high-level dashboard stats for the author."""
|
| 49 |
-
book_repo = BookRepository(db)
|
| 50 |
-
books = await book_repo.list_for_author(current_user.id)
|
| 51 |
-
|
| 52 |
-
from sqlalchemy import func, select
|
| 53 |
-
from app.models.chat_session import ChatSession
|
| 54 |
-
from app.models.analytics import AnalyticsEvent
|
| 55 |
-
|
| 56 |
-
total_sessions = await db.scalar(
|
| 57 |
-
select(func.count()).where(ChatSession.author_id == current_user.id)
|
| 58 |
-
) or 0
|
| 59 |
-
|
| 60 |
-
return {
|
| 61 |
-
"total_books": len(books),
|
| 62 |
-
"active_books": sum(1 for b in books if b.status == "active"),
|
| 63 |
-
"total_sessions": total_sessions,
|
| 64 |
-
"total_clicks": 0,
|
| 65 |
-
}
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
@router.get("/{author_slug}/sessions")
|
| 69 |
-
async def list_sessions(
|
| 70 |
-
author_slug: str,
|
| 71 |
-
limit: int = Query(50, ge=1, le=200),
|
| 72 |
-
offset: int = 0,
|
| 73 |
-
current_user=Depends(get_current_author_scoped),
|
| 74 |
-
db: AsyncSession = Depends(get_db),
|
| 75 |
-
):
|
| 76 |
-
"""List reader sessions with pagination."""
|
| 77 |
-
from sqlalchemy import select, desc
|
| 78 |
-
from app.models.chat_session import ChatSession
|
| 79 |
-
|
| 80 |
-
result = await db.execute(
|
| 81 |
-
select(ChatSession)
|
| 82 |
-
.where(ChatSession.author_id == current_user.id)
|
| 83 |
-
.order_by(desc(ChatSession.created_at))
|
| 84 |
-
.offset(offset)
|
| 85 |
-
.limit(limit)
|
| 86 |
-
)
|
| 87 |
-
sessions = result.scalars().all()
|
| 88 |
-
return {
|
| 89 |
-
"sessions": [
|
| 90 |
-
{
|
| 91 |
-
"id": s.id,
|
| 92 |
-
"visitor_fingerprint": s.visitor_fingerprint,
|
| 93 |
-
"visitor_name": s.visitor_name,
|
| 94 |
-
"country": s.country_name,
|
| 95 |
-
"device_type": s.device_type,
|
| 96 |
-
"turn_count": s.turn_count,
|
| 97 |
-
"rating": s.rating,
|
| 98 |
-
"created_at": s.created_at.isoformat() if s.created_at else None,
|
| 99 |
-
"blocked": s.blocked,
|
| 100 |
-
}
|
| 101 |
-
for s in sessions
|
| 102 |
-
]
|
| 103 |
-
}
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
@router.get("/{author_slug}/sessions/search")
|
| 107 |
-
async def search_sessions(
|
| 108 |
-
author_slug: str,
|
| 109 |
-
q: str = Query("", min_length=0, max_length=200),
|
| 110 |
-
page: int = Query(1, ge=1),
|
| 111 |
-
per_page: int = Query(20, ge=1, le=100),
|
| 112 |
-
current_user=Depends(get_current_author_scoped),
|
| 113 |
-
db: AsyncSession = Depends(get_db),
|
| 114 |
-
):
|
| 115 |
-
"""Full-text search across chat messages for the author."""
|
| 116 |
-
from sqlalchemy import select, func, desc, distinct
|
| 117 |
-
from app.models.chat_session import ChatSession, ChatMessage
|
| 118 |
-
|
| 119 |
-
if not q.strip():
|
| 120 |
-
return {"sessions": [], "total": 0}
|
| 121 |
-
|
| 122 |
-
search_term = f"%{q.strip().lower()}%"
|
| 123 |
-
|
| 124 |
-
matching_sessions = (
|
| 125 |
-
select(distinct(ChatMessage.session_id))
|
| 126 |
-
.join(ChatSession, ChatMessage.session_id == ChatSession.id)
|
| 127 |
-
.where(
|
| 128 |
-
ChatSession.author_id == current_user.id,
|
| 129 |
-
func.lower(ChatMessage.content).like(search_term),
|
| 130 |
-
)
|
| 131 |
-
)
|
| 132 |
-
|
| 133 |
-
total = await db.scalar(
|
| 134 |
-
select(func.count()).select_from(matching_sessions.subquery())
|
| 135 |
-
) or 0
|
| 136 |
-
|
| 137 |
-
offset = (page - 1) * per_page
|
| 138 |
-
result = await db.execute(
|
| 139 |
-
select(ChatSession)
|
| 140 |
-
.where(
|
| 141 |
-
ChatSession.author_id == current_user.id,
|
| 142 |
-
ChatSession.id.in_(matching_sessions),
|
| 143 |
-
)
|
| 144 |
-
.order_by(desc(ChatSession.created_at))
|
| 145 |
-
.offset(offset)
|
| 146 |
-
.limit(per_page)
|
| 147 |
-
)
|
| 148 |
-
sessions = result.scalars().all()
|
| 149 |
-
|
| 150 |
-
return {
|
| 151 |
-
"sessions": [
|
| 152 |
-
{
|
| 153 |
-
"id": s.id,
|
| 154 |
-
"visitor_fingerprint": s.visitor_fingerprint[:8] + "...",
|
| 155 |
-
"visitor_name": s.visitor_name,
|
| 156 |
-
"country": s.country_name,
|
| 157 |
-
"device_type": s.device_type,
|
| 158 |
-
"turn_count": s.turn_count,
|
| 159 |
-
"rating": s.rating,
|
| 160 |
-
"created_at": s.created_at.isoformat() if s.created_at else None,
|
| 161 |
-
}
|
| 162 |
-
for s in sessions
|
| 163 |
-
],
|
| 164 |
-
"total": total,
|
| 165 |
-
"query": q,
|
| 166 |
-
"page": page,
|
| 167 |
-
}
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
@router.post("/{author_slug}/sessions/{session_id}/block")
|
| 171 |
-
async def block_session(
|
| 172 |
-
author_slug: str,
|
| 173 |
-
session_id: str,
|
| 174 |
-
current_user=Depends(get_current_author_scoped),
|
| 175 |
-
db: AsyncSession = Depends(get_db),
|
| 176 |
-
):
|
| 177 |
-
"""Block a reader session (403 on next message)."""
|
| 178 |
-
from sqlalchemy import update
|
| 179 |
-
from app.models.chat_session import ChatSession
|
| 180 |
-
await db.execute(
|
| 181 |
-
update(ChatSession)
|
| 182 |
-
.where(ChatSession.id == session_id, ChatSession.author_id == current_user.id)
|
| 183 |
-
.values(blocked=True)
|
| 184 |
-
)
|
| 185 |
-
await db.commit()
|
| 186 |
-
return {"message": "Session blocked"}
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
@router.post("/{author_slug}/sessions/{session_id}/unblock")
|
| 190 |
-
async def unblock_session(
|
| 191 |
-
author_slug: str,
|
| 192 |
-
session_id: str,
|
| 193 |
-
current_user=Depends(get_current_author_scoped),
|
| 194 |
-
db: AsyncSession = Depends(get_db),
|
| 195 |
-
):
|
| 196 |
-
"""Unblock a reader session."""
|
| 197 |
-
from sqlalchemy import update
|
| 198 |
-
from app.models.chat_session import ChatSession
|
| 199 |
-
await db.execute(
|
| 200 |
-
update(ChatSession)
|
| 201 |
-
.where(ChatSession.id == session_id, ChatSession.author_id == current_user.id)
|
| 202 |
-
.values(blocked=False)
|
| 203 |
-
)
|
| 204 |
-
await db.commit()
|
| 205 |
-
return {"message": "Session unblocked"}
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
@router.get("/{author_slug}/books")
|
| 209 |
-
async def list_books(
|
| 210 |
-
author_slug: str,
|
| 211 |
-
current_user=Depends(get_current_author_scoped),
|
| 212 |
-
db: AsyncSession = Depends(get_db),
|
| 213 |
-
):
|
| 214 |
-
"""List all books for the author."""
|
| 215 |
-
book_repo = BookRepository(db)
|
| 216 |
-
books = await book_repo.list_for_author(current_user.id)
|
| 217 |
-
return {
|
| 218 |
-
"books": [
|
| 219 |
-
{
|
| 220 |
-
"id": b.id,
|
| 221 |
-
"title": b.title,
|
| 222 |
-
"genre": b.genre,
|
| 223 |
-
"status": b.status,
|
| 224 |
-
"chunk_count": getattr(b, "chunk_count", 0),
|
| 225 |
-
"ai_summary": getattr(b, "ai_summary", ""),
|
| 226 |
-
"cover_path": getattr(b, "cover_path", None),
|
| 227 |
-
"cover_thumbnail": getattr(b, "cover_thumbnail_path", None),
|
| 228 |
-
"cover_medium": getattr(b, "cover_medium_path", None),
|
| 229 |
-
"buy_url": getattr(b, "buy_url", None),
|
| 230 |
-
}
|
| 231 |
-
for b in books
|
| 232 |
-
]
|
| 233 |
-
}
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
@router.delete("/{author_slug}/books/{book_id}")
|
| 237 |
-
async def delete_book(
|
| 238 |
-
author_slug: str,
|
| 239 |
-
book_id: str,
|
| 240 |
-
current_user=Depends(get_current_author_scoped),
|
| 241 |
-
db: AsyncSession = Depends(get_db),
|
| 242 |
-
):
|
| 243 |
-
"""Delete a book and its ChromaDB collection."""
|
| 244 |
-
book_repo = BookRepository(db)
|
| 245 |
-
book = await book_repo.get_by_id(book_id)
|
| 246 |
-
if not book or book.author_id != current_user.id:
|
| 247 |
-
from fastapi import HTTPException
|
| 248 |
-
raise HTTPException(404, "Book not found")
|
| 249 |
-
|
| 250 |
-
from app.services.embeddings import delete_book_embeddings
|
| 251 |
-
delete_book_embeddings(current_user.id, book_id)
|
| 252 |
-
|
| 253 |
-
await db.delete(book)
|
| 254 |
-
await db.commit()
|
| 255 |
-
return {"message": "Book deleted"}
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
@router.post("/{author_slug}/books/{book_id}/cover")
|
| 259 |
-
async def upload_book_cover(
|
| 260 |
-
author_slug: str,
|
| 261 |
-
book_id: str,
|
| 262 |
-
current_user=Depends(get_current_author_scoped),
|
| 263 |
-
db: AsyncSession = Depends(get_db),
|
| 264 |
-
file: UploadFile = File(...),
|
| 265 |
-
):
|
| 266 |
-
"""Upload a book cover image (JPG/PNG/WebP, max 5MB)."""
|
| 267 |
-
from fastapi import HTTPException
|
| 268 |
-
import os
|
| 269 |
-
|
| 270 |
-
book_repo = BookRepository(db)
|
| 271 |
-
book = await book_repo.get_by_id(book_id)
|
| 272 |
-
if not book or book.author_id != current_user.id:
|
| 273 |
-
raise HTTPException(404, "Book not found")
|
| 274 |
-
|
| 275 |
-
# Validate file type
|
| 276 |
-
allowed_types = {"image/jpeg", "image/png", "image/webp"}
|
| 277 |
-
if file.content_type not in allowed_types:
|
| 278 |
-
raise HTTPException(400, f"Invalid file type. Allowed: JPG, PNG, WebP")
|
| 279 |
-
|
| 280 |
-
# Read and validate size
|
| 281 |
-
contents = await file.read()
|
| 282 |
-
if len(contents) > 5 * 1024 * 1024: # 5MB
|
| 283 |
-
raise HTTPException(400, "File too large. Maximum: 5MB")
|
| 284 |
-
|
| 285 |
-
# Validate it's actually an image by checking magic bytes
|
| 286 |
-
if contents[:2] not in (b'\xff\xd8', b'\x89P') and contents[:4] != b'RIFF':
|
| 287 |
-
raise HTTPException(400, "File does not appear to be a valid image")
|
| 288 |
-
|
| 289 |
-
# Create directories
|
| 290 |
-
cover_dir = f"/data/covers/{current_user.id}/{book_id}"
|
| 291 |
-
os.makedirs(cover_dir, exist_ok=True)
|
| 292 |
-
|
| 293 |
-
# Save original
|
| 294 |
-
ext = file.filename.rsplit('.', 1)[-1].lower() if file.filename else 'jpg'
|
| 295 |
-
if ext not in ('jpg', 'jpeg', 'png', 'webp'):
|
| 296 |
-
ext = 'jpg'
|
| 297 |
-
original_path = f"{cover_dir}/original.{ext}"
|
| 298 |
-
with open(original_path, "wb") as f:
|
| 299 |
-
f.write(contents)
|
| 300 |
-
|
| 301 |
-
# Try to create resized versions (PIL optional)
|
| 302 |
-
thumbnail_path = original_path
|
| 303 |
-
medium_path = original_path
|
| 304 |
-
try:
|
| 305 |
-
from PIL import Image
|
| 306 |
-
import io
|
| 307 |
-
|
| 308 |
-
img = Image.open(io.BytesIO(contents))
|
| 309 |
-
img = img.convert('RGB') # Ensure RGB, strip EXIF
|
| 310 |
-
|
| 311 |
-
# Thumbnail (80x120)
|
| 312 |
-
thumb = img.copy()
|
| 313 |
-
thumb.thumbnail((80, 120), Image.LANCZOS)
|
| 314 |
-
thumbnail_path = f"{cover_dir}/thumb.webp"
|
| 315 |
-
thumb.save(thumbnail_path, 'WEBP', quality=80)
|
| 316 |
-
|
| 317 |
-
# Medium (300x450)
|
| 318 |
-
med = img.copy()
|
| 319 |
-
med.thumbnail((300, 450), Image.LANCZOS)
|
| 320 |
-
medium_path = f"{cover_dir}/medium.webp"
|
| 321 |
-
med.save(medium_path, 'WEBP', quality=85)
|
| 322 |
-
except ImportError:
|
| 323 |
-
pass # PIL not available, use original for all sizes
|
| 324 |
-
|
| 325 |
-
# Update book record
|
| 326 |
-
book.cover_path = original_path
|
| 327 |
-
book.cover_thumbnail_path = thumbnail_path
|
| 328 |
-
book.cover_medium_path = medium_path
|
| 329 |
-
await db.commit()
|
| 330 |
-
|
| 331 |
-
return {
|
| 332 |
-
"message": "Cover uploaded",
|
| 333 |
-
"cover_path": original_path,
|
| 334 |
-
"cover_thumbnail": thumbnail_path,
|
| 335 |
-
"cover_medium": medium_path,
|
| 336 |
-
}
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
@router.get("/{author_slug}/analytics")
|
| 340 |
-
async def get_analytics(
|
| 341 |
-
author_slug: str,
|
| 342 |
-
days: int = Query(30, ge=1, le=365),
|
| 343 |
-
current_user=Depends(get_current_author_scoped),
|
| 344 |
-
db: AsyncSession = Depends(get_db),
|
| 345 |
-
):
|
| 346 |
-
"""Return analytics data for dashboard charts."""
|
| 347 |
-
from datetime import datetime, timedelta, timezone
|
| 348 |
-
from sqlalchemy import select, func
|
| 349 |
-
from app.models.analytics import AnalyticsEvent
|
| 350 |
-
|
| 351 |
-
since = datetime.now(timezone.utc) - timedelta(days=days)
|
| 352 |
-
result = await db.execute(
|
| 353 |
-
select(
|
| 354 |
-
func.date(AnalyticsEvent.timestamp).label("date"),
|
| 355 |
-
func.count().label("count"),
|
| 356 |
-
)
|
| 357 |
-
.where(
|
| 358 |
-
AnalyticsEvent.author_id == current_user.id,
|
| 359 |
-
AnalyticsEvent.timestamp >= since,
|
| 360 |
-
)
|
| 361 |
-
.group_by(func.date(AnalyticsEvent.timestamp))
|
| 362 |
-
.order_by(func.date(AnalyticsEvent.timestamp))
|
| 363 |
-
)
|
| 364 |
-
daily = [{"date": str(row.date), "count": row.count} for row in result]
|
| 365 |
-
return {"daily_sessions": daily, "period_days": days}
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
@router.get("/{author_slug}/analytics/funnel")
|
| 369 |
-
async def get_conversion_funnel(
|
| 370 |
-
author_slug: str,
|
| 371 |
-
days: int = Query(30, ge=1, le=365),
|
| 372 |
-
current_user=Depends(get_current_author_scoped),
|
| 373 |
-
db: AsyncSession = Depends(get_db),
|
| 374 |
-
):
|
| 375 |
-
"""Return conversion funnel data derived from analytics events."""
|
| 376 |
-
from datetime import datetime, timedelta, timezone
|
| 377 |
-
from sqlalchemy import select, func
|
| 378 |
-
from app.models.analytics import AnalyticsEvent
|
| 379 |
-
from app.models.chat_session import ChatSession
|
| 380 |
-
|
| 381 |
-
since = datetime.now(timezone.utc) - timedelta(days=days)
|
| 382 |
-
|
| 383 |
-
# Stage 1: Total sessions (= widget_load approximation)
|
| 384 |
-
total_sessions = await db.scalar(
|
| 385 |
-
select(func.count()).where(
|
| 386 |
-
ChatSession.author_id == current_user.id,
|
| 387 |
-
ChatSession.created_at >= since,
|
| 388 |
-
)
|
| 389 |
-
) or 0
|
| 390 |
-
|
| 391 |
-
# Stage 2: Sessions with at least 1 analytics event (= chat_started)
|
| 392 |
-
chat_started = await db.scalar(
|
| 393 |
-
select(func.count(func.distinct(AnalyticsEvent.session_id))).where(
|
| 394 |
-
AnalyticsEvent.author_id == current_user.id,
|
| 395 |
-
AnalyticsEvent.timestamp >= since,
|
| 396 |
-
)
|
| 397 |
-
) or 0
|
| 398 |
-
|
| 399 |
-
# Stage 3: Sessions that discussed a book (book_id is set)
|
| 400 |
-
book_discussed = await db.scalar(
|
| 401 |
-
select(func.count(func.distinct(AnalyticsEvent.session_id))).where(
|
| 402 |
-
AnalyticsEvent.author_id == current_user.id,
|
| 403 |
-
AnalyticsEvent.book_id.isnot(None),
|
| 404 |
-
AnalyticsEvent.timestamp >= since,
|
| 405 |
-
)
|
| 406 |
-
) or 0
|
| 407 |
-
|
| 408 |
-
# Stage 4: Link shown
|
| 409 |
-
link_shown = await db.scalar(
|
| 410 |
-
select(func.count()).where(
|
| 411 |
-
AnalyticsEvent.author_id == current_user.id,
|
| 412 |
-
AnalyticsEvent.link_shown == True,
|
| 413 |
-
AnalyticsEvent.timestamp >= since,
|
| 414 |
-
)
|
| 415 |
-
) or 0
|
| 416 |
-
|
| 417 |
-
# Stage 5: Link clicked
|
| 418 |
-
link_clicked = await db.scalar(
|
| 419 |
-
select(func.count()).where(
|
| 420 |
-
AnalyticsEvent.author_id == current_user.id,
|
| 421 |
-
AnalyticsEvent.link_clicked == True,
|
| 422 |
-
AnalyticsEvent.timestamp >= since,
|
| 423 |
-
)
|
| 424 |
-
) or 0
|
| 425 |
-
|
| 426 |
-
funnel = [
|
| 427 |
-
{"stage": "Widget Loads", "count": total_sessions},
|
| 428 |
-
{"stage": "Chats Started", "count": chat_started},
|
| 429 |
-
{"stage": "Book Discussed", "count": book_discussed},
|
| 430 |
-
{"stage": "Link Shown", "count": link_shown},
|
| 431 |
-
{"stage": "Link Clicked", "count": link_clicked},
|
| 432 |
-
]
|
| 433 |
-
return {"funnel": funnel, "period_days": days}
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
@router.get("/{author_slug}/analytics/intents")
|
| 437 |
-
async def get_intent_distribution(
|
| 438 |
-
author_slug: str,
|
| 439 |
-
days: int = Query(30, ge=1, le=365),
|
| 440 |
-
current_user=Depends(get_current_author_scoped),
|
| 441 |
-
db: AsyncSession = Depends(get_db),
|
| 442 |
-
):
|
| 443 |
-
"""Return distribution of detected intents."""
|
| 444 |
-
from datetime import datetime, timedelta, timezone
|
| 445 |
-
from sqlalchemy import select, func
|
| 446 |
-
from app.models.analytics import AnalyticsEvent
|
| 447 |
-
|
| 448 |
-
since = datetime.now(timezone.utc) - timedelta(days=days)
|
| 449 |
-
result = await db.execute(
|
| 450 |
-
select(
|
| 451 |
-
AnalyticsEvent.intent,
|
| 452 |
-
func.count().label("count"),
|
| 453 |
-
)
|
| 454 |
-
.where(
|
| 455 |
-
AnalyticsEvent.author_id == current_user.id,
|
| 456 |
-
AnalyticsEvent.intent.isnot(None),
|
| 457 |
-
AnalyticsEvent.timestamp >= since,
|
| 458 |
-
)
|
| 459 |
-
.group_by(AnalyticsEvent.intent)
|
| 460 |
-
.order_by(func.count().desc())
|
| 461 |
-
.limit(20)
|
| 462 |
-
)
|
| 463 |
-
intents = [{"intent": row.intent or "unknown", "count": row.count} for row in result]
|
| 464 |
-
return {"intents": intents, "period_days": days}
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
@router.post("/{author_slug}/password")
|
| 469 |
-
async def change_password(
|
| 470 |
-
author_slug: str,
|
| 471 |
-
body: PasswordChangeRequest,
|
| 472 |
-
current_user=Depends(get_current_author_scoped),
|
| 473 |
-
db: AsyncSession = Depends(get_db),
|
| 474 |
-
):
|
| 475 |
-
"""Self-service password change. R-010: Validates via Pydantic schema."""
|
| 476 |
-
import bcrypt
|
| 477 |
-
try:
|
| 478 |
-
valid = bcrypt.checkpw(body.current_password.encode(), current_user.password_hash.encode())
|
| 479 |
-
except Exception:
|
| 480 |
-
valid = False
|
| 481 |
-
if not valid:
|
| 482 |
-
from fastapi import HTTPException
|
| 483 |
-
raise HTTPException(400, "Current password is incorrect")
|
| 484 |
-
current_user.password_hash = bcrypt.hashpw(body.new_password.encode(), bcrypt.gensalt(12)).decode()
|
| 485 |
-
await db.commit()
|
| 486 |
-
return {"message": "Password updated"}
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
# ── Widget Configuration ──────────────────────────────────────────────────────
|
| 490 |
-
|
| 491 |
-
|
| 492 |
-
@router.get("/{author_slug}/widget-config")
|
| 493 |
-
async def get_widget_config(
|
| 494 |
-
author_slug: str,
|
| 495 |
-
current_user=Depends(get_current_author_scoped),
|
| 496 |
-
):
|
| 497 |
-
"""Return current widget configuration."""
|
| 498 |
-
return {
|
| 499 |
-
"bot_name": current_user.bot_name,
|
| 500 |
-
"welcome_message": current_user.welcome_message,
|
| 501 |
-
"theme": current_user.widget_theme,
|
| 502 |
-
"position": current_user.widget_position,
|
| 503 |
-
"auto_open_delay": current_user.widget_auto_open_delay,
|
| 504 |
-
"is_active": current_user.chatbot_is_active,
|
| 505 |
-
}
|
| 506 |
-
|
| 507 |
-
|
| 508 |
-
@router.put("/{author_slug}/widget-config")
|
| 509 |
-
async def update_widget_config(
|
| 510 |
-
author_slug: str,
|
| 511 |
-
body: WidgetConfigUpdate,
|
| 512 |
-
current_user=Depends(get_current_author_scoped),
|
| 513 |
-
db: AsyncSession = Depends(get_db),
|
| 514 |
-
):
|
| 515 |
-
"""Update widget configuration. R-029: Validated via Pydantic schema."""
|
| 516 |
-
update_map = {
|
| 517 |
-
"bot_name": "bot_name",
|
| 518 |
-
"welcome_message": "welcome_message",
|
| 519 |
-
"theme": "widget_theme",
|
| 520 |
-
"position": "widget_position",
|
| 521 |
-
"auto_open_delay": "widget_auto_open_delay",
|
| 522 |
-
"is_active": "chatbot_is_active",
|
| 523 |
-
}
|
| 524 |
-
for field_name, attr in update_map.items():
|
| 525 |
-
val = getattr(body, field_name, None)
|
| 526 |
-
if val is not None:
|
| 527 |
-
setattr(current_user, attr, val)
|
| 528 |
-
await db.commit()
|
| 529 |
-
return {"message": "Widget config updated"}
|
| 530 |
-
|
| 531 |
-
|
| 532 |
-
# ── Profile ───────────────────────────────────────────────────────────────────
|
| 533 |
-
|
| 534 |
-
|
| 535 |
-
@router.get("/{author_slug}/profile")
|
| 536 |
-
async def get_profile(
|
| 537 |
-
author_slug: str,
|
| 538 |
-
current_user=Depends(get_current_author_scoped),
|
| 539 |
-
):
|
| 540 |
-
"""Return current author profile."""
|
| 541 |
-
return {
|
| 542 |
-
"full_name": current_user.full_name or "",
|
| 543 |
-
"email": current_user.email,
|
| 544 |
-
"website": current_user.website_url or "",
|
| 545 |
-
"bio": current_user.bio or "",
|
| 546 |
-
"timezone": current_user.timezone,
|
| 547 |
-
}
|
| 548 |
-
|
| 549 |
-
|
| 550 |
-
@router.put("/{author_slug}/profile")
|
| 551 |
-
async def update_profile(
|
| 552 |
-
author_slug: str,
|
| 553 |
-
body: ProfileUpdate,
|
| 554 |
-
current_user=Depends(get_current_author_scoped),
|
| 555 |
-
db: AsyncSession = Depends(get_db),
|
| 556 |
-
):
|
| 557 |
-
"""Update author profile. R-029: Validated via Pydantic schema."""
|
| 558 |
-
if body.full_name is not None:
|
| 559 |
-
current_user.full_name = body.full_name
|
| 560 |
-
if body.website is not None:
|
| 561 |
-
current_user.website_url = body.website
|
| 562 |
-
if body.bio is not None:
|
| 563 |
-
current_user.bio = body.bio
|
| 564 |
-
if body.timezone is not None:
|
| 565 |
-
current_user.timezone = body.timezone
|
| 566 |
-
await db.commit()
|
| 567 |
-
return {"message": "Profile updated"}
|
| 568 |
-
|
| 569 |
-
|
| 570 |
-
# ── Bot Personality ───────────────────────────────────────────────────────────
|
| 571 |
-
|
| 572 |
-
|
| 573 |
-
@router.get("/{author_slug}/personality")
|
| 574 |
-
async def get_personality(
|
| 575 |
-
author_slug: str,
|
| 576 |
-
current_user=Depends(get_current_author_scoped),
|
| 577 |
-
):
|
| 578 |
-
"""Return bot personality settings."""
|
| 579 |
-
return {
|
| 580 |
-
"response_style": current_user.response_style,
|
| 581 |
-
"fallback_message": current_user.fallback_message,
|
| 582 |
-
"out_of_scope_message": current_user.out_of_scope_message,
|
| 583 |
-
"welcome_message": current_user.welcome_message,
|
| 584 |
-
}
|
| 585 |
-
|
| 586 |
-
|
| 587 |
-
@router.put("/{author_slug}/personality")
|
| 588 |
-
async def update_personality(
|
| 589 |
-
author_slug: str,
|
| 590 |
-
body: dict,
|
| 591 |
-
current_user=Depends(get_current_author_scoped),
|
| 592 |
-
db: AsyncSession = Depends(get_db),
|
| 593 |
-
):
|
| 594 |
-
"""Update bot personality settings."""
|
| 595 |
-
valid_styles = ["balanced", "formal", "casual", "enthusiastic"]
|
| 596 |
-
if "response_style" in body:
|
| 597 |
-
style = str(body["response_style"])
|
| 598 |
-
if style not in valid_styles:
|
| 599 |
-
from fastapi import HTTPException
|
| 600 |
-
raise HTTPException(400, f"Invalid style. Must be one of: {valid_styles}")
|
| 601 |
-
current_user.response_style = style
|
| 602 |
-
if "fallback_message" in body:
|
| 603 |
-
current_user.fallback_message = str(body["fallback_message"])[:500]
|
| 604 |
-
if "out_of_scope_message" in body:
|
| 605 |
-
current_user.out_of_scope_message = str(body["out_of_scope_message"])[:500]
|
| 606 |
-
await db.commit()
|
| 607 |
-
return {"message": "Personality settings updated"}
|
| 608 |
-
|
| 609 |
-
|
| 610 |
-
# ── Notification Preferences ──────────────────────────────────────────────────
|
| 611 |
-
|
| 612 |
-
|
| 613 |
-
@router.get("/{author_slug}/notifications")
|
| 614 |
-
async def get_notifications(
|
| 615 |
-
author_slug: str,
|
| 616 |
-
current_user=Depends(get_current_author_scoped),
|
| 617 |
-
):
|
| 618 |
-
"""Return notification preferences."""
|
| 619 |
-
return {
|
| 620 |
-
"weekly_digest": current_user.notify_weekly_digest,
|
| 621 |
-
"token_alerts": current_user.notify_token_alerts,
|
| 622 |
-
"new_conversation": current_user.notify_new_conversation,
|
| 623 |
-
"subscription_expiry": current_user.notify_subscription_expiry,
|
| 624 |
-
}
|
| 625 |
-
|
| 626 |
-
|
| 627 |
-
@router.put("/{author_slug}/notifications")
|
| 628 |
-
async def update_notifications(
|
| 629 |
-
author_slug: str,
|
| 630 |
-
body: dict,
|
| 631 |
-
current_user=Depends(get_current_author_scoped),
|
| 632 |
-
db: AsyncSession = Depends(get_db),
|
| 633 |
-
):
|
| 634 |
-
"""Update notification preferences."""
|
| 635 |
-
mapping = {
|
| 636 |
-
"weekly_digest": "notify_weekly_digest",
|
| 637 |
-
"token_alerts": "notify_token_alerts",
|
| 638 |
-
"new_conversation": "notify_new_conversation",
|
| 639 |
-
"subscription_expiry": "notify_subscription_expiry",
|
| 640 |
-
}
|
| 641 |
-
for key, attr in mapping.items():
|
| 642 |
-
if key in body:
|
| 643 |
-
setattr(current_user, attr, bool(body[key]))
|
| 644 |
-
await db.commit()
|
| 645 |
-
return {"message": "Notification preferences updated"}
|
| 646 |
-
|
| 647 |
-
|
| 648 |
-
# ── Embed Token ───────────────────────────────────────────────────────────────
|
| 649 |
-
|
| 650 |
-
|
| 651 |
-
@router.get("/{author_slug}/embed-token")
|
| 652 |
-
async def get_embed_token(
|
| 653 |
-
author_slug: str,
|
| 654 |
-
current_user=Depends(get_current_author_scoped),
|
| 655 |
-
db: AsyncSession = Depends(get_db),
|
| 656 |
-
):
|
| 657 |
-
"""Return the active subscription token for embedding the widget.
|
| 658 |
-
Token is regenerated from stored grant data — never stored in plaintext.
|
| 659 |
-
"""
|
| 660 |
-
from sqlalchemy import select
|
| 661 |
-
from datetime import datetime, timezone
|
| 662 |
-
from app.models.client_access import ClientAccess
|
| 663 |
-
from app.core.access.token_crypto import create_subscription_token
|
| 664 |
-
|
| 665 |
-
now = datetime.now(timezone.utc)
|
| 666 |
-
now_naive = now.replace(tzinfo=None) # For comparisons with naive DB datetimes
|
| 667 |
-
|
| 668 |
-
result = await db.execute(
|
| 669 |
-
select(ClientAccess)
|
| 670 |
-
.where(
|
| 671 |
-
ClientAccess.author_id == current_user.id,
|
| 672 |
-
ClientAccess.is_revoked == False,
|
| 673 |
-
ClientAccess.expires_at > now_naive,
|
| 674 |
-
)
|
| 675 |
-
.order_by(ClientAccess.expires_at.desc())
|
| 676 |
-
.limit(1)
|
| 677 |
-
)
|
| 678 |
-
access = result.scalar_one_or_none()
|
| 679 |
-
if not access:
|
| 680 |
-
# Return clean 200 with active=False instead of 403 so UI can show a proper message
|
| 681 |
-
return {
|
| 682 |
-
"active": False,
|
| 683 |
-
"token": None,
|
| 684 |
-
"message": "No active subscription. Contact your administrator to activate your chatbot.",
|
| 685 |
-
}
|
| 686 |
-
|
| 687 |
-
token = create_subscription_token(
|
| 688 |
-
author_id=current_user.id,
|
| 689 |
-
grant_id=access.id,
|
| 690 |
-
granted_at=access.granted_at,
|
| 691 |
-
expires_at=access.expires_at,
|
| 692 |
-
)
|
| 693 |
-
|
| 694 |
-
# Normalize expiry to naive UTC for subtraction
|
| 695 |
-
exp = access.expires_at
|
| 696 |
-
if exp and exp.tzinfo is not None:
|
| 697 |
-
exp = exp.replace(tzinfo=None)
|
| 698 |
-
days_remaining = max(0, (exp - now_naive).days) if exp else 0
|
| 699 |
-
|
| 700 |
-
from app.services.token_budget import tokens_remaining
|
| 701 |
-
|
| 702 |
-
return {
|
| 703 |
-
"active": True,
|
| 704 |
-
"token": token,
|
| 705 |
-
"grant_id": access.id,
|
| 706 |
-
"plan": access.plan,
|
| 707 |
-
"expires_at": access.expires_at.isoformat(),
|
| 708 |
-
"days_remaining": days_remaining,
|
| 709 |
-
"tokens_remaining": tokens_remaining(access),
|
| 710 |
-
}
|
| 711 |
-
|
| 712 |
-
|
| 713 |
-
# ── Smart Links ───────────────────────────────────────────────────────────────
|
| 714 |
-
|
| 715 |
-
|
| 716 |
-
@router.put("/{author_slug}/smart-links/{book_id}")
|
| 717 |
-
async def update_smart_link(
|
| 718 |
-
author_slug: str,
|
| 719 |
-
book_id: str,
|
| 720 |
-
body: dict,
|
| 721 |
-
current_user=Depends(get_current_author_scoped),
|
| 722 |
-
db: AsyncSession = Depends(get_db),
|
| 723 |
-
):
|
| 724 |
-
"""Update buy/preview URLs for a book."""
|
| 725 |
-
book_repo = BookRepository(db)
|
| 726 |
-
book = await book_repo.get_by_id(book_id)
|
| 727 |
-
if not book or book.author_id != current_user.id:
|
| 728 |
-
from fastapi import HTTPException
|
| 729 |
-
raise HTTPException(404, "Book not found")
|
| 730 |
-
if "buy_url" in body:
|
| 731 |
-
book.buy_url = str(body["buy_url"])[:1000] if body["buy_url"] else None
|
| 732 |
-
if "preview_url" in body:
|
| 733 |
-
book.preview_url = str(body["preview_url"])[:1000] if body["preview_url"] else None
|
| 734 |
-
|
| 735 |
-
from app.repositories.link_repo import LinkRepository
|
| 736 |
-
|
| 737 |
-
link_repo = LinkRepository(db)
|
| 738 |
-
await link_repo.upsert_for_book(
|
| 739 |
-
current_user.id,
|
| 740 |
-
book_id,
|
| 741 |
-
{
|
| 742 |
-
"purchase_url": book.buy_url,
|
| 743 |
-
"preview_url": book.preview_url,
|
| 744 |
-
},
|
| 745 |
-
)
|
| 746 |
-
await db.commit()
|
| 747 |
-
return {"message": "Smart link updated"}
|
| 748 |
-
|
| 749 |
-
|
| 750 |
-
# ── Token Usage ───────────────────────────────────────────────────────────────
|
| 751 |
-
|
| 752 |
-
|
| 753 |
-
@router.get("/{author_slug}/token-usage")
|
| 754 |
-
async def get_token_usage(
|
| 755 |
-
author_slug: str,
|
| 756 |
-
current_user=Depends(get_current_author_scoped),
|
| 757 |
-
db: AsyncSession = Depends(get_db),
|
| 758 |
-
):
|
| 759 |
-
"""Return token budget and consumption for the active subscription only."""
|
| 760 |
-
from app.repositories.access_repo import AccessRepository
|
| 761 |
-
from app.services.token_budget import usage_summary_or_empty
|
| 762 |
-
|
| 763 |
-
access_repo = AccessRepository(db)
|
| 764 |
-
access = await access_repo.get_active_for_author(current_user.id)
|
| 765 |
-
return usage_summary_or_empty(access)
|
| 766 |
-
|
| 767 |
-
|
| 768 |
-
# ══════════════════════════════════════════════════════════════════════════════
|
| 769 |
-
# PHASE 1.1 — CONVERSATION VIEWER & LIVE TRANSCRIPT
|
| 770 |
-
# ══════════════════════════════════════════════════════════════════════════════
|
| 771 |
-
|
| 772 |
-
|
| 773 |
-
@router.get("/{author_slug}/sessions/{session_id}/transcript")
|
| 774 |
-
async def get_transcript(
|
| 775 |
-
author_slug: str,
|
| 776 |
-
session_id: str,
|
| 777 |
-
page: int = Query(1, ge=1),
|
| 778 |
-
per_page: int = Query(50, ge=1, le=200),
|
| 779 |
-
current_user=Depends(get_current_author_scoped),
|
| 780 |
-
db: AsyncSession = Depends(get_db),
|
| 781 |
-
):
|
| 782 |
-
"""Return paginated message transcript for a session."""
|
| 783 |
-
from sqlalchemy import select, func
|
| 784 |
-
from app.models.chat_session import ChatSession, ChatMessage
|
| 785 |
-
|
| 786 |
-
# Verify session belongs to author
|
| 787 |
-
session_result = await db.execute(
|
| 788 |
-
select(ChatSession).where(
|
| 789 |
-
ChatSession.id == session_id,
|
| 790 |
-
ChatSession.author_id == current_user.id,
|
| 791 |
-
)
|
| 792 |
-
)
|
| 793 |
-
session = session_result.scalar_one_or_none()
|
| 794 |
-
if not session:
|
| 795 |
-
from fastapi import HTTPException
|
| 796 |
-
raise HTTPException(404, "Session not found")
|
| 797 |
-
|
| 798 |
-
# Count total messages
|
| 799 |
-
total = await db.scalar(
|
| 800 |
-
select(func.count()).where(ChatMessage.session_id == session_id)
|
| 801 |
-
) or 0
|
| 802 |
-
|
| 803 |
-
# Paginated messages
|
| 804 |
-
offset = (page - 1) * per_page
|
| 805 |
-
result = await db.execute(
|
| 806 |
-
select(ChatMessage)
|
| 807 |
-
.where(ChatMessage.session_id == session_id)
|
| 808 |
-
.order_by(ChatMessage.created_at.asc())
|
| 809 |
-
.offset(offset)
|
| 810 |
-
.limit(per_page)
|
| 811 |
-
)
|
| 812 |
-
messages = result.scalars().all()
|
| 813 |
-
|
| 814 |
-
return {
|
| 815 |
-
"session": {
|
| 816 |
-
"id": session.id,
|
| 817 |
-
"visitor_fingerprint": session.visitor_fingerprint[:8] + "...",
|
| 818 |
-
"visitor_name": session.visitor_name,
|
| 819 |
-
"visitor_email": session.visitor_email,
|
| 820 |
-
"country": session.country_name,
|
| 821 |
-
"city": session.city,
|
| 822 |
-
"device_type": session.device_type,
|
| 823 |
-
"browser": session.browser,
|
| 824 |
-
"os": session.os,
|
| 825 |
-
"rating": session.rating,
|
| 826 |
-
"blocked": session.blocked,
|
| 827 |
-
"created_at": session.created_at.isoformat() if session.created_at else None,
|
| 828 |
-
"summary": session.summary,
|
| 829 |
-
},
|
| 830 |
-
"messages": [
|
| 831 |
-
{
|
| 832 |
-
"id": m.id,
|
| 833 |
-
"role": m.role,
|
| 834 |
-
"content": m.content,
|
| 835 |
-
"intent": m.intent,
|
| 836 |
-
"intent_confidence": m.intent_confidence,
|
| 837 |
-
"faithfulness_score": m.faithfulness_score,
|
| 838 |
-
"hallucination_detected": m.hallucination_detected,
|
| 839 |
-
"prompt_tokens": m.prompt_tokens,
|
| 840 |
-
"completion_tokens": m.completion_tokens,
|
| 841 |
-
"response_ms": m.response_ms,
|
| 842 |
-
"annotation": m.annotation,
|
| 843 |
-
"flag_type": m.flag_type,
|
| 844 |
-
"user_feedback": m.user_feedback,
|
| 845 |
-
"created_at": m.created_at.isoformat() if m.created_at else None,
|
| 846 |
-
}
|
| 847 |
-
for m in messages
|
| 848 |
-
],
|
| 849 |
-
"pagination": {
|
| 850 |
-
"page": page,
|
| 851 |
-
"per_page": per_page,
|
| 852 |
-
"total": total,
|
| 853 |
-
"total_pages": (total + per_page - 1) // per_page,
|
| 854 |
-
},
|
| 855 |
-
}
|
| 856 |
-
|
| 857 |
-
|
| 858 |
-
@router.post("/{author_slug}/sessions/{session_id}/messages/{message_id}/annotate")
|
| 859 |
-
async def annotate_message(
|
| 860 |
-
author_slug: str,
|
| 861 |
-
session_id: str,
|
| 862 |
-
message_id: str,
|
| 863 |
-
body: AnnotateRequest,
|
| 864 |
-
current_user=Depends(get_current_author_scoped),
|
| 865 |
-
db: AsyncSession = Depends(get_db),
|
| 866 |
-
):
|
| 867 |
-
"""Add admin annotation to a chat message (append-only). R-029: Schema validated."""
|
| 868 |
-
from sqlalchemy import select
|
| 869 |
-
from app.models.chat_session import ChatMessage, ChatSession
|
| 870 |
-
|
| 871 |
-
# R-040: Verify session ownership via JOIN to prevent IDOR
|
| 872 |
-
result = await db.execute(
|
| 873 |
-
select(ChatMessage)
|
| 874 |
-
.join(ChatSession, ChatMessage.session_id == ChatSession.id)
|
| 875 |
-
.where(
|
| 876 |
-
ChatMessage.id == message_id,
|
| 877 |
-
ChatMessage.session_id == session_id,
|
| 878 |
-
ChatSession.author_id == current_user.id,
|
| 879 |
-
)
|
| 880 |
-
)
|
| 881 |
-
msg = result.scalar_one_or_none()
|
| 882 |
-
if not msg:
|
| 883 |
-
from fastapi import HTTPException
|
| 884 |
-
raise HTTPException(404, "Message not found")
|
| 885 |
-
|
| 886 |
-
annotation = body.annotation
|
| 887 |
-
|
| 888 |
-
# Append-only: prepend timestamp + existing
|
| 889 |
-
from datetime import datetime, timezone
|
| 890 |
-
prefix = f"[{datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M')}] "
|
| 891 |
-
existing = msg.annotation or ""
|
| 892 |
-
msg.annotation = (prefix + annotation + "\n" + existing).strip()
|
| 893 |
-
await db.commit()
|
| 894 |
-
return {"message": "Annotation saved", "annotation": msg.annotation}
|
| 895 |
-
|
| 896 |
-
|
| 897 |
-
@router.post("/{author_slug}/sessions/{session_id}/messages/{message_id}/flag")
|
| 898 |
-
async def flag_message(
|
| 899 |
-
author_slug: str,
|
| 900 |
-
session_id: str,
|
| 901 |
-
message_id: str,
|
| 902 |
-
body: FlagRequest,
|
| 903 |
-
current_user=Depends(get_current_author_scoped),
|
| 904 |
-
db: AsyncSession = Depends(get_db),
|
| 905 |
-
):
|
| 906 |
-
"""Flag a message as spam, quality issue, or escalation. R-029: Schema validated."""
|
| 907 |
-
from sqlalchemy import select
|
| 908 |
-
from app.models.chat_session import ChatMessage, ChatSession
|
| 909 |
-
|
| 910 |
-
# flag_type validated by FlagRequest Pydantic Literal — "spam", "quality", "escalation", or None
|
| 911 |
-
|
| 912 |
-
# R-040: Verify session ownership via JOIN to prevent IDOR
|
| 913 |
-
result = await db.execute(
|
| 914 |
-
select(ChatMessage)
|
| 915 |
-
.join(ChatSession, ChatMessage.session_id == ChatSession.id)
|
| 916 |
-
.where(
|
| 917 |
-
ChatMessage.id == message_id,
|
| 918 |
-
ChatMessage.session_id == session_id,
|
| 919 |
-
ChatSession.author_id == current_user.id,
|
| 920 |
-
)
|
| 921 |
-
)
|
| 922 |
-
msg = result.scalar_one_or_none()
|
| 923 |
-
if not msg:
|
| 924 |
-
from fastapi import HTTPException
|
| 925 |
-
raise HTTPException(404, "Message not found")
|
| 926 |
-
|
| 927 |
-
msg.flag_type = body.flag_type
|
| 928 |
-
await db.commit()
|
| 929 |
-
return {"message": "Flag updated", "flag_type": body.flag_type}
|
| 930 |
-
|
| 931 |
-
|
| 932 |
-
|
| 933 |
-
|
| 934 |
-
|
| 935 |
-
# ══════════════════════════════════════════════════════════════════════════════
|
| 936 |
-
# PHASE 1.3 — CUSTOM Q&A TRAINING DATA
|
| 937 |
-
# ══════════════════════════════════════════════════════════════════════════════
|
| 938 |
-
|
| 939 |
-
|
| 940 |
-
@router.get("/{author_slug}/qa")
|
| 941 |
-
async def list_qa(
|
| 942 |
-
author_slug: str,
|
| 943 |
-
book_id: str | None = None,
|
| 944 |
-
current_user=Depends(get_current_author_scoped),
|
| 945 |
-
db: AsyncSession = Depends(get_db),
|
| 946 |
-
):
|
| 947 |
-
"""List all custom Q&A pairs for the author."""
|
| 948 |
-
from sqlalchemy import select
|
| 949 |
-
from app.models.custom_qa import CustomQA
|
| 950 |
-
|
| 951 |
-
query = (
|
| 952 |
-
select(CustomQA)
|
| 953 |
-
.where(CustomQA.author_id == current_user.id)
|
| 954 |
-
.order_by(CustomQA.priority.desc(), CustomQA.created_at.desc())
|
| 955 |
-
)
|
| 956 |
-
if book_id:
|
| 957 |
-
query = query.where(CustomQA.book_id == book_id)
|
| 958 |
-
|
| 959 |
-
result = await db.execute(query)
|
| 960 |
-
items = result.scalars().all()
|
| 961 |
-
return {
|
| 962 |
-
"qa_pairs": [
|
| 963 |
-
{
|
| 964 |
-
"id": qa.id,
|
| 965 |
-
"book_id": qa.book_id,
|
| 966 |
-
"question": qa.question,
|
| 967 |
-
"answer": qa.answer,
|
| 968 |
-
"priority": qa.priority,
|
| 969 |
-
"is_active": qa.is_active,
|
| 970 |
-
"match_count": qa.match_count,
|
| 971 |
-
"match_threshold": qa.match_threshold,
|
| 972 |
-
"category": qa.category,
|
| 973 |
-
"created_at": qa.created_at.isoformat() if qa.created_at else None,
|
| 974 |
-
}
|
| 975 |
-
for qa in items
|
| 976 |
-
],
|
| 977 |
-
"total": len(items),
|
| 978 |
-
}
|
| 979 |
-
|
| 980 |
-
|
| 981 |
-
@router.post("/{author_slug}/qa", status_code=201)
|
| 982 |
-
async def create_qa(
|
| 983 |
-
author_slug: str,
|
| 984 |
-
body: dict,
|
| 985 |
-
current_user=Depends(get_current_author_scoped),
|
| 986 |
-
db: AsyncSession = Depends(get_db),
|
| 987 |
-
):
|
| 988 |
-
"""Create a new custom Q&A pair."""
|
| 989 |
-
from sqlalchemy import select, func
|
| 990 |
-
from app.models.custom_qa import CustomQA
|
| 991 |
-
from fastapi import HTTPException
|
| 992 |
-
|
| 993 |
-
# Enforce limit: max 500 per author
|
| 994 |
-
count = await db.scalar(
|
| 995 |
-
select(func.count()).where(CustomQA.author_id == current_user.id)
|
| 996 |
-
) or 0
|
| 997 |
-
if count >= 500:
|
| 998 |
-
raise HTTPException(400, "Maximum 500 Q&A pairs allowed")
|
| 999 |
-
|
| 1000 |
-
question = str(body.get("question", "")).strip()
|
| 1001 |
-
answer = str(body.get("answer", "")).strip()
|
| 1002 |
-
if not question or not answer:
|
| 1003 |
-
raise HTTPException(400, "Both question and answer are required")
|
| 1004 |
-
if len(question) > 500:
|
| 1005 |
-
raise HTTPException(400, "Question must be under 500 characters")
|
| 1006 |
-
if len(answer) > 2000:
|
| 1007 |
-
raise HTTPException(400, "Answer must be under 2000 characters")
|
| 1008 |
-
|
| 1009 |
-
qa = CustomQA(
|
| 1010 |
-
author_id=current_user.id,
|
| 1011 |
-
book_id=body.get("book_id"),
|
| 1012 |
-
question=question,
|
| 1013 |
-
answer=answer,
|
| 1014 |
-
priority=int(body.get("priority", 0)),
|
| 1015 |
-
category=body.get("category"),
|
| 1016 |
-
match_threshold=float(body.get("match_threshold", 0.85)),
|
| 1017 |
-
)
|
| 1018 |
-
db.add(qa)
|
| 1019 |
-
await db.commit()
|
| 1020 |
-
return {"id": qa.id, "message": "Q&A pair created"}
|
| 1021 |
-
|
| 1022 |
-
|
| 1023 |
-
@router.post("/{author_slug}/qa/import")
|
| 1024 |
-
async def import_qa_csv(
|
| 1025 |
-
author_slug: str,
|
| 1026 |
-
current_user=Depends(get_current_author_scoped),
|
| 1027 |
-
db: AsyncSession = Depends(get_db),
|
| 1028 |
-
file: UploadFile = File(...),
|
| 1029 |
-
):
|
| 1030 |
-
"""Bulk import Q&A pairs from CSV (columns: question,answer,category,priority)."""
|
| 1031 |
-
import csv
|
| 1032 |
-
import io
|
| 1033 |
-
from sqlalchemy import select, func
|
| 1034 |
-
from app.models.custom_qa import CustomQA
|
| 1035 |
-
from fastapi import HTTPException
|
| 1036 |
-
|
| 1037 |
-
contents = await file.read()
|
| 1038 |
-
text = contents.decode("utf-8-sig")
|
| 1039 |
-
reader = csv.DictReader(io.StringIO(text))
|
| 1040 |
-
|
| 1041 |
-
if not reader.fieldnames or "question" not in reader.fieldnames or "answer" not in reader.fieldnames:
|
| 1042 |
-
raise HTTPException(400, "CSV must have 'question' and 'answer' columns")
|
| 1043 |
-
|
| 1044 |
-
current_count = await db.scalar(
|
| 1045 |
-
select(func.count()).where(CustomQA.author_id == current_user.id)
|
| 1046 |
-
) or 0
|
| 1047 |
-
|
| 1048 |
-
imported = 0
|
| 1049 |
-
skipped = 0
|
| 1050 |
-
errors = []
|
| 1051 |
-
|
| 1052 |
-
for i, row in enumerate(reader, 1):
|
| 1053 |
-
if current_count + imported >= 500:
|
| 1054 |
-
errors.append(f"Row {i}: Limit of 500 Q&A pairs reached")
|
| 1055 |
-
break
|
| 1056 |
-
|
| 1057 |
-
q = (row.get("question") or "").strip()
|
| 1058 |
-
a = (row.get("answer") or "").strip()
|
| 1059 |
-
if not q or not a:
|
| 1060 |
-
skipped += 1
|
| 1061 |
-
continue
|
| 1062 |
-
if len(q) > 500 or len(a) > 2000:
|
| 1063 |
-
errors.append(f"Row {i}: Question or answer too long")
|
| 1064 |
-
skipped += 1
|
| 1065 |
-
continue
|
| 1066 |
-
|
| 1067 |
-
qa = CustomQA(
|
| 1068 |
-
author_id=current_user.id,
|
| 1069 |
-
question=q[:500],
|
| 1070 |
-
answer=a[:2000],
|
| 1071 |
-
category=(row.get("category") or "").strip()[:50] or None,
|
| 1072 |
-
priority=int(row.get("priority") or 0),
|
| 1073 |
-
)
|
| 1074 |
-
db.add(qa)
|
| 1075 |
-
imported += 1
|
| 1076 |
-
|
| 1077 |
-
await db.commit()
|
| 1078 |
-
return {
|
| 1079 |
-
"imported": imported,
|
| 1080 |
-
"skipped": skipped,
|
| 1081 |
-
"errors": errors[:10],
|
| 1082 |
-
"message": f"Imported {imported} Q&A pairs",
|
| 1083 |
-
}
|
| 1084 |
-
|
| 1085 |
-
|
| 1086 |
-
@router.get("/{author_slug}/qa/export")
|
| 1087 |
-
async def export_qa_csv(
|
| 1088 |
-
author_slug: str,
|
| 1089 |
-
current_user=Depends(get_current_author_scoped),
|
| 1090 |
-
db: AsyncSession = Depends(get_db),
|
| 1091 |
-
):
|
| 1092 |
-
"""Export all Q&A pairs as CSV."""
|
| 1093 |
-
import csv
|
| 1094 |
-
import io
|
| 1095 |
-
from sqlalchemy import select
|
| 1096 |
-
from app.models.custom_qa import CustomQA
|
| 1097 |
-
from fastapi.responses import StreamingResponse
|
| 1098 |
-
|
| 1099 |
-
result = await db.execute(
|
| 1100 |
-
select(CustomQA)
|
| 1101 |
-
.where(CustomQA.author_id == current_user.id)
|
| 1102 |
-
.order_by(CustomQA.priority.desc())
|
| 1103 |
-
)
|
| 1104 |
-
items = result.scalars().all()
|
| 1105 |
-
|
| 1106 |
-
output = io.StringIO()
|
| 1107 |
-
writer = csv.writer(output)
|
| 1108 |
-
writer.writerow(["question", "answer", "category", "priority", "is_active", "match_count"])
|
| 1109 |
-
for qa in items:
|
| 1110 |
-
writer.writerow([qa.question, qa.answer, qa.category or "", qa.priority, qa.is_active, qa.match_count])
|
| 1111 |
-
|
| 1112 |
-
output.seek(0)
|
| 1113 |
-
return StreamingResponse(
|
| 1114 |
-
iter([output.getvalue()]),
|
| 1115 |
-
media_type="text/csv",
|
| 1116 |
-
headers={"Content-Disposition": "attachment; filename=qa_pairs.csv"},
|
| 1117 |
-
)
|
| 1118 |
-
|
| 1119 |
-
|
| 1120 |
-
@router.put("/{author_slug}/qa/{qa_id}")
|
| 1121 |
-
async def update_qa(
|
| 1122 |
-
author_slug: str,
|
| 1123 |
-
qa_id: str,
|
| 1124 |
-
body: dict,
|
| 1125 |
-
current_user=Depends(get_current_author_scoped),
|
| 1126 |
-
db: AsyncSession = Depends(get_db),
|
| 1127 |
-
):
|
| 1128 |
-
"""Update an existing Q&A pair."""
|
| 1129 |
-
from sqlalchemy import select
|
| 1130 |
-
from app.models.custom_qa import CustomQA
|
| 1131 |
-
from fastapi import HTTPException
|
| 1132 |
-
|
| 1133 |
-
result = await db.execute(
|
| 1134 |
-
select(CustomQA).where(CustomQA.id == qa_id, CustomQA.author_id == current_user.id)
|
| 1135 |
-
)
|
| 1136 |
-
qa = result.scalar_one_or_none()
|
| 1137 |
-
if not qa:
|
| 1138 |
-
raise HTTPException(404, "Q&A pair not found")
|
| 1139 |
-
|
| 1140 |
-
if "question" in body:
|
| 1141 |
-
q = str(body["question"]).strip()
|
| 1142 |
-
if not q or len(q) > 500:
|
| 1143 |
-
raise HTTPException(400, "Question must be 1-500 characters")
|
| 1144 |
-
qa.question = q
|
| 1145 |
-
if "answer" in body:
|
| 1146 |
-
a = str(body["answer"]).strip()
|
| 1147 |
-
if not a or len(a) > 2000:
|
| 1148 |
-
raise HTTPException(400, "Answer must be 1-2000 characters")
|
| 1149 |
-
qa.answer = a
|
| 1150 |
-
if "priority" in body:
|
| 1151 |
-
qa.priority = int(body["priority"])
|
| 1152 |
-
if "is_active" in body:
|
| 1153 |
-
qa.is_active = bool(body["is_active"])
|
| 1154 |
-
if "category" in body:
|
| 1155 |
-
qa.category = body["category"]
|
| 1156 |
-
if "book_id" in body:
|
| 1157 |
-
qa.book_id = body["book_id"]
|
| 1158 |
-
if "match_threshold" in body:
|
| 1159 |
-
t = float(body["match_threshold"])
|
| 1160 |
-
if not (0.5 <= t <= 1.0):
|
| 1161 |
-
raise HTTPException(400, "Threshold must be between 0.5 and 1.0")
|
| 1162 |
-
qa.match_threshold = t
|
| 1163 |
-
|
| 1164 |
-
await db.commit()
|
| 1165 |
-
return {"message": "Q&A pair updated"}
|
| 1166 |
-
|
| 1167 |
-
|
| 1168 |
-
@router.delete("/{author_slug}/qa/{qa_id}")
|
| 1169 |
-
async def delete_qa(
|
| 1170 |
-
author_slug: str,
|
| 1171 |
-
qa_id: str,
|
| 1172 |
-
current_user=Depends(get_current_author_scoped),
|
| 1173 |
-
db: AsyncSession = Depends(get_db),
|
| 1174 |
-
):
|
| 1175 |
-
"""Delete a Q&A pair."""
|
| 1176 |
-
from sqlalchemy import select
|
| 1177 |
-
from app.models.custom_qa import CustomQA
|
| 1178 |
-
from fastapi import HTTPException
|
| 1179 |
-
|
| 1180 |
-
result = await db.execute(
|
| 1181 |
-
select(CustomQA).where(CustomQA.id == qa_id, CustomQA.author_id == current_user.id)
|
| 1182 |
-
)
|
| 1183 |
-
qa = result.scalar_one_or_none()
|
| 1184 |
-
if not qa:
|
| 1185 |
-
raise HTTPException(404, "Q&A pair not found")
|
| 1186 |
-
|
| 1187 |
-
await db.delete(qa)
|
| 1188 |
-
await db.commit()
|
| 1189 |
-
return {"message": "Q&A pair deleted"}
|
| 1190 |
-
|
| 1191 |
-
|
| 1192 |
-
|
| 1193 |
-
|
| 1194 |
-
|
| 1195 |
-
# ══════════════════════════════════════════════════════════════════════════════
|
| 1196 |
-
# PHASE 1.4 — EXPORT CENTER
|
| 1197 |
-
# ══════════════════════════════════════════════════════════════════════════════
|
| 1198 |
-
|
| 1199 |
-
|
| 1200 |
-
@router.get("/{author_slug}/export/sessions")
|
| 1201 |
-
async def export_sessions(
|
| 1202 |
-
author_slug: str,
|
| 1203 |
-
days: int = Query(30, ge=1, le=365),
|
| 1204 |
-
current_user=Depends(get_current_author_scoped),
|
| 1205 |
-
db: AsyncSession = Depends(get_db),
|
| 1206 |
-
):
|
| 1207 |
-
"""Export chat sessions as CSV."""
|
| 1208 |
-
import csv
|
| 1209 |
-
import io
|
| 1210 |
-
from datetime import datetime, timedelta, timezone
|
| 1211 |
-
from sqlalchemy import select
|
| 1212 |
-
from app.models.chat_session import ChatSession
|
| 1213 |
-
from fastapi.responses import StreamingResponse
|
| 1214 |
-
|
| 1215 |
-
since = datetime.now(timezone.utc) - timedelta(days=days)
|
| 1216 |
-
result = await db.execute(
|
| 1217 |
-
select(ChatSession)
|
| 1218 |
-
.where(ChatSession.author_id == current_user.id, ChatSession.created_at >= since)
|
| 1219 |
-
.order_by(ChatSession.created_at.desc())
|
| 1220 |
-
.limit(50000)
|
| 1221 |
-
)
|
| 1222 |
-
sessions = result.scalars().all()
|
| 1223 |
-
|
| 1224 |
-
output = io.StringIO()
|
| 1225 |
-
writer = csv.writer(output)
|
| 1226 |
-
writer.writerow([
|
| 1227 |
-
"session_id", "visitor_fp", "visitor_name", "country", "city",
|
| 1228 |
-
"device", "browser", "os", "turn_count", "link_clicked", "rating",
|
| 1229 |
-
"blocked", "created_at",
|
| 1230 |
-
])
|
| 1231 |
-
for s in sessions:
|
| 1232 |
-
writer.writerow([
|
| 1233 |
-
s.id, s.visitor_fingerprint[:8], s.visitor_name or "",
|
| 1234 |
-
s.country_name or "", s.city or "", s.device_type or "",
|
| 1235 |
-
s.browser or "", s.os or "", s.turn_count, s.link_clicked,
|
| 1236 |
-
s.rating or "", s.blocked,
|
| 1237 |
-
s.created_at.isoformat() if s.created_at else "",
|
| 1238 |
-
])
|
| 1239 |
-
|
| 1240 |
-
output.seek(0)
|
| 1241 |
-
return StreamingResponse(
|
| 1242 |
-
iter([output.getvalue()]),
|
| 1243 |
-
media_type="text/csv",
|
| 1244 |
-
headers={"Content-Disposition": f"attachment; filename=sessions_{days}d.csv"},
|
| 1245 |
-
)
|
| 1246 |
-
|
| 1247 |
-
|
| 1248 |
-
@router.get("/{author_slug}/export/analytics")
|
| 1249 |
-
async def export_analytics(
|
| 1250 |
-
author_slug: str,
|
| 1251 |
-
days: int = Query(30, ge=1, le=365),
|
| 1252 |
-
current_user=Depends(get_current_author_scoped),
|
| 1253 |
-
db: AsyncSession = Depends(get_db),
|
| 1254 |
-
):
|
| 1255 |
-
"""Export daily analytics as CSV."""
|
| 1256 |
-
import csv
|
| 1257 |
-
import io
|
| 1258 |
-
from datetime import datetime, timedelta, timezone
|
| 1259 |
-
from sqlalchemy import select, func
|
| 1260 |
-
from app.models.analytics import AnalyticsEvent
|
| 1261 |
-
from fastapi.responses import StreamingResponse
|
| 1262 |
-
|
| 1263 |
-
since = datetime.now(timezone.utc) - timedelta(days=days)
|
| 1264 |
-
result = await db.execute(
|
| 1265 |
-
select(
|
| 1266 |
-
func.date(AnalyticsEvent.timestamp).label("date"),
|
| 1267 |
-
func.count().label("events"),
|
| 1268 |
-
func.sum(AnalyticsEvent.prompt_tokens + AnalyticsEvent.completion_tokens).label("tokens"),
|
| 1269 |
-
func.avg(AnalyticsEvent.response_ms).label("avg_latency"),
|
| 1270 |
-
func.sum(func.cast(AnalyticsEvent.link_clicked, Integer)).label("clicks"),
|
| 1271 |
-
)
|
| 1272 |
-
.where(AnalyticsEvent.author_id == current_user.id, AnalyticsEvent.timestamp >= since)
|
| 1273 |
-
.group_by(func.date(AnalyticsEvent.timestamp))
|
| 1274 |
-
.order_by(func.date(AnalyticsEvent.timestamp))
|
| 1275 |
-
)
|
| 1276 |
-
rows = result.all()
|
| 1277 |
-
|
| 1278 |
-
output = io.StringIO()
|
| 1279 |
-
writer = csv.writer(output)
|
| 1280 |
-
writer.writerow(["date", "events", "total_tokens", "avg_latency_ms", "link_clicks"])
|
| 1281 |
-
for r in rows:
|
| 1282 |
-
writer.writerow([str(r.date), r.events, r.tokens or 0, round(r.avg_latency or 0, 1), r.clicks or 0])
|
| 1283 |
-
|
| 1284 |
-
output.seek(0)
|
| 1285 |
-
return StreamingResponse(
|
| 1286 |
-
iter([output.getvalue()]),
|
| 1287 |
-
media_type="text/csv",
|
| 1288 |
-
headers={"Content-Disposition": f"attachment; filename=analytics_{days}d.csv"},
|
| 1289 |
-
)
|
| 1290 |
-
|
| 1291 |
-
|
| 1292 |
-
@router.get("/{author_slug}/export/conversations")
|
| 1293 |
-
async def export_conversations(
|
| 1294 |
-
author_slug: str,
|
| 1295 |
-
session_id: str | None = None,
|
| 1296 |
-
days: int = Query(7, ge=1, le=30),
|
| 1297 |
-
current_user=Depends(get_current_author_scoped),
|
| 1298 |
-
db: AsyncSession = Depends(get_db),
|
| 1299 |
-
):
|
| 1300 |
-
"""Export full conversation transcripts as CSV."""
|
| 1301 |
-
import csv
|
| 1302 |
-
import io
|
| 1303 |
-
from datetime import datetime, timedelta, timezone
|
| 1304 |
-
from sqlalchemy import select
|
| 1305 |
-
from app.models.chat_session import ChatSession, ChatMessage
|
| 1306 |
-
from fastapi.responses import StreamingResponse
|
| 1307 |
-
|
| 1308 |
-
since = datetime.now(timezone.utc) - timedelta(days=days)
|
| 1309 |
-
query = (
|
| 1310 |
-
select(ChatMessage)
|
| 1311 |
-
.join(ChatSession, ChatMessage.session_id == ChatSession.id)
|
| 1312 |
-
.where(ChatSession.author_id == current_user.id)
|
| 1313 |
-
.order_by(ChatMessage.created_at.asc())
|
| 1314 |
-
.limit(50000)
|
| 1315 |
-
)
|
| 1316 |
-
if session_id:
|
| 1317 |
-
query = query.where(ChatSession.id == session_id)
|
| 1318 |
-
else:
|
| 1319 |
-
query = query.where(ChatSession.created_at >= since)
|
| 1320 |
-
|
| 1321 |
-
result = await db.execute(query)
|
| 1322 |
-
messages = result.scalars().all()
|
| 1323 |
-
|
| 1324 |
-
output = io.StringIO()
|
| 1325 |
-
writer = csv.writer(output)
|
| 1326 |
-
writer.writerow([
|
| 1327 |
-
"session_id", "role", "content", "intent", "confidence",
|
| 1328 |
-
"faithfulness", "hallucination", "tokens", "latency_ms", "timestamp",
|
| 1329 |
-
])
|
| 1330 |
-
for m in messages:
|
| 1331 |
-
writer.writerow([
|
| 1332 |
-
m.session_id, m.role, m.content[:500], m.intent or "",
|
| 1333 |
-
m.intent_confidence or "", m.faithfulness_score or "",
|
| 1334 |
-
m.hallucination_detected or "", (m.prompt_tokens or 0) + (m.completion_tokens or 0),
|
| 1335 |
-
m.response_ms or "",
|
| 1336 |
-
m.created_at.isoformat() if m.created_at else "",
|
| 1337 |
-
])
|
| 1338 |
-
|
| 1339 |
-
output.seek(0)
|
| 1340 |
-
return StreamingResponse(
|
| 1341 |
-
iter([output.getvalue()]),
|
| 1342 |
-
media_type="text/csv",
|
| 1343 |
-
headers={"Content-Disposition": "attachment; filename=conversations.csv"},
|
| 1344 |
-
)
|
|
|
|
| 1 |
+
"""admin/router.py — Author Admin API aggregator.
|
| 2 |
|
| 3 |
+
Mounts all sub-routers. This file should never contain route logic.
|
| 4 |
+
All routes live in admin/routers/*.py.
|
| 5 |
|
| 6 |
+
Routes served at: /api/admin/{author_slug}/*
|
| 7 |
+
Auth: Bearer JWT required on all routes (enforced per sub-router via dependency).
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
"""
|
| 9 |
|
| 10 |
+
from fastapi import APIRouter
|
|
|
|
|
|
|
| 11 |
|
| 12 |
+
from app.admin.routers import analytics, books, dashboard, exports, links, qa, settings
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
router = APIRouter()
|
| 15 |
|
| 16 |
+
router.include_router(dashboard.router)
|
| 17 |
+
router.include_router(books.router)
|
| 18 |
+
router.include_router(analytics.router)
|
| 19 |
+
router.include_router(settings.router)
|
| 20 |
+
router.include_router(links.router)
|
| 21 |
+
router.include_router(qa.router)
|
| 22 |
+
router.include_router(exports.router)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app/admin/routers/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""admin/routers/__init__.py"""
|
app/admin/routers/analytics.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""admin/routers/analytics.py — Analytics and reporting routes.
|
| 2 |
+
|
| 3 |
+
Routes:
|
| 4 |
+
GET /{slug}/analytics
|
| 5 |
+
GET /{slug}/analytics/funnel
|
| 6 |
+
GET /{slug}/analytics/intents
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from fastapi import APIRouter, Depends, Query
|
| 10 |
+
from sqlalchemy import Integer
|
| 11 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 12 |
+
|
| 13 |
+
from app.dependencies import get_db, get_current_author_scoped
|
| 14 |
+
|
| 15 |
+
router = APIRouter()
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
@router.get("/{author_slug}/analytics")
|
| 19 |
+
async def get_analytics(
|
| 20 |
+
author_slug: str,
|
| 21 |
+
days: int = Query(30, ge=1, le=365),
|
| 22 |
+
current_user=Depends(get_current_author_scoped),
|
| 23 |
+
db: AsyncSession = Depends(get_db),
|
| 24 |
+
):
|
| 25 |
+
"""Return daily session counts for dashboard charts."""
|
| 26 |
+
from datetime import datetime, timedelta, timezone
|
| 27 |
+
from sqlalchemy import select, func
|
| 28 |
+
from app.models.analytics import AnalyticsEvent
|
| 29 |
+
|
| 30 |
+
since = datetime.now(timezone.utc) - timedelta(days=days)
|
| 31 |
+
result = await db.execute(
|
| 32 |
+
select(
|
| 33 |
+
func.date(AnalyticsEvent.timestamp).label("date"),
|
| 34 |
+
func.count().label("count"),
|
| 35 |
+
)
|
| 36 |
+
.where(
|
| 37 |
+
AnalyticsEvent.author_id == current_user.id,
|
| 38 |
+
AnalyticsEvent.timestamp >= since,
|
| 39 |
+
)
|
| 40 |
+
.group_by(func.date(AnalyticsEvent.timestamp))
|
| 41 |
+
.order_by(func.date(AnalyticsEvent.timestamp))
|
| 42 |
+
)
|
| 43 |
+
daily = [{"date": str(row.date), "count": row.count} for row in result]
|
| 44 |
+
return {"daily_sessions": daily, "period_days": days}
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
@router.get("/{author_slug}/analytics/funnel")
|
| 48 |
+
async def get_conversion_funnel(
|
| 49 |
+
author_slug: str,
|
| 50 |
+
days: int = Query(30, ge=1, le=365),
|
| 51 |
+
current_user=Depends(get_current_author_scoped),
|
| 52 |
+
db: AsyncSession = Depends(get_db),
|
| 53 |
+
):
|
| 54 |
+
"""Return conversion funnel data (sessions → chat → book → link → click)."""
|
| 55 |
+
from datetime import datetime, timedelta, timezone
|
| 56 |
+
from sqlalchemy import select, func
|
| 57 |
+
from app.models.analytics import AnalyticsEvent
|
| 58 |
+
from app.models.chat_session import ChatSession
|
| 59 |
+
|
| 60 |
+
since = datetime.now(timezone.utc) - timedelta(days=days)
|
| 61 |
+
|
| 62 |
+
total_sessions = await db.scalar(
|
| 63 |
+
select(func.count()).where(
|
| 64 |
+
ChatSession.author_id == current_user.id,
|
| 65 |
+
ChatSession.created_at >= since,
|
| 66 |
+
)
|
| 67 |
+
) or 0
|
| 68 |
+
|
| 69 |
+
chat_started = await db.scalar(
|
| 70 |
+
select(func.count(func.distinct(AnalyticsEvent.session_id))).where(
|
| 71 |
+
AnalyticsEvent.author_id == current_user.id,
|
| 72 |
+
AnalyticsEvent.timestamp >= since,
|
| 73 |
+
)
|
| 74 |
+
) or 0
|
| 75 |
+
|
| 76 |
+
book_discussed = await db.scalar(
|
| 77 |
+
select(func.count(func.distinct(AnalyticsEvent.session_id))).where(
|
| 78 |
+
AnalyticsEvent.author_id == current_user.id,
|
| 79 |
+
AnalyticsEvent.book_id.isnot(None),
|
| 80 |
+
AnalyticsEvent.timestamp >= since,
|
| 81 |
+
)
|
| 82 |
+
) or 0
|
| 83 |
+
|
| 84 |
+
link_shown = await db.scalar(
|
| 85 |
+
select(func.count()).where(
|
| 86 |
+
AnalyticsEvent.author_id == current_user.id,
|
| 87 |
+
AnalyticsEvent.link_shown == True,
|
| 88 |
+
AnalyticsEvent.timestamp >= since,
|
| 89 |
+
)
|
| 90 |
+
) or 0
|
| 91 |
+
|
| 92 |
+
link_clicked = await db.scalar(
|
| 93 |
+
select(func.count()).where(
|
| 94 |
+
AnalyticsEvent.author_id == current_user.id,
|
| 95 |
+
AnalyticsEvent.link_clicked == True,
|
| 96 |
+
AnalyticsEvent.timestamp >= since,
|
| 97 |
+
)
|
| 98 |
+
) or 0
|
| 99 |
+
|
| 100 |
+
return {
|
| 101 |
+
"funnel": [
|
| 102 |
+
{"stage": "Widget Loads", "count": total_sessions},
|
| 103 |
+
{"stage": "Chats Started", "count": chat_started},
|
| 104 |
+
{"stage": "Book Discussed", "count": book_discussed},
|
| 105 |
+
{"stage": "Link Shown", "count": link_shown},
|
| 106 |
+
{"stage": "Link Clicked", "count": link_clicked},
|
| 107 |
+
],
|
| 108 |
+
"period_days": days,
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
@router.get("/{author_slug}/analytics/intents")
|
| 113 |
+
async def get_intent_distribution(
|
| 114 |
+
author_slug: str,
|
| 115 |
+
days: int = Query(30, ge=1, le=365),
|
| 116 |
+
current_user=Depends(get_current_author_scoped),
|
| 117 |
+
db: AsyncSession = Depends(get_db),
|
| 118 |
+
):
|
| 119 |
+
"""Return distribution of detected intent labels."""
|
| 120 |
+
from datetime import datetime, timedelta, timezone
|
| 121 |
+
from sqlalchemy import select, func
|
| 122 |
+
from app.models.analytics import AnalyticsEvent
|
| 123 |
+
|
| 124 |
+
since = datetime.now(timezone.utc) - timedelta(days=days)
|
| 125 |
+
result = await db.execute(
|
| 126 |
+
select(AnalyticsEvent.intent, func.count().label("count"))
|
| 127 |
+
.where(
|
| 128 |
+
AnalyticsEvent.author_id == current_user.id,
|
| 129 |
+
AnalyticsEvent.intent.isnot(None),
|
| 130 |
+
AnalyticsEvent.timestamp >= since,
|
| 131 |
+
)
|
| 132 |
+
.group_by(AnalyticsEvent.intent)
|
| 133 |
+
.order_by(func.count().desc())
|
| 134 |
+
.limit(20)
|
| 135 |
+
)
|
| 136 |
+
intents = [{"intent": row.intent or "unknown", "count": row.count} for row in result]
|
| 137 |
+
return {"intents": intents, "period_days": days}
|
app/admin/routers/books.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""admin/routers/books.py — Book management routes.
|
| 2 |
+
|
| 3 |
+
Routes:
|
| 4 |
+
GET /{slug}/books
|
| 5 |
+
DELETE /{slug}/books/{book_id}
|
| 6 |
+
POST /{slug}/books/{book_id}/cover
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from fastapi import APIRouter, Depends, File, UploadFile
|
| 10 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 11 |
+
|
| 12 |
+
from app.dependencies import get_db, get_current_author_scoped
|
| 13 |
+
from app.repositories.book_repo import BookRepository
|
| 14 |
+
|
| 15 |
+
router = APIRouter()
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
@router.get("/{author_slug}/books")
|
| 19 |
+
async def list_books(
|
| 20 |
+
author_slug: str,
|
| 21 |
+
current_user=Depends(get_current_author_scoped),
|
| 22 |
+
db: AsyncSession = Depends(get_db),
|
| 23 |
+
):
|
| 24 |
+
"""List all books for the author."""
|
| 25 |
+
book_repo = BookRepository(db)
|
| 26 |
+
books = await book_repo.list_for_author(current_user.id)
|
| 27 |
+
return {
|
| 28 |
+
"books": [
|
| 29 |
+
{
|
| 30 |
+
"id": b.id,
|
| 31 |
+
"title": b.title,
|
| 32 |
+
"genre": b.genre,
|
| 33 |
+
"status": b.status,
|
| 34 |
+
"chunk_count": getattr(b, "chunk_count", 0),
|
| 35 |
+
"ai_summary": getattr(b, "ai_summary", ""),
|
| 36 |
+
"cover_path": getattr(b, "cover_path", None),
|
| 37 |
+
"cover_thumbnail": getattr(b, "cover_thumbnail_path", None),
|
| 38 |
+
"cover_medium": getattr(b, "cover_medium_path", None),
|
| 39 |
+
"buy_url": getattr(b, "buy_url", None),
|
| 40 |
+
}
|
| 41 |
+
for b in books
|
| 42 |
+
]
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
@router.delete("/{author_slug}/books/{book_id}")
|
| 47 |
+
async def delete_book(
|
| 48 |
+
author_slug: str,
|
| 49 |
+
book_id: str,
|
| 50 |
+
current_user=Depends(get_current_author_scoped),
|
| 51 |
+
db: AsyncSession = Depends(get_db),
|
| 52 |
+
):
|
| 53 |
+
"""Delete a book and its ChromaDB collection."""
|
| 54 |
+
from fastapi import HTTPException
|
| 55 |
+
from app.services.embeddings import delete_book_embeddings
|
| 56 |
+
|
| 57 |
+
book_repo = BookRepository(db)
|
| 58 |
+
book = await book_repo.get_by_id(book_id)
|
| 59 |
+
if not book or book.author_id != current_user.id:
|
| 60 |
+
raise HTTPException(404, "Book not found")
|
| 61 |
+
|
| 62 |
+
delete_book_embeddings(current_user.id, book_id)
|
| 63 |
+
await db.delete(book)
|
| 64 |
+
await db.commit()
|
| 65 |
+
return {"message": "Book deleted"}
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
@router.post("/{author_slug}/books/{book_id}/cover")
|
| 69 |
+
async def upload_book_cover(
|
| 70 |
+
author_slug: str,
|
| 71 |
+
book_id: str,
|
| 72 |
+
current_user=Depends(get_current_author_scoped),
|
| 73 |
+
db: AsyncSession = Depends(get_db),
|
| 74 |
+
file: UploadFile = File(...),
|
| 75 |
+
):
|
| 76 |
+
"""Upload a book cover image (JPG/PNG/WebP, max 5MB)."""
|
| 77 |
+
import os
|
| 78 |
+
from fastapi import HTTPException
|
| 79 |
+
|
| 80 |
+
book_repo = BookRepository(db)
|
| 81 |
+
book = await book_repo.get_by_id(book_id)
|
| 82 |
+
if not book or book.author_id != current_user.id:
|
| 83 |
+
raise HTTPException(404, "Book not found")
|
| 84 |
+
|
| 85 |
+
allowed_types = {"image/jpeg", "image/png", "image/webp"}
|
| 86 |
+
if file.content_type not in allowed_types:
|
| 87 |
+
raise HTTPException(400, "Invalid file type. Allowed: JPG, PNG, WebP")
|
| 88 |
+
|
| 89 |
+
contents = await file.read()
|
| 90 |
+
if len(contents) > 5 * 1024 * 1024:
|
| 91 |
+
raise HTTPException(400, "File too large. Maximum: 5MB")
|
| 92 |
+
|
| 93 |
+
if contents[:2] not in (b'\xff\xd8', b'\x89P') and contents[:4] != b'RIFF':
|
| 94 |
+
raise HTTPException(400, "File does not appear to be a valid image")
|
| 95 |
+
|
| 96 |
+
cover_dir = f"/data/covers/{current_user.id}/{book_id}"
|
| 97 |
+
os.makedirs(cover_dir, exist_ok=True)
|
| 98 |
+
|
| 99 |
+
ext = file.filename.rsplit('.', 1)[-1].lower() if file.filename else 'jpg'
|
| 100 |
+
if ext not in ('jpg', 'jpeg', 'png', 'webp'):
|
| 101 |
+
ext = 'jpg'
|
| 102 |
+
original_path = f"{cover_dir}/original.{ext}"
|
| 103 |
+
with open(original_path, "wb") as f:
|
| 104 |
+
f.write(contents)
|
| 105 |
+
|
| 106 |
+
thumbnail_path = original_path
|
| 107 |
+
medium_path = original_path
|
| 108 |
+
try:
|
| 109 |
+
from PIL import Image
|
| 110 |
+
import io
|
| 111 |
+
|
| 112 |
+
img = Image.open(io.BytesIO(contents)).convert('RGB')
|
| 113 |
+
|
| 114 |
+
thumb = img.copy()
|
| 115 |
+
thumb.thumbnail((80, 120), Image.LANCZOS)
|
| 116 |
+
thumbnail_path = f"{cover_dir}/thumb.webp"
|
| 117 |
+
thumb.save(thumbnail_path, 'WEBP', quality=80)
|
| 118 |
+
|
| 119 |
+
med = img.copy()
|
| 120 |
+
med.thumbnail((300, 450), Image.LANCZOS)
|
| 121 |
+
medium_path = f"{cover_dir}/medium.webp"
|
| 122 |
+
med.save(medium_path, 'WEBP', quality=85)
|
| 123 |
+
except ImportError:
|
| 124 |
+
pass # PIL not available — use original for all sizes
|
| 125 |
+
|
| 126 |
+
book.cover_path = original_path
|
| 127 |
+
book.cover_thumbnail_path = thumbnail_path
|
| 128 |
+
book.cover_medium_path = medium_path
|
| 129 |
+
await db.commit()
|
| 130 |
+
|
| 131 |
+
return {
|
| 132 |
+
"message": "Cover uploaded",
|
| 133 |
+
"cover_path": original_path,
|
| 134 |
+
"cover_thumbnail": thumbnail_path,
|
| 135 |
+
"cover_medium": medium_path,
|
| 136 |
+
}
|
app/admin/routers/dashboard.py
ADDED
|
@@ -0,0 +1,347 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""admin/routers/dashboard.py — Sessions, transcripts, and message moderation.
|
| 2 |
+
|
| 3 |
+
Routes:
|
| 4 |
+
GET /{slug}/dashboard
|
| 5 |
+
GET /{slug}/sessions
|
| 6 |
+
GET /{slug}/sessions/search
|
| 7 |
+
POST /{slug}/sessions/{id}/block
|
| 8 |
+
POST /{slug}/sessions/{id}/unblock
|
| 9 |
+
GET /{slug}/sessions/{id}/transcript
|
| 10 |
+
POST /{slug}/sessions/{id}/messages/{mid}/annotate
|
| 11 |
+
POST /{slug}/sessions/{id}/messages/{mid}/flag
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from fastapi import APIRouter, Depends, Query
|
| 15 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 16 |
+
|
| 17 |
+
from app.dependencies import get_db, get_current_author_scoped
|
| 18 |
+
from app.repositories.book_repo import BookRepository
|
| 19 |
+
from app.schemas.admin import AnnotateRequest, FlagRequest
|
| 20 |
+
|
| 21 |
+
router = APIRouter()
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
@router.get("/{author_slug}/dashboard")
|
| 25 |
+
async def dashboard(
|
| 26 |
+
author_slug: str,
|
| 27 |
+
current_user=Depends(get_current_author_scoped),
|
| 28 |
+
db: AsyncSession = Depends(get_db),
|
| 29 |
+
):
|
| 30 |
+
"""Return high-level dashboard stats for the author."""
|
| 31 |
+
book_repo = BookRepository(db)
|
| 32 |
+
books = await book_repo.list_for_author(current_user.id)
|
| 33 |
+
|
| 34 |
+
from sqlalchemy import func, select
|
| 35 |
+
from app.models.chat_session import ChatSession
|
| 36 |
+
|
| 37 |
+
total_sessions = await db.scalar(
|
| 38 |
+
select(func.count()).where(ChatSession.author_id == current_user.id)
|
| 39 |
+
) or 0
|
| 40 |
+
|
| 41 |
+
return {
|
| 42 |
+
"total_books": len(books),
|
| 43 |
+
"active_books": sum(1 for b in books if b.status == "active"),
|
| 44 |
+
"total_sessions": total_sessions,
|
| 45 |
+
"total_clicks": 0,
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
@router.get("/{author_slug}/sessions")
|
| 50 |
+
async def list_sessions(
|
| 51 |
+
author_slug: str,
|
| 52 |
+
limit: int = Query(50, ge=1, le=200),
|
| 53 |
+
offset: int = 0,
|
| 54 |
+
current_user=Depends(get_current_author_scoped),
|
| 55 |
+
db: AsyncSession = Depends(get_db),
|
| 56 |
+
):
|
| 57 |
+
"""List reader sessions with pagination."""
|
| 58 |
+
from sqlalchemy import select, desc
|
| 59 |
+
from app.models.chat_session import ChatSession
|
| 60 |
+
|
| 61 |
+
result = await db.execute(
|
| 62 |
+
select(ChatSession)
|
| 63 |
+
.where(ChatSession.author_id == current_user.id)
|
| 64 |
+
.order_by(desc(ChatSession.created_at))
|
| 65 |
+
.offset(offset)
|
| 66 |
+
.limit(limit)
|
| 67 |
+
)
|
| 68 |
+
sessions = result.scalars().all()
|
| 69 |
+
return {
|
| 70 |
+
"sessions": [
|
| 71 |
+
{
|
| 72 |
+
"id": s.id,
|
| 73 |
+
"visitor_fingerprint": s.visitor_fingerprint,
|
| 74 |
+
"visitor_name": s.visitor_name,
|
| 75 |
+
"country": s.country_name,
|
| 76 |
+
"device_type": s.device_type,
|
| 77 |
+
"turn_count": s.turn_count,
|
| 78 |
+
"rating": s.rating,
|
| 79 |
+
"created_at": s.created_at.isoformat() if s.created_at else None,
|
| 80 |
+
"blocked": s.blocked,
|
| 81 |
+
}
|
| 82 |
+
for s in sessions
|
| 83 |
+
]
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
@router.get("/{author_slug}/sessions/search")
|
| 88 |
+
async def search_sessions(
|
| 89 |
+
author_slug: str,
|
| 90 |
+
q: str = Query("", min_length=0, max_length=200),
|
| 91 |
+
page: int = Query(1, ge=1),
|
| 92 |
+
per_page: int = Query(20, ge=1, le=100),
|
| 93 |
+
current_user=Depends(get_current_author_scoped),
|
| 94 |
+
db: AsyncSession = Depends(get_db),
|
| 95 |
+
):
|
| 96 |
+
"""Full-text search across chat messages for the author."""
|
| 97 |
+
from sqlalchemy import select, func, desc, distinct
|
| 98 |
+
from app.models.chat_session import ChatSession, ChatMessage
|
| 99 |
+
|
| 100 |
+
if not q.strip():
|
| 101 |
+
return {"sessions": [], "total": 0}
|
| 102 |
+
|
| 103 |
+
search_term = f"%{q.strip().lower()}%"
|
| 104 |
+
|
| 105 |
+
matching_sessions = (
|
| 106 |
+
select(distinct(ChatMessage.session_id))
|
| 107 |
+
.join(ChatSession, ChatMessage.session_id == ChatSession.id)
|
| 108 |
+
.where(
|
| 109 |
+
ChatSession.author_id == current_user.id,
|
| 110 |
+
func.lower(ChatMessage.content).like(search_term),
|
| 111 |
+
)
|
| 112 |
+
)
|
| 113 |
+
|
| 114 |
+
total = await db.scalar(
|
| 115 |
+
select(func.count()).select_from(matching_sessions.subquery())
|
| 116 |
+
) or 0
|
| 117 |
+
|
| 118 |
+
result = await db.execute(
|
| 119 |
+
select(ChatSession)
|
| 120 |
+
.where(
|
| 121 |
+
ChatSession.author_id == current_user.id,
|
| 122 |
+
ChatSession.id.in_(matching_sessions),
|
| 123 |
+
)
|
| 124 |
+
.order_by(desc(ChatSession.created_at))
|
| 125 |
+
.offset((page - 1) * per_page)
|
| 126 |
+
.limit(per_page)
|
| 127 |
+
)
|
| 128 |
+
sessions = result.scalars().all()
|
| 129 |
+
|
| 130 |
+
return {
|
| 131 |
+
"sessions": [
|
| 132 |
+
{
|
| 133 |
+
"id": s.id,
|
| 134 |
+
"visitor_fingerprint": s.visitor_fingerprint[:8] + "...",
|
| 135 |
+
"visitor_name": s.visitor_name,
|
| 136 |
+
"country": s.country_name,
|
| 137 |
+
"device_type": s.device_type,
|
| 138 |
+
"turn_count": s.turn_count,
|
| 139 |
+
"rating": s.rating,
|
| 140 |
+
"created_at": s.created_at.isoformat() if s.created_at else None,
|
| 141 |
+
}
|
| 142 |
+
for s in sessions
|
| 143 |
+
],
|
| 144 |
+
"total": total,
|
| 145 |
+
"query": q,
|
| 146 |
+
"page": page,
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
@router.post("/{author_slug}/sessions/{session_id}/block")
|
| 151 |
+
async def block_session(
|
| 152 |
+
author_slug: str,
|
| 153 |
+
session_id: str,
|
| 154 |
+
current_user=Depends(get_current_author_scoped),
|
| 155 |
+
db: AsyncSession = Depends(get_db),
|
| 156 |
+
):
|
| 157 |
+
"""Block a visitor session."""
|
| 158 |
+
from sqlalchemy import select
|
| 159 |
+
from app.models.chat_session import ChatSession
|
| 160 |
+
from fastapi import HTTPException
|
| 161 |
+
|
| 162 |
+
result = await db.execute(
|
| 163 |
+
select(ChatSession).where(
|
| 164 |
+
ChatSession.id == session_id,
|
| 165 |
+
ChatSession.author_id == current_user.id,
|
| 166 |
+
)
|
| 167 |
+
)
|
| 168 |
+
session = result.scalar_one_or_none()
|
| 169 |
+
if not session:
|
| 170 |
+
raise HTTPException(404, "Session not found")
|
| 171 |
+
session.blocked = True
|
| 172 |
+
await db.commit()
|
| 173 |
+
return {"message": "Session blocked"}
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
@router.post("/{author_slug}/sessions/{session_id}/unblock")
|
| 177 |
+
async def unblock_session(
|
| 178 |
+
author_slug: str,
|
| 179 |
+
session_id: str,
|
| 180 |
+
current_user=Depends(get_current_author_scoped),
|
| 181 |
+
db: AsyncSession = Depends(get_db),
|
| 182 |
+
):
|
| 183 |
+
"""Unblock a visitor session."""
|
| 184 |
+
from sqlalchemy import select
|
| 185 |
+
from app.models.chat_session import ChatSession
|
| 186 |
+
from fastapi import HTTPException
|
| 187 |
+
|
| 188 |
+
result = await db.execute(
|
| 189 |
+
select(ChatSession).where(
|
| 190 |
+
ChatSession.id == session_id,
|
| 191 |
+
ChatSession.author_id == current_user.id,
|
| 192 |
+
)
|
| 193 |
+
)
|
| 194 |
+
session = result.scalar_one_or_none()
|
| 195 |
+
if not session:
|
| 196 |
+
raise HTTPException(404, "Session not found")
|
| 197 |
+
session.blocked = False
|
| 198 |
+
await db.commit()
|
| 199 |
+
return {"message": "Session unblocked"}
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
@router.get("/{author_slug}/sessions/{session_id}/transcript")
|
| 203 |
+
async def get_transcript(
|
| 204 |
+
author_slug: str,
|
| 205 |
+
session_id: str,
|
| 206 |
+
page: int = Query(1, ge=1),
|
| 207 |
+
per_page: int = Query(50, ge=1, le=200),
|
| 208 |
+
current_user=Depends(get_current_author_scoped),
|
| 209 |
+
db: AsyncSession = Depends(get_db),
|
| 210 |
+
):
|
| 211 |
+
"""Return paginated message transcript for a session."""
|
| 212 |
+
from sqlalchemy import select, func
|
| 213 |
+
from app.models.chat_session import ChatSession, ChatMessage
|
| 214 |
+
from fastapi import HTTPException
|
| 215 |
+
|
| 216 |
+
session_result = await db.execute(
|
| 217 |
+
select(ChatSession).where(
|
| 218 |
+
ChatSession.id == session_id,
|
| 219 |
+
ChatSession.author_id == current_user.id,
|
| 220 |
+
)
|
| 221 |
+
)
|
| 222 |
+
session = session_result.scalar_one_or_none()
|
| 223 |
+
if not session:
|
| 224 |
+
raise HTTPException(404, "Session not found")
|
| 225 |
+
|
| 226 |
+
total = await db.scalar(
|
| 227 |
+
select(func.count()).where(ChatMessage.session_id == session_id)
|
| 228 |
+
) or 0
|
| 229 |
+
|
| 230 |
+
result = await db.execute(
|
| 231 |
+
select(ChatMessage)
|
| 232 |
+
.where(ChatMessage.session_id == session_id)
|
| 233 |
+
.order_by(ChatMessage.created_at.asc())
|
| 234 |
+
.offset((page - 1) * per_page)
|
| 235 |
+
.limit(per_page)
|
| 236 |
+
)
|
| 237 |
+
messages = result.scalars().all()
|
| 238 |
+
|
| 239 |
+
return {
|
| 240 |
+
"session": {
|
| 241 |
+
"id": session.id,
|
| 242 |
+
"visitor_fingerprint": session.visitor_fingerprint[:8] + "...",
|
| 243 |
+
"visitor_name": session.visitor_name,
|
| 244 |
+
"visitor_email": session.visitor_email,
|
| 245 |
+
"country": session.country_name,
|
| 246 |
+
"city": session.city,
|
| 247 |
+
"device_type": session.device_type,
|
| 248 |
+
"browser": session.browser,
|
| 249 |
+
"os": session.os,
|
| 250 |
+
"rating": session.rating,
|
| 251 |
+
"blocked": session.blocked,
|
| 252 |
+
"created_at": session.created_at.isoformat() if session.created_at else None,
|
| 253 |
+
"summary": session.summary,
|
| 254 |
+
},
|
| 255 |
+
"messages": [
|
| 256 |
+
{
|
| 257 |
+
"id": m.id,
|
| 258 |
+
"role": m.role,
|
| 259 |
+
"content": m.content,
|
| 260 |
+
"intent": m.intent,
|
| 261 |
+
"intent_confidence": m.intent_confidence,
|
| 262 |
+
"faithfulness_score": m.faithfulness_score,
|
| 263 |
+
"hallucination_detected": m.hallucination_detected,
|
| 264 |
+
"prompt_tokens": m.prompt_tokens,
|
| 265 |
+
"completion_tokens": m.completion_tokens,
|
| 266 |
+
"response_ms": m.response_ms,
|
| 267 |
+
"annotation": m.annotation,
|
| 268 |
+
"flag_type": m.flag_type,
|
| 269 |
+
"user_feedback": m.user_feedback,
|
| 270 |
+
"created_at": m.created_at.isoformat() if m.created_at else None,
|
| 271 |
+
}
|
| 272 |
+
for m in messages
|
| 273 |
+
],
|
| 274 |
+
"pagination": {
|
| 275 |
+
"page": page,
|
| 276 |
+
"per_page": per_page,
|
| 277 |
+
"total": total,
|
| 278 |
+
"total_pages": (total + per_page - 1) // per_page,
|
| 279 |
+
},
|
| 280 |
+
}
|
| 281 |
+
|
| 282 |
+
|
| 283 |
+
@router.post("/{author_slug}/sessions/{session_id}/messages/{message_id}/annotate")
|
| 284 |
+
async def annotate_message(
|
| 285 |
+
author_slug: str,
|
| 286 |
+
session_id: str,
|
| 287 |
+
message_id: str,
|
| 288 |
+
body: AnnotateRequest,
|
| 289 |
+
current_user=Depends(get_current_author_scoped),
|
| 290 |
+
db: AsyncSession = Depends(get_db),
|
| 291 |
+
):
|
| 292 |
+
"""Add admin annotation to a chat message (append-only). R-029: Schema validated."""
|
| 293 |
+
from sqlalchemy import select
|
| 294 |
+
from app.models.chat_session import ChatMessage, ChatSession
|
| 295 |
+
from fastapi import HTTPException
|
| 296 |
+
from datetime import datetime, timezone
|
| 297 |
+
|
| 298 |
+
result = await db.execute(
|
| 299 |
+
select(ChatMessage)
|
| 300 |
+
.join(ChatSession, ChatMessage.session_id == ChatSession.id)
|
| 301 |
+
.where(
|
| 302 |
+
ChatMessage.id == message_id,
|
| 303 |
+
ChatMessage.session_id == session_id,
|
| 304 |
+
ChatSession.author_id == current_user.id,
|
| 305 |
+
)
|
| 306 |
+
)
|
| 307 |
+
msg = result.scalar_one_or_none()
|
| 308 |
+
if not msg:
|
| 309 |
+
raise HTTPException(404, "Message not found")
|
| 310 |
+
|
| 311 |
+
prefix = f"[{datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M')}] "
|
| 312 |
+
existing = msg.annotation or ""
|
| 313 |
+
msg.annotation = (prefix + body.annotation + "\n" + existing).strip()
|
| 314 |
+
await db.commit()
|
| 315 |
+
return {"message": "Annotation saved", "annotation": msg.annotation}
|
| 316 |
+
|
| 317 |
+
|
| 318 |
+
@router.post("/{author_slug}/sessions/{session_id}/messages/{message_id}/flag")
|
| 319 |
+
async def flag_message(
|
| 320 |
+
author_slug: str,
|
| 321 |
+
session_id: str,
|
| 322 |
+
message_id: str,
|
| 323 |
+
body: FlagRequest,
|
| 324 |
+
current_user=Depends(get_current_author_scoped),
|
| 325 |
+
db: AsyncSession = Depends(get_db),
|
| 326 |
+
):
|
| 327 |
+
"""Flag a message as spam, quality issue, or escalation. R-029: Schema validated."""
|
| 328 |
+
from sqlalchemy import select
|
| 329 |
+
from app.models.chat_session import ChatMessage, ChatSession
|
| 330 |
+
from fastapi import HTTPException
|
| 331 |
+
|
| 332 |
+
result = await db.execute(
|
| 333 |
+
select(ChatMessage)
|
| 334 |
+
.join(ChatSession, ChatMessage.session_id == ChatSession.id)
|
| 335 |
+
.where(
|
| 336 |
+
ChatMessage.id == message_id,
|
| 337 |
+
ChatMessage.session_id == session_id,
|
| 338 |
+
ChatSession.author_id == current_user.id,
|
| 339 |
+
)
|
| 340 |
+
)
|
| 341 |
+
msg = result.scalar_one_or_none()
|
| 342 |
+
if not msg:
|
| 343 |
+
raise HTTPException(404, "Message not found")
|
| 344 |
+
|
| 345 |
+
msg.flag_type = body.flag_type
|
| 346 |
+
await db.commit()
|
| 347 |
+
return {"message": "Flag updated", "flag_type": body.flag_type}
|
app/admin/routers/exports.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""admin/routers/exports.py — CSV data export routes.
|
| 2 |
+
|
| 3 |
+
Routes:
|
| 4 |
+
GET /{slug}/export/sessions
|
| 5 |
+
GET /{slug}/export/analytics
|
| 6 |
+
GET /{slug}/export/conversations
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from fastapi import APIRouter, Depends, Query
|
| 10 |
+
from sqlalchemy import Integer
|
| 11 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 12 |
+
|
| 13 |
+
from app.dependencies import get_db, get_current_author_scoped
|
| 14 |
+
|
| 15 |
+
router = APIRouter()
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
@router.get("/{author_slug}/export/sessions")
|
| 19 |
+
async def export_sessions(
|
| 20 |
+
author_slug: str,
|
| 21 |
+
days: int = Query(30, ge=1, le=365),
|
| 22 |
+
current_user=Depends(get_current_author_scoped),
|
| 23 |
+
db: AsyncSession = Depends(get_db),
|
| 24 |
+
):
|
| 25 |
+
"""Export chat sessions as CSV."""
|
| 26 |
+
import csv
|
| 27 |
+
import io
|
| 28 |
+
from datetime import datetime, timedelta, timezone
|
| 29 |
+
from sqlalchemy import select
|
| 30 |
+
from app.models.chat_session import ChatSession
|
| 31 |
+
from fastapi.responses import StreamingResponse
|
| 32 |
+
|
| 33 |
+
since = datetime.now(timezone.utc) - timedelta(days=days)
|
| 34 |
+
result = await db.execute(
|
| 35 |
+
select(ChatSession)
|
| 36 |
+
.where(ChatSession.author_id == current_user.id, ChatSession.created_at >= since)
|
| 37 |
+
.order_by(ChatSession.created_at.desc())
|
| 38 |
+
.limit(50000)
|
| 39 |
+
)
|
| 40 |
+
sessions = result.scalars().all()
|
| 41 |
+
|
| 42 |
+
output = io.StringIO()
|
| 43 |
+
writer = csv.writer(output)
|
| 44 |
+
writer.writerow([
|
| 45 |
+
"session_id", "visitor_fp", "visitor_name", "country", "city",
|
| 46 |
+
"device", "browser", "os", "turn_count", "link_clicked", "rating",
|
| 47 |
+
"blocked", "created_at",
|
| 48 |
+
])
|
| 49 |
+
for s in sessions:
|
| 50 |
+
writer.writerow([
|
| 51 |
+
s.id, s.visitor_fingerprint[:8], s.visitor_name or "",
|
| 52 |
+
s.country_name or "", s.city or "", s.device_type or "",
|
| 53 |
+
s.browser or "", s.os or "", s.turn_count, s.link_clicked,
|
| 54 |
+
s.rating or "", s.blocked,
|
| 55 |
+
s.created_at.isoformat() if s.created_at else "",
|
| 56 |
+
])
|
| 57 |
+
|
| 58 |
+
output.seek(0)
|
| 59 |
+
return StreamingResponse(
|
| 60 |
+
iter([output.getvalue()]),
|
| 61 |
+
media_type="text/csv",
|
| 62 |
+
headers={"Content-Disposition": f"attachment; filename=sessions_{days}d.csv"},
|
| 63 |
+
)
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
@router.get("/{author_slug}/export/analytics")
|
| 67 |
+
async def export_analytics(
|
| 68 |
+
author_slug: str,
|
| 69 |
+
days: int = Query(30, ge=1, le=365),
|
| 70 |
+
current_user=Depends(get_current_author_scoped),
|
| 71 |
+
db: AsyncSession = Depends(get_db),
|
| 72 |
+
):
|
| 73 |
+
"""Export daily analytics aggregates as CSV."""
|
| 74 |
+
import csv
|
| 75 |
+
import io
|
| 76 |
+
from datetime import datetime, timedelta, timezone
|
| 77 |
+
from sqlalchemy import select, func
|
| 78 |
+
from app.models.analytics import AnalyticsEvent
|
| 79 |
+
from fastapi.responses import StreamingResponse
|
| 80 |
+
|
| 81 |
+
since = datetime.now(timezone.utc) - timedelta(days=days)
|
| 82 |
+
result = await db.execute(
|
| 83 |
+
select(
|
| 84 |
+
func.date(AnalyticsEvent.timestamp).label("date"),
|
| 85 |
+
func.count().label("events"),
|
| 86 |
+
func.sum(AnalyticsEvent.prompt_tokens + AnalyticsEvent.completion_tokens).label("tokens"),
|
| 87 |
+
func.avg(AnalyticsEvent.response_ms).label("avg_latency"),
|
| 88 |
+
func.sum(func.cast(AnalyticsEvent.link_clicked, Integer)).label("clicks"),
|
| 89 |
+
)
|
| 90 |
+
.where(AnalyticsEvent.author_id == current_user.id, AnalyticsEvent.timestamp >= since)
|
| 91 |
+
.group_by(func.date(AnalyticsEvent.timestamp))
|
| 92 |
+
.order_by(func.date(AnalyticsEvent.timestamp))
|
| 93 |
+
)
|
| 94 |
+
rows = result.all()
|
| 95 |
+
|
| 96 |
+
output = io.StringIO()
|
| 97 |
+
writer = csv.writer(output)
|
| 98 |
+
writer.writerow(["date", "events", "total_tokens", "avg_latency_ms", "link_clicks"])
|
| 99 |
+
for r in rows:
|
| 100 |
+
writer.writerow([str(r.date), r.events, r.tokens or 0, round(r.avg_latency or 0, 1), r.clicks or 0])
|
| 101 |
+
|
| 102 |
+
output.seek(0)
|
| 103 |
+
return StreamingResponse(
|
| 104 |
+
iter([output.getvalue()]),
|
| 105 |
+
media_type="text/csv",
|
| 106 |
+
headers={"Content-Disposition": f"attachment; filename=analytics_{days}d.csv"},
|
| 107 |
+
)
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
@router.get("/{author_slug}/export/conversations")
|
| 111 |
+
async def export_conversations(
|
| 112 |
+
author_slug: str,
|
| 113 |
+
session_id: str | None = None,
|
| 114 |
+
days: int = Query(7, ge=1, le=30),
|
| 115 |
+
current_user=Depends(get_current_author_scoped),
|
| 116 |
+
db: AsyncSession = Depends(get_db),
|
| 117 |
+
):
|
| 118 |
+
"""Export full conversation transcripts as CSV."""
|
| 119 |
+
import csv
|
| 120 |
+
import io
|
| 121 |
+
from datetime import datetime, timedelta, timezone
|
| 122 |
+
from sqlalchemy import select
|
| 123 |
+
from app.models.chat_session import ChatSession, ChatMessage
|
| 124 |
+
from fastapi.responses import StreamingResponse
|
| 125 |
+
|
| 126 |
+
since = datetime.now(timezone.utc) - timedelta(days=days)
|
| 127 |
+
query = (
|
| 128 |
+
select(ChatMessage)
|
| 129 |
+
.join(ChatSession, ChatMessage.session_id == ChatSession.id)
|
| 130 |
+
.where(ChatSession.author_id == current_user.id)
|
| 131 |
+
.order_by(ChatMessage.created_at.asc())
|
| 132 |
+
.limit(50000)
|
| 133 |
+
)
|
| 134 |
+
if session_id:
|
| 135 |
+
query = query.where(ChatSession.id == session_id)
|
| 136 |
+
else:
|
| 137 |
+
query = query.where(ChatSession.created_at >= since)
|
| 138 |
+
|
| 139 |
+
result = await db.execute(query)
|
| 140 |
+
messages = result.scalars().all()
|
| 141 |
+
|
| 142 |
+
output = io.StringIO()
|
| 143 |
+
writer = csv.writer(output)
|
| 144 |
+
writer.writerow([
|
| 145 |
+
"session_id", "role", "content", "intent", "confidence",
|
| 146 |
+
"faithfulness", "hallucination", "tokens", "latency_ms", "timestamp",
|
| 147 |
+
])
|
| 148 |
+
for m in messages:
|
| 149 |
+
writer.writerow([
|
| 150 |
+
m.session_id, m.role, m.content[:500], m.intent or "",
|
| 151 |
+
m.intent_confidence or "", m.faithfulness_score or "",
|
| 152 |
+
m.hallucination_detected or "", (m.prompt_tokens or 0) + (m.completion_tokens or 0),
|
| 153 |
+
m.response_ms or "",
|
| 154 |
+
m.created_at.isoformat() if m.created_at else "",
|
| 155 |
+
])
|
| 156 |
+
|
| 157 |
+
output.seek(0)
|
| 158 |
+
return StreamingResponse(
|
| 159 |
+
iter([output.getvalue()]),
|
| 160 |
+
media_type="text/csv",
|
| 161 |
+
headers={"Content-Disposition": "attachment; filename=conversations.csv"},
|
| 162 |
+
)
|
app/admin/routers/links.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""admin/routers/links.py — Smart links (buy/preview URLs) routes.
|
| 2 |
+
|
| 3 |
+
Routes:
|
| 4 |
+
PUT /{slug}/smart-links/{book_id}
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from fastapi import APIRouter, Depends
|
| 8 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 9 |
+
|
| 10 |
+
from app.dependencies import get_db, get_current_author_scoped
|
| 11 |
+
from app.repositories.book_repo import BookRepository
|
| 12 |
+
|
| 13 |
+
router = APIRouter()
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
@router.put("/{author_slug}/smart-links/{book_id}")
|
| 17 |
+
async def update_smart_link(
|
| 18 |
+
author_slug: str,
|
| 19 |
+
book_id: str,
|
| 20 |
+
body: dict,
|
| 21 |
+
current_user=Depends(get_current_author_scoped),
|
| 22 |
+
db: AsyncSession = Depends(get_db),
|
| 23 |
+
):
|
| 24 |
+
"""Update buy/preview URLs for a book."""
|
| 25 |
+
from fastapi import HTTPException
|
| 26 |
+
from app.repositories.link_repo import LinkRepository
|
| 27 |
+
|
| 28 |
+
book_repo = BookRepository(db)
|
| 29 |
+
book = await book_repo.get_by_id(book_id)
|
| 30 |
+
if not book or book.author_id != current_user.id:
|
| 31 |
+
raise HTTPException(404, "Book not found")
|
| 32 |
+
|
| 33 |
+
if "buy_url" in body:
|
| 34 |
+
book.buy_url = str(body["buy_url"])[:1000] if body["buy_url"] else None
|
| 35 |
+
if "preview_url" in body:
|
| 36 |
+
book.preview_url = str(body["preview_url"])[:1000] if body["preview_url"] else None
|
| 37 |
+
|
| 38 |
+
link_repo = LinkRepository(db)
|
| 39 |
+
await link_repo.upsert_for_book(
|
| 40 |
+
current_user.id,
|
| 41 |
+
book_id,
|
| 42 |
+
{
|
| 43 |
+
"purchase_url": book.buy_url,
|
| 44 |
+
"preview_url": book.preview_url,
|
| 45 |
+
},
|
| 46 |
+
)
|
| 47 |
+
await db.commit()
|
| 48 |
+
return {"message": "Smart link updated"}
|
app/admin/routers/qa.py
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""admin/routers/qa.py — Custom Q&A training data routes.
|
| 2 |
+
|
| 3 |
+
Routes:
|
| 4 |
+
GET /{slug}/qa
|
| 5 |
+
POST /{slug}/qa
|
| 6 |
+
POST /{slug}/qa/import
|
| 7 |
+
GET /{slug}/qa/export
|
| 8 |
+
PUT /{slug}/qa/{qa_id}
|
| 9 |
+
DELETE /{slug}/qa/{qa_id}
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from fastapi import APIRouter, Depends, File, UploadFile
|
| 13 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 14 |
+
|
| 15 |
+
from app.dependencies import get_db, get_current_author_scoped
|
| 16 |
+
|
| 17 |
+
router = APIRouter()
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
@router.get("/{author_slug}/qa")
|
| 21 |
+
async def list_qa(
|
| 22 |
+
author_slug: str,
|
| 23 |
+
book_id: str | None = None,
|
| 24 |
+
current_user=Depends(get_current_author_scoped),
|
| 25 |
+
db: AsyncSession = Depends(get_db),
|
| 26 |
+
):
|
| 27 |
+
"""List all custom Q&A pairs for the author."""
|
| 28 |
+
from sqlalchemy import select
|
| 29 |
+
from app.models.custom_qa import CustomQA
|
| 30 |
+
|
| 31 |
+
query = (
|
| 32 |
+
select(CustomQA)
|
| 33 |
+
.where(CustomQA.author_id == current_user.id)
|
| 34 |
+
.order_by(CustomQA.priority.desc(), CustomQA.created_at.desc())
|
| 35 |
+
)
|
| 36 |
+
if book_id:
|
| 37 |
+
query = query.where(CustomQA.book_id == book_id)
|
| 38 |
+
|
| 39 |
+
result = await db.execute(query)
|
| 40 |
+
items = result.scalars().all()
|
| 41 |
+
return {
|
| 42 |
+
"qa_pairs": [
|
| 43 |
+
{
|
| 44 |
+
"id": qa.id,
|
| 45 |
+
"book_id": qa.book_id,
|
| 46 |
+
"question": qa.question,
|
| 47 |
+
"answer": qa.answer,
|
| 48 |
+
"priority": qa.priority,
|
| 49 |
+
"is_active": qa.is_active,
|
| 50 |
+
"match_count": qa.match_count,
|
| 51 |
+
"match_threshold": qa.match_threshold,
|
| 52 |
+
"category": qa.category,
|
| 53 |
+
"created_at": qa.created_at.isoformat() if qa.created_at else None,
|
| 54 |
+
}
|
| 55 |
+
for qa in items
|
| 56 |
+
],
|
| 57 |
+
"total": len(items),
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
@router.post("/{author_slug}/qa", status_code=201)
|
| 62 |
+
async def create_qa(
|
| 63 |
+
author_slug: str,
|
| 64 |
+
body: dict,
|
| 65 |
+
current_user=Depends(get_current_author_scoped),
|
| 66 |
+
db: AsyncSession = Depends(get_db),
|
| 67 |
+
):
|
| 68 |
+
"""Create a new custom Q&A pair."""
|
| 69 |
+
from sqlalchemy import select, func
|
| 70 |
+
from app.models.custom_qa import CustomQA
|
| 71 |
+
from fastapi import HTTPException
|
| 72 |
+
|
| 73 |
+
count = await db.scalar(
|
| 74 |
+
select(func.count()).where(CustomQA.author_id == current_user.id)
|
| 75 |
+
) or 0
|
| 76 |
+
if count >= 500:
|
| 77 |
+
raise HTTPException(400, "Maximum 500 Q&A pairs allowed")
|
| 78 |
+
|
| 79 |
+
question = str(body.get("question", "")).strip()
|
| 80 |
+
answer = str(body.get("answer", "")).strip()
|
| 81 |
+
if not question or not answer:
|
| 82 |
+
raise HTTPException(400, "Both question and answer are required")
|
| 83 |
+
if len(question) > 500:
|
| 84 |
+
raise HTTPException(400, "Question must be under 500 characters")
|
| 85 |
+
if len(answer) > 2000:
|
| 86 |
+
raise HTTPException(400, "Answer must be under 2000 characters")
|
| 87 |
+
|
| 88 |
+
qa = CustomQA(
|
| 89 |
+
author_id=current_user.id,
|
| 90 |
+
book_id=body.get("book_id"),
|
| 91 |
+
question=question,
|
| 92 |
+
answer=answer,
|
| 93 |
+
priority=int(body.get("priority", 0)),
|
| 94 |
+
category=body.get("category"),
|
| 95 |
+
match_threshold=float(body.get("match_threshold", 0.85)),
|
| 96 |
+
)
|
| 97 |
+
db.add(qa)
|
| 98 |
+
await db.commit()
|
| 99 |
+
return {"id": qa.id, "message": "Q&A pair created"}
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
@router.post("/{author_slug}/qa/import")
|
| 103 |
+
async def import_qa_csv(
|
| 104 |
+
author_slug: str,
|
| 105 |
+
current_user=Depends(get_current_author_scoped),
|
| 106 |
+
db: AsyncSession = Depends(get_db),
|
| 107 |
+
file: UploadFile = File(...),
|
| 108 |
+
):
|
| 109 |
+
"""Bulk import Q&A pairs from CSV (columns: question,answer,category,priority)."""
|
| 110 |
+
import csv
|
| 111 |
+
import io
|
| 112 |
+
from sqlalchemy import select, func
|
| 113 |
+
from app.models.custom_qa import CustomQA
|
| 114 |
+
from fastapi import HTTPException
|
| 115 |
+
|
| 116 |
+
contents = await file.read()
|
| 117 |
+
text = contents.decode("utf-8-sig")
|
| 118 |
+
reader = csv.DictReader(io.StringIO(text))
|
| 119 |
+
|
| 120 |
+
if not reader.fieldnames or "question" not in reader.fieldnames or "answer" not in reader.fieldnames:
|
| 121 |
+
raise HTTPException(400, "CSV must have 'question' and 'answer' columns")
|
| 122 |
+
|
| 123 |
+
current_count = await db.scalar(
|
| 124 |
+
select(func.count()).where(CustomQA.author_id == current_user.id)
|
| 125 |
+
) or 0
|
| 126 |
+
|
| 127 |
+
imported = 0
|
| 128 |
+
skipped = 0
|
| 129 |
+
errors = []
|
| 130 |
+
|
| 131 |
+
for i, row in enumerate(reader, 1):
|
| 132 |
+
if current_count + imported >= 500:
|
| 133 |
+
errors.append(f"Row {i}: Limit of 500 Q&A pairs reached")
|
| 134 |
+
break
|
| 135 |
+
|
| 136 |
+
q = (row.get("question") or "").strip()
|
| 137 |
+
a = (row.get("answer") or "").strip()
|
| 138 |
+
if not q or not a:
|
| 139 |
+
skipped += 1
|
| 140 |
+
continue
|
| 141 |
+
if len(q) > 500 or len(a) > 2000:
|
| 142 |
+
errors.append(f"Row {i}: Question or answer too long")
|
| 143 |
+
skipped += 1
|
| 144 |
+
continue
|
| 145 |
+
|
| 146 |
+
qa = CustomQA(
|
| 147 |
+
author_id=current_user.id,
|
| 148 |
+
question=q[:500],
|
| 149 |
+
answer=a[:2000],
|
| 150 |
+
category=(row.get("category") or "").strip()[:50] or None,
|
| 151 |
+
priority=int(row.get("priority") or 0),
|
| 152 |
+
)
|
| 153 |
+
db.add(qa)
|
| 154 |
+
imported += 1
|
| 155 |
+
|
| 156 |
+
await db.commit()
|
| 157 |
+
return {
|
| 158 |
+
"imported": imported,
|
| 159 |
+
"skipped": skipped,
|
| 160 |
+
"errors": errors[:10],
|
| 161 |
+
"message": f"Imported {imported} Q&A pairs",
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
@router.get("/{author_slug}/qa/export")
|
| 166 |
+
async def export_qa_csv(
|
| 167 |
+
author_slug: str,
|
| 168 |
+
current_user=Depends(get_current_author_scoped),
|
| 169 |
+
db: AsyncSession = Depends(get_db),
|
| 170 |
+
):
|
| 171 |
+
"""Export all Q&A pairs as CSV."""
|
| 172 |
+
import csv
|
| 173 |
+
import io
|
| 174 |
+
from sqlalchemy import select
|
| 175 |
+
from app.models.custom_qa import CustomQA
|
| 176 |
+
from fastapi.responses import StreamingResponse
|
| 177 |
+
|
| 178 |
+
result = await db.execute(
|
| 179 |
+
select(CustomQA)
|
| 180 |
+
.where(CustomQA.author_id == current_user.id)
|
| 181 |
+
.order_by(CustomQA.priority.desc())
|
| 182 |
+
)
|
| 183 |
+
items = result.scalars().all()
|
| 184 |
+
|
| 185 |
+
output = io.StringIO()
|
| 186 |
+
writer = csv.writer(output)
|
| 187 |
+
writer.writerow(["question", "answer", "category", "priority", "is_active", "match_count"])
|
| 188 |
+
for qa in items:
|
| 189 |
+
writer.writerow([qa.question, qa.answer, qa.category or "", qa.priority, qa.is_active, qa.match_count])
|
| 190 |
+
|
| 191 |
+
output.seek(0)
|
| 192 |
+
return StreamingResponse(
|
| 193 |
+
iter([output.getvalue()]),
|
| 194 |
+
media_type="text/csv",
|
| 195 |
+
headers={"Content-Disposition": "attachment; filename=qa_pairs.csv"},
|
| 196 |
+
)
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
@router.put("/{author_slug}/qa/{qa_id}")
|
| 200 |
+
async def update_qa(
|
| 201 |
+
author_slug: str,
|
| 202 |
+
qa_id: str,
|
| 203 |
+
body: dict,
|
| 204 |
+
current_user=Depends(get_current_author_scoped),
|
| 205 |
+
db: AsyncSession = Depends(get_db),
|
| 206 |
+
):
|
| 207 |
+
"""Update an existing Q&A pair."""
|
| 208 |
+
from sqlalchemy import select
|
| 209 |
+
from app.models.custom_qa import CustomQA
|
| 210 |
+
from fastapi import HTTPException
|
| 211 |
+
|
| 212 |
+
result = await db.execute(
|
| 213 |
+
select(CustomQA).where(CustomQA.id == qa_id, CustomQA.author_id == current_user.id)
|
| 214 |
+
)
|
| 215 |
+
qa = result.scalar_one_or_none()
|
| 216 |
+
if not qa:
|
| 217 |
+
raise HTTPException(404, "Q&A pair not found")
|
| 218 |
+
|
| 219 |
+
if "question" in body:
|
| 220 |
+
q = str(body["question"]).strip()
|
| 221 |
+
if not q or len(q) > 500:
|
| 222 |
+
raise HTTPException(400, "Question must be 1-500 characters")
|
| 223 |
+
qa.question = q
|
| 224 |
+
if "answer" in body:
|
| 225 |
+
a = str(body["answer"]).strip()
|
| 226 |
+
if not a or len(a) > 2000:
|
| 227 |
+
raise HTTPException(400, "Answer must be 1-2000 characters")
|
| 228 |
+
qa.answer = a
|
| 229 |
+
if "priority" in body:
|
| 230 |
+
qa.priority = int(body["priority"])
|
| 231 |
+
if "is_active" in body:
|
| 232 |
+
qa.is_active = bool(body["is_active"])
|
| 233 |
+
if "category" in body:
|
| 234 |
+
qa.category = body["category"]
|
| 235 |
+
if "book_id" in body:
|
| 236 |
+
qa.book_id = body["book_id"]
|
| 237 |
+
if "match_threshold" in body:
|
| 238 |
+
t = float(body["match_threshold"])
|
| 239 |
+
if not (0.5 <= t <= 1.0):
|
| 240 |
+
raise HTTPException(400, "Threshold must be between 0.5 and 1.0")
|
| 241 |
+
qa.match_threshold = t
|
| 242 |
+
|
| 243 |
+
await db.commit()
|
| 244 |
+
return {"message": "Q&A pair updated"}
|
| 245 |
+
|
| 246 |
+
|
| 247 |
+
@router.delete("/{author_slug}/qa/{qa_id}")
|
| 248 |
+
async def delete_qa(
|
| 249 |
+
author_slug: str,
|
| 250 |
+
qa_id: str,
|
| 251 |
+
current_user=Depends(get_current_author_scoped),
|
| 252 |
+
db: AsyncSession = Depends(get_db),
|
| 253 |
+
):
|
| 254 |
+
"""Delete a Q&A pair."""
|
| 255 |
+
from sqlalchemy import select
|
| 256 |
+
from app.models.custom_qa import CustomQA
|
| 257 |
+
from fastapi import HTTPException
|
| 258 |
+
|
| 259 |
+
result = await db.execute(
|
| 260 |
+
select(CustomQA).where(CustomQA.id == qa_id, CustomQA.author_id == current_user.id)
|
| 261 |
+
)
|
| 262 |
+
qa = result.scalar_one_or_none()
|
| 263 |
+
if not qa:
|
| 264 |
+
raise HTTPException(404, "Q&A pair not found")
|
| 265 |
+
|
| 266 |
+
await db.delete(qa)
|
| 267 |
+
await db.commit()
|
| 268 |
+
return {"message": "Q&A pair deleted"}
|
app/admin/routers/settings.py
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""admin/routers/settings.py — Author settings and account management.
|
| 2 |
+
|
| 3 |
+
Routes:
|
| 4 |
+
POST /{slug}/password
|
| 5 |
+
GET /{slug}/widget-config
|
| 6 |
+
PUT /{slug}/widget-config
|
| 7 |
+
GET /{slug}/profile
|
| 8 |
+
PUT /{slug}/profile
|
| 9 |
+
GET /{slug}/personality
|
| 10 |
+
PUT /{slug}/personality
|
| 11 |
+
GET /{slug}/notifications
|
| 12 |
+
PUT /{slug}/notifications
|
| 13 |
+
GET /{slug}/embed-token
|
| 14 |
+
GET /{slug}/token-usage
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
from fastapi import APIRouter, Depends
|
| 18 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 19 |
+
|
| 20 |
+
from app.dependencies import get_db, get_current_author_scoped
|
| 21 |
+
from app.schemas.admin import PasswordChangeRequest, ProfileUpdate, WidgetConfigUpdate
|
| 22 |
+
|
| 23 |
+
router = APIRouter()
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
@router.post("/{author_slug}/password")
|
| 27 |
+
async def change_password(
|
| 28 |
+
author_slug: str,
|
| 29 |
+
body: PasswordChangeRequest,
|
| 30 |
+
current_user=Depends(get_current_author_scoped),
|
| 31 |
+
db: AsyncSession = Depends(get_db),
|
| 32 |
+
):
|
| 33 |
+
"""Self-service password change. R-010: Validates via Pydantic schema."""
|
| 34 |
+
import bcrypt
|
| 35 |
+
from fastapi import HTTPException
|
| 36 |
+
|
| 37 |
+
try:
|
| 38 |
+
valid = bcrypt.checkpw(body.current_password.encode(), current_user.password_hash.encode())
|
| 39 |
+
except Exception:
|
| 40 |
+
valid = False
|
| 41 |
+
if not valid:
|
| 42 |
+
raise HTTPException(400, "Current password is incorrect")
|
| 43 |
+
|
| 44 |
+
current_user.password_hash = bcrypt.hashpw(
|
| 45 |
+
body.new_password.encode(), bcrypt.gensalt(12)
|
| 46 |
+
).decode()
|
| 47 |
+
await db.commit()
|
| 48 |
+
return {"message": "Password updated"}
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
@router.get("/{author_slug}/widget-config")
|
| 52 |
+
async def get_widget_config(
|
| 53 |
+
author_slug: str,
|
| 54 |
+
current_user=Depends(get_current_author_scoped),
|
| 55 |
+
):
|
| 56 |
+
"""Return current widget configuration."""
|
| 57 |
+
return {
|
| 58 |
+
"bot_name": current_user.bot_name,
|
| 59 |
+
"welcome_message": current_user.welcome_message,
|
| 60 |
+
"theme": current_user.widget_theme,
|
| 61 |
+
"position": current_user.widget_position,
|
| 62 |
+
"auto_open_delay": current_user.widget_auto_open_delay,
|
| 63 |
+
"is_active": current_user.chatbot_is_active,
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
@router.put("/{author_slug}/widget-config")
|
| 68 |
+
async def update_widget_config(
|
| 69 |
+
author_slug: str,
|
| 70 |
+
body: WidgetConfigUpdate,
|
| 71 |
+
current_user=Depends(get_current_author_scoped),
|
| 72 |
+
db: AsyncSession = Depends(get_db),
|
| 73 |
+
):
|
| 74 |
+
"""Update widget configuration. R-029: Validated via Pydantic schema."""
|
| 75 |
+
update_map = {
|
| 76 |
+
"bot_name": "bot_name",
|
| 77 |
+
"welcome_message": "welcome_message",
|
| 78 |
+
"theme": "widget_theme",
|
| 79 |
+
"position": "widget_position",
|
| 80 |
+
"auto_open_delay": "widget_auto_open_delay",
|
| 81 |
+
"is_active": "chatbot_is_active",
|
| 82 |
+
}
|
| 83 |
+
for field_name, attr in update_map.items():
|
| 84 |
+
val = getattr(body, field_name, None)
|
| 85 |
+
if val is not None:
|
| 86 |
+
setattr(current_user, attr, val)
|
| 87 |
+
await db.commit()
|
| 88 |
+
return {"message": "Widget config updated"}
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
@router.get("/{author_slug}/profile")
|
| 92 |
+
async def get_profile(
|
| 93 |
+
author_slug: str,
|
| 94 |
+
current_user=Depends(get_current_author_scoped),
|
| 95 |
+
):
|
| 96 |
+
"""Return current author profile."""
|
| 97 |
+
return {
|
| 98 |
+
"full_name": current_user.full_name or "",
|
| 99 |
+
"email": current_user.email,
|
| 100 |
+
"website": current_user.website_url or "",
|
| 101 |
+
"bio": current_user.bio or "",
|
| 102 |
+
"timezone": current_user.timezone,
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
@router.put("/{author_slug}/profile")
|
| 107 |
+
async def update_profile(
|
| 108 |
+
author_slug: str,
|
| 109 |
+
body: ProfileUpdate,
|
| 110 |
+
current_user=Depends(get_current_author_scoped),
|
| 111 |
+
db: AsyncSession = Depends(get_db),
|
| 112 |
+
):
|
| 113 |
+
"""Update author profile. R-029: Validated via Pydantic schema."""
|
| 114 |
+
if body.full_name is not None:
|
| 115 |
+
current_user.full_name = body.full_name
|
| 116 |
+
if body.website is not None:
|
| 117 |
+
current_user.website_url = body.website
|
| 118 |
+
if body.bio is not None:
|
| 119 |
+
current_user.bio = body.bio
|
| 120 |
+
if body.timezone is not None:
|
| 121 |
+
current_user.timezone = body.timezone
|
| 122 |
+
await db.commit()
|
| 123 |
+
return {"message": "Profile updated"}
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
@router.get("/{author_slug}/personality")
|
| 127 |
+
async def get_personality(
|
| 128 |
+
author_slug: str,
|
| 129 |
+
current_user=Depends(get_current_author_scoped),
|
| 130 |
+
):
|
| 131 |
+
"""Return bot personality settings."""
|
| 132 |
+
return {
|
| 133 |
+
"response_style": current_user.response_style,
|
| 134 |
+
"fallback_message": current_user.fallback_message,
|
| 135 |
+
"out_of_scope_message": current_user.out_of_scope_message,
|
| 136 |
+
"welcome_message": current_user.welcome_message,
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
@router.put("/{author_slug}/personality")
|
| 141 |
+
async def update_personality(
|
| 142 |
+
author_slug: str,
|
| 143 |
+
body: dict,
|
| 144 |
+
current_user=Depends(get_current_author_scoped),
|
| 145 |
+
db: AsyncSession = Depends(get_db),
|
| 146 |
+
):
|
| 147 |
+
"""Update bot personality settings."""
|
| 148 |
+
from fastapi import HTTPException
|
| 149 |
+
|
| 150 |
+
valid_styles = ["balanced", "formal", "casual", "enthusiastic"]
|
| 151 |
+
if "response_style" in body:
|
| 152 |
+
style = str(body["response_style"])
|
| 153 |
+
if style not in valid_styles:
|
| 154 |
+
raise HTTPException(400, f"Invalid style. Must be one of: {valid_styles}")
|
| 155 |
+
current_user.response_style = style
|
| 156 |
+
if "fallback_message" in body:
|
| 157 |
+
current_user.fallback_message = str(body["fallback_message"])[:500]
|
| 158 |
+
if "out_of_scope_message" in body:
|
| 159 |
+
current_user.out_of_scope_message = str(body["out_of_scope_message"])[:500]
|
| 160 |
+
await db.commit()
|
| 161 |
+
return {"message": "Personality settings updated"}
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
@router.get("/{author_slug}/notifications")
|
| 165 |
+
async def get_notifications(
|
| 166 |
+
author_slug: str,
|
| 167 |
+
current_user=Depends(get_current_author_scoped),
|
| 168 |
+
):
|
| 169 |
+
"""Return notification preferences."""
|
| 170 |
+
return {
|
| 171 |
+
"weekly_digest": current_user.notify_weekly_digest,
|
| 172 |
+
"token_alerts": current_user.notify_token_alerts,
|
| 173 |
+
"new_conversation": current_user.notify_new_conversation,
|
| 174 |
+
"subscription_expiry": current_user.notify_subscription_expiry,
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
@router.put("/{author_slug}/notifications")
|
| 179 |
+
async def update_notifications(
|
| 180 |
+
author_slug: str,
|
| 181 |
+
body: dict,
|
| 182 |
+
current_user=Depends(get_current_author_scoped),
|
| 183 |
+
db: AsyncSession = Depends(get_db),
|
| 184 |
+
):
|
| 185 |
+
"""Update notification preferences."""
|
| 186 |
+
mapping = {
|
| 187 |
+
"weekly_digest": "notify_weekly_digest",
|
| 188 |
+
"token_alerts": "notify_token_alerts",
|
| 189 |
+
"new_conversation": "notify_new_conversation",
|
| 190 |
+
"subscription_expiry": "notify_subscription_expiry",
|
| 191 |
+
}
|
| 192 |
+
for key, attr in mapping.items():
|
| 193 |
+
if key in body:
|
| 194 |
+
setattr(current_user, attr, bool(body[key]))
|
| 195 |
+
await db.commit()
|
| 196 |
+
return {"message": "Notification preferences updated"}
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
@router.get("/{author_slug}/embed-token")
|
| 200 |
+
async def get_embed_token(
|
| 201 |
+
author_slug: str,
|
| 202 |
+
current_user=Depends(get_current_author_scoped),
|
| 203 |
+
db: AsyncSession = Depends(get_db),
|
| 204 |
+
):
|
| 205 |
+
"""Return the active subscription token for embedding the widget."""
|
| 206 |
+
from datetime import datetime, timezone
|
| 207 |
+
from sqlalchemy import select
|
| 208 |
+
from app.models.client_access import ClientAccess
|
| 209 |
+
from app.core.access.token_crypto import create_subscription_token
|
| 210 |
+
from app.services.token_budget import tokens_remaining
|
| 211 |
+
|
| 212 |
+
now = datetime.now(timezone.utc)
|
| 213 |
+
now_naive = now.replace(tzinfo=None)
|
| 214 |
+
|
| 215 |
+
result = await db.execute(
|
| 216 |
+
select(ClientAccess)
|
| 217 |
+
.where(
|
| 218 |
+
ClientAccess.author_id == current_user.id,
|
| 219 |
+
ClientAccess.is_revoked == False,
|
| 220 |
+
ClientAccess.expires_at > now_naive,
|
| 221 |
+
)
|
| 222 |
+
.order_by(ClientAccess.expires_at.desc())
|
| 223 |
+
.limit(1)
|
| 224 |
+
)
|
| 225 |
+
access = result.scalar_one_or_none()
|
| 226 |
+
if not access:
|
| 227 |
+
return {
|
| 228 |
+
"active": False,
|
| 229 |
+
"token": None,
|
| 230 |
+
"message": "No active subscription. Contact your administrator to activate your chatbot.",
|
| 231 |
+
}
|
| 232 |
+
|
| 233 |
+
token = create_subscription_token(
|
| 234 |
+
author_id=current_user.id,
|
| 235 |
+
grant_id=access.id,
|
| 236 |
+
granted_at=access.granted_at,
|
| 237 |
+
expires_at=access.expires_at,
|
| 238 |
+
)
|
| 239 |
+
|
| 240 |
+
exp = access.expires_at
|
| 241 |
+
if exp and exp.tzinfo is not None:
|
| 242 |
+
exp = exp.replace(tzinfo=None)
|
| 243 |
+
days_remaining = max(0, (exp - now_naive).days) if exp else 0
|
| 244 |
+
|
| 245 |
+
return {
|
| 246 |
+
"active": True,
|
| 247 |
+
"token": token,
|
| 248 |
+
"grant_id": access.id,
|
| 249 |
+
"plan": access.plan,
|
| 250 |
+
"expires_at": access.expires_at.isoformat(),
|
| 251 |
+
"days_remaining": days_remaining,
|
| 252 |
+
"tokens_remaining": tokens_remaining(access),
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
|
| 256 |
+
@router.get("/{author_slug}/token-usage")
|
| 257 |
+
async def get_token_usage(
|
| 258 |
+
author_slug: str,
|
| 259 |
+
current_user=Depends(get_current_author_scoped),
|
| 260 |
+
db: AsyncSession = Depends(get_db),
|
| 261 |
+
):
|
| 262 |
+
"""Return token budget and consumption for the active subscription."""
|
| 263 |
+
from app.repositories.access_repo import AccessRepository
|
| 264 |
+
from app.services.token_budget import usage_summary_or_empty
|
| 265 |
+
|
| 266 |
+
access_repo = AccessRepository(db)
|
| 267 |
+
access = await access_repo.get_active_for_author(current_user.id)
|
| 268 |
+
return usage_summary_or_empty(access)
|
app/services/pipeline/__init__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""services/pipeline/__init__.py — Public API for the RAG pipeline package.
|
| 2 |
+
|
| 3 |
+
Callers import from here. The internal module structure is an implementation detail.
|
| 4 |
+
|
| 5 |
+
Usage:
|
| 6 |
+
from app.services.pipeline import run_pipeline, PipelineResult, invalidate_book_cache
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from app.services.pipeline.core import run_pipeline
|
| 10 |
+
from app.services.pipeline.handlers import PipelineResult
|
| 11 |
+
from app.services.pipeline.cache import invalidate_book_cache
|
| 12 |
+
|
| 13 |
+
__all__ = [
|
| 14 |
+
"run_pipeline",
|
| 15 |
+
"PipelineResult",
|
| 16 |
+
"invalidate_book_cache",
|
| 17 |
+
]
|
app/services/pipeline/cache.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""pipeline/cache.py — LRU answer cache.
|
| 2 |
+
|
| 3 |
+
Keyed on MD5(author_id + book_id + normalized query).
|
| 4 |
+
Max 256 slots. Evicts LRU on overflow.
|
| 5 |
+
NOT cached: purchase_intent, complaint, greeting (personal / time-sensitive).
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import hashlib
|
| 10 |
+
from collections import OrderedDict
|
| 11 |
+
from typing import TYPE_CHECKING
|
| 12 |
+
|
| 13 |
+
if TYPE_CHECKING:
|
| 14 |
+
from app.services.pipeline.core import PipelineResult
|
| 15 |
+
|
| 16 |
+
_CACHE_MAX = 256
|
| 17 |
+
_answer_cache: OrderedDict = OrderedDict()
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def cache_key(author_id: str, book_id: str | None, query: str) -> str:
|
| 21 |
+
"""Generate a stable MD5 cache key."""
|
| 22 |
+
raw = f"{author_id}:{book_id or ''}:{query.lower().strip()}"
|
| 23 |
+
return hashlib.md5(raw.encode()).hexdigest()
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def cache_get(key: str) -> PipelineResult | None:
|
| 27 |
+
"""Return cached result or None. Promotes the key to most-recently-used."""
|
| 28 |
+
if key in _answer_cache:
|
| 29 |
+
_answer_cache.move_to_end(key)
|
| 30 |
+
return _answer_cache[key]
|
| 31 |
+
return None
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def cache_set(key: str, result: PipelineResult) -> None:
|
| 35 |
+
"""Store a result. Evicts least-recently-used entry when over capacity."""
|
| 36 |
+
_answer_cache[key] = result
|
| 37 |
+
_answer_cache.move_to_end(key)
|
| 38 |
+
if len(_answer_cache) > _CACHE_MAX:
|
| 39 |
+
_answer_cache.popitem(last=False)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def invalidate_book_cache(author_id: str, book_id: str) -> int: # noqa: ARG001
|
| 43 |
+
"""Remove ALL cached answers for this author when a book changes.
|
| 44 |
+
|
| 45 |
+
Called by the ingest pipeline on re-upload to prevent stale answers.
|
| 46 |
+
|
| 47 |
+
Returns:
|
| 48 |
+
Number of cache entries removed.
|
| 49 |
+
"""
|
| 50 |
+
count = len(_answer_cache)
|
| 51 |
+
_answer_cache.clear()
|
| 52 |
+
return count
|
app/services/pipeline/core.py
ADDED
|
@@ -0,0 +1,364 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""pipeline/core.py — The 12-step RAG pipeline orchestrator.
|
| 2 |
+
|
| 3 |
+
This is the single entry point for ALL chatbot response generation.
|
| 4 |
+
Every chat message flows through all 12 steps in sequence.
|
| 5 |
+
|
| 6 |
+
RULE: No step may be skipped.
|
| 7 |
+
RULE: Every step failure must be handled gracefully — never crash the user's session.
|
| 8 |
+
RULE: Token usage is tracked and returned for budget accounting.
|
| 9 |
+
|
| 10 |
+
Pipeline Steps:
|
| 11 |
+
1. Boundary check (query)
|
| 12 |
+
2. Intent classification
|
| 13 |
+
3. Book resolution (select or show selector)
|
| 14 |
+
4. Query rewriting
|
| 15 |
+
5. Vector retrieval (ChromaDB)
|
| 16 |
+
6. Cross-encoder re-ranking
|
| 17 |
+
6.5 Chunk deduplication
|
| 18 |
+
7. Context assembly (token-aware)
|
| 19 |
+
8. LLM generation
|
| 20 |
+
9. Faithfulness check (NLI guardrail)
|
| 21 |
+
10. Response scope check (leak prevention)
|
| 22 |
+
11. Upsell strategy injection
|
| 23 |
+
12. Response formatting + link injection
|
| 24 |
+
"""
|
| 25 |
+
|
| 26 |
+
import time
|
| 27 |
+
|
| 28 |
+
import structlog
|
| 29 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 30 |
+
|
| 31 |
+
from app.config import get_settings
|
| 32 |
+
from app.models.user import User
|
| 33 |
+
from app.repositories.book_repo import BookRepository
|
| 34 |
+
from app.services.context_builder import build_context
|
| 35 |
+
from app.services.faithfulness import check_faithfulness
|
| 36 |
+
from app.services.formatter import ResponseFormatter
|
| 37 |
+
from app.services.guardrails import (
|
| 38 |
+
check_boundary,
|
| 39 |
+
is_response_safe,
|
| 40 |
+
sanitize_user_input,
|
| 41 |
+
scrub_unsafe_response,
|
| 42 |
+
)
|
| 43 |
+
from app.services.intent import classify_intent
|
| 44 |
+
from app.services.prompter import (
|
| 45 |
+
HALLUCINATION_FALLBACK_RESPONSE,
|
| 46 |
+
JAILBREAK_RESPONSE,
|
| 47 |
+
NO_CONTEXT_RESPONSE,
|
| 48 |
+
OFF_TOPIC_RESPONSE,
|
| 49 |
+
get_response_style_instruction,
|
| 50 |
+
MASTER_SYSTEM_PROMPT,
|
| 51 |
+
)
|
| 52 |
+
from app.services.reranker import rerank_chunks
|
| 53 |
+
from app.services.rewriter import rewrite_query
|
| 54 |
+
from app.services.session_core.manager import SessionContext
|
| 55 |
+
from app.services.upsell_engine import UpsellEngine
|
| 56 |
+
from app.services.vector_store import retrieve_chunks
|
| 57 |
+
|
| 58 |
+
from app.services.pipeline.cache import cache_get, cache_key, cache_set
|
| 59 |
+
from app.services.pipeline.dedup import deduplicate_chunks
|
| 60 |
+
from app.services.pipeline.guards import (
|
| 61 |
+
is_book_selection_turn,
|
| 62 |
+
is_catalog_question,
|
| 63 |
+
is_full_story_request,
|
| 64 |
+
is_greeting,
|
| 65 |
+
)
|
| 66 |
+
from app.services.pipeline.handlers import (
|
| 67 |
+
PipelineResult,
|
| 68 |
+
book_selected_response,
|
| 69 |
+
book_selector_response,
|
| 70 |
+
books_list_response,
|
| 71 |
+
boundary_response,
|
| 72 |
+
catalog_response,
|
| 73 |
+
full_story_response,
|
| 74 |
+
greeting_response,
|
| 75 |
+
no_books_response,
|
| 76 |
+
no_context_response,
|
| 77 |
+
piracy_response,
|
| 78 |
+
)
|
| 79 |
+
from app.services.pipeline.helpers import (
|
| 80 |
+
call_llm,
|
| 81 |
+
check_custom_qa,
|
| 82 |
+
find_book,
|
| 83 |
+
format_history,
|
| 84 |
+
get_book_links,
|
| 85 |
+
resolve_book,
|
| 86 |
+
selected_book_title,
|
| 87 |
+
)
|
| 88 |
+
|
| 89 |
+
logger = structlog.get_logger(__name__)
|
| 90 |
+
cfg = get_settings()
|
| 91 |
+
|
| 92 |
+
_upsell_engine = UpsellEngine()
|
| 93 |
+
_formatter = ResponseFormatter()
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
async def run_pipeline(
|
| 97 |
+
query: str,
|
| 98 |
+
author: User,
|
| 99 |
+
session_context: SessionContext,
|
| 100 |
+
db: AsyncSession,
|
| 101 |
+
) -> PipelineResult:
|
| 102 |
+
"""Execute the full 12-step RAG pipeline for one chat turn.
|
| 103 |
+
|
| 104 |
+
Args:
|
| 105 |
+
query: The user's raw message text.
|
| 106 |
+
author: The author whose catalog is being queried.
|
| 107 |
+
session_context: Current session state (history, selected book, interest).
|
| 108 |
+
db: Active database session.
|
| 109 |
+
|
| 110 |
+
Returns:
|
| 111 |
+
PipelineResult with formatted response and all metadata for logging.
|
| 112 |
+
"""
|
| 113 |
+
start_ms = time.monotonic()
|
| 114 |
+
log = logger.bind(author_id=author.id, turn=session_context.turn_count)
|
| 115 |
+
|
| 116 |
+
# ── Step 0: Sanitize input ────────────────────────────────────────────────
|
| 117 |
+
query = sanitize_user_input(query)
|
| 118 |
+
if not query:
|
| 119 |
+
return boundary_response(
|
| 120 |
+
"I didn't catch that — try asking about one of the books!",
|
| 121 |
+
start_ms,
|
| 122 |
+
"empty_input",
|
| 123 |
+
)
|
| 124 |
+
|
| 125 |
+
# ── Step 1: Boundary Check ────────────────────────────────────────────────
|
| 126 |
+
violation_type, _ = check_boundary(query)
|
| 127 |
+
if violation_type == "jailbreak_attempt":
|
| 128 |
+
return boundary_response(
|
| 129 |
+
JAILBREAK_RESPONSE.format(
|
| 130 |
+
bot_name=author.bot_name,
|
| 131 |
+
author_name=author.full_name or "the author",
|
| 132 |
+
),
|
| 133 |
+
start_ms,
|
| 134 |
+
"jailbreak_attempt",
|
| 135 |
+
)
|
| 136 |
+
if violation_type == "piracy_request":
|
| 137 |
+
return await piracy_response(author, session_context, db, start_ms)
|
| 138 |
+
if violation_type == "off_topic":
|
| 139 |
+
return boundary_response(
|
| 140 |
+
OFF_TOPIC_RESPONSE.format(author_name=author.full_name or "the author"),
|
| 141 |
+
start_ms,
|
| 142 |
+
"off_topic",
|
| 143 |
+
)
|
| 144 |
+
|
| 145 |
+
# ── Step 1.5: Custom Q&A short-circuit ───────────────────────────────────
|
| 146 |
+
qa_match = await check_custom_qa(query, author.id, db)
|
| 147 |
+
if qa_match:
|
| 148 |
+
elapsed_ms = int((time.monotonic() - start_ms) * 1000)
|
| 149 |
+
return PipelineResult(
|
| 150 |
+
response={"text": qa_match["answer"], "links": [], "has_links": False},
|
| 151 |
+
intent="custom_qa",
|
| 152 |
+
intent_confidence=qa_match["score"],
|
| 153 |
+
response_ms=elapsed_ms,
|
| 154 |
+
hallucination_detected=False,
|
| 155 |
+
)
|
| 156 |
+
|
| 157 |
+
# ── Step 2: Intent Classification ─────────────────────────────────────────
|
| 158 |
+
intent_result = await classify_intent(query, session_context.history)
|
| 159 |
+
log.debug("Intent classified", intent=intent_result.intent, source=intent_result.source)
|
| 160 |
+
|
| 161 |
+
if intent_result.intent == "jailbreak_attempt":
|
| 162 |
+
return boundary_response(
|
| 163 |
+
JAILBREAK_RESPONSE.format(
|
| 164 |
+
bot_name=author.bot_name,
|
| 165 |
+
author_name=author.full_name or "the author",
|
| 166 |
+
),
|
| 167 |
+
start_ms,
|
| 168 |
+
"jailbreak_attempt",
|
| 169 |
+
)
|
| 170 |
+
|
| 171 |
+
# ── Step 3: Book Resolution ───────────────────────────────────────────────
|
| 172 |
+
book_repo = BookRepository(db)
|
| 173 |
+
active_books = await book_repo.list_active_for_author(author.id)
|
| 174 |
+
|
| 175 |
+
if not active_books:
|
| 176 |
+
return no_books_response(start_ms)
|
| 177 |
+
|
| 178 |
+
# Short-circuit: greetings and catalog questions
|
| 179 |
+
if intent_result.intent == "greeting" or is_greeting(query):
|
| 180 |
+
return greeting_response(author, active_books, session_context, start_ms)
|
| 181 |
+
|
| 182 |
+
if intent_result.intent in ("meta", "comparison") or is_catalog_question(query):
|
| 183 |
+
return await catalog_response(author, active_books, session_context, db, start_ms)
|
| 184 |
+
|
| 185 |
+
# Book just selected — warm intro
|
| 186 |
+
if is_book_selection_turn(query, session_context.selected_book_id, active_books):
|
| 187 |
+
book = find_book(active_books, session_context.selected_book_id)
|
| 188 |
+
if book:
|
| 189 |
+
return await book_selected_response(book, author.id, db, start_ms)
|
| 190 |
+
|
| 191 |
+
# Full story / spoiler request
|
| 192 |
+
if intent_result.intent == "full_story_request" or is_full_story_request(query):
|
| 193 |
+
book = find_book(active_books, session_context.selected_book_id) or active_books[0]
|
| 194 |
+
return await full_story_response(book, author.id, db, start_ms)
|
| 195 |
+
|
| 196 |
+
# Multiple books without selection
|
| 197 |
+
if len(active_books) > 1 and not session_context.selected_book_id:
|
| 198 |
+
return books_list_response(
|
| 199 |
+
"Select a book below to ask about it.",
|
| 200 |
+
active_books,
|
| 201 |
+
start_ms,
|
| 202 |
+
intent="comparison",
|
| 203 |
+
)
|
| 204 |
+
|
| 205 |
+
# Resolve target book for retrieval
|
| 206 |
+
target_book_id = await resolve_book(intent_result, session_context, active_books, author.id)
|
| 207 |
+
|
| 208 |
+
if (
|
| 209 |
+
target_book_id is None
|
| 210 |
+
and len(active_books) > 1
|
| 211 |
+
and intent_result.book_confidence < cfg.RAG_BOOK_CONFIDENCE_THRESHOLD
|
| 212 |
+
and session_context.selected_book_id is None
|
| 213 |
+
):
|
| 214 |
+
return book_selector_response(active_books, start_ms)
|
| 215 |
+
|
| 216 |
+
search_book_id = target_book_id or session_context.selected_book_id
|
| 217 |
+
|
| 218 |
+
# ── Cache Lookup (before any API calls) ───────────────────────────────────
|
| 219 |
+
if intent_result.intent not in ("purchase_intent", "complaint", "greeting"):
|
| 220 |
+
ck = cache_key(author.id, search_book_id, query)
|
| 221 |
+
cached = cache_get(ck)
|
| 222 |
+
if cached is not None:
|
| 223 |
+
log.debug("Cache hit", key=ck[:8])
|
| 224 |
+
return cached
|
| 225 |
+
|
| 226 |
+
# ── Step 4: Query Rewriting ───────────────────────────────────────────────
|
| 227 |
+
query_variations = await rewrite_query(query, session_context.history)
|
| 228 |
+
|
| 229 |
+
# ── Step 5: Vector Retrieval ──────────────────────────────────────────────
|
| 230 |
+
raw_chunks = await retrieve_chunks(
|
| 231 |
+
queries=query_variations,
|
| 232 |
+
author_id=author.id,
|
| 233 |
+
book_id=search_book_id,
|
| 234 |
+
top_k=cfg.RAG_RETRIEVAL_TOP_K,
|
| 235 |
+
)
|
| 236 |
+
|
| 237 |
+
if not raw_chunks:
|
| 238 |
+
log.warning("No chunks retrieved")
|
| 239 |
+
return await no_context_response(query, author, active_books, session_context, db, start_ms)
|
| 240 |
+
|
| 241 |
+
# ── Step 6: Re-ranking ────────────────────────────────────────────────────
|
| 242 |
+
top_chunks = await rerank_chunks(
|
| 243 |
+
query=query,
|
| 244 |
+
chunks=raw_chunks,
|
| 245 |
+
top_n=cfg.RAG_RERANK_TOP_N,
|
| 246 |
+
min_score=cfg.RAG_RERANK_MIN_SCORE,
|
| 247 |
+
)
|
| 248 |
+
|
| 249 |
+
if not top_chunks:
|
| 250 |
+
log.warning("Re-ranker filtered all chunks — using top retrieval results")
|
| 251 |
+
top_chunks = raw_chunks[: cfg.RAG_RERANK_TOP_N]
|
| 252 |
+
|
| 253 |
+
if not top_chunks:
|
| 254 |
+
return await no_context_response(query, author, active_books, session_context, db, start_ms)
|
| 255 |
+
|
| 256 |
+
# ── Step 6.5: Chunk Deduplication ────────────────────────────────────────
|
| 257 |
+
top_chunks = deduplicate_chunks(top_chunks)
|
| 258 |
+
|
| 259 |
+
# ── Step 7: Context Assembly ──────────────────────────────────────────────
|
| 260 |
+
context_str, context_tokens = build_context(top_chunks)
|
| 261 |
+
|
| 262 |
+
# ── Step 8: LLM Generation ───────────────────────────────────────────────
|
| 263 |
+
history_str = format_history(session_context.history)
|
| 264 |
+
interest_tags_str = ", ".join(session_context.interest_tags[:10]) or "None detected yet"
|
| 265 |
+
book_title = selected_book_title(active_books, session_context.selected_book_id)
|
| 266 |
+
|
| 267 |
+
style = author.response_style or "balanced"
|
| 268 |
+
system_prompt = MASTER_SYSTEM_PROMPT.format(
|
| 269 |
+
bot_name=author.bot_name,
|
| 270 |
+
author_name=author.full_name or "the author",
|
| 271 |
+
book_title=book_title,
|
| 272 |
+
interest_score=f"{session_context.interest_score:.1f}",
|
| 273 |
+
interest_tags=interest_tags_str,
|
| 274 |
+
context=context_str,
|
| 275 |
+
history=history_str,
|
| 276 |
+
response_style=style,
|
| 277 |
+
tone_instruction=get_response_style_instruction(style),
|
| 278 |
+
)
|
| 279 |
+
|
| 280 |
+
user_content = query
|
| 281 |
+
if is_full_story_request(query):
|
| 282 |
+
user_content = (
|
| 283 |
+
f"{query}\n\n"
|
| 284 |
+
"[Reminder: Max 2 sentences. Do NOT summarize the plot. Tease only.]"
|
| 285 |
+
)
|
| 286 |
+
|
| 287 |
+
messages = [
|
| 288 |
+
{"role": "system", "content": system_prompt},
|
| 289 |
+
{"role": "user", "content": user_content},
|
| 290 |
+
]
|
| 291 |
+
|
| 292 |
+
raw_response, prompt_tokens, completion_tokens = await call_llm(messages)
|
| 293 |
+
|
| 294 |
+
# ── Step 9: Faithfulness Check ────────────────────────────────────────────
|
| 295 |
+
is_faithful, faithfulness_score = await check_faithfulness(raw_response, top_chunks)
|
| 296 |
+
hallucination_detected = not is_faithful
|
| 297 |
+
|
| 298 |
+
if hallucination_detected:
|
| 299 |
+
log.warning("Hallucination detected", score=faithfulness_score)
|
| 300 |
+
stricter_messages = messages + [
|
| 301 |
+
{"role": "assistant", "content": raw_response},
|
| 302 |
+
{"role": "user", "content": "Reply using ONLY the retrieved context. Max 2 sentences."},
|
| 303 |
+
]
|
| 304 |
+
raw_response, p2, c2 = await call_llm(stricter_messages, temperature=0.3)
|
| 305 |
+
prompt_tokens += p2
|
| 306 |
+
completion_tokens += c2
|
| 307 |
+
|
| 308 |
+
is_faithful2, faithfulness_score = await check_faithfulness(raw_response, top_chunks)
|
| 309 |
+
if not is_faithful2:
|
| 310 |
+
raw_response = HALLUCINATION_FALLBACK_RESPONSE.format(book_title=book_title)
|
| 311 |
+
|
| 312 |
+
# ── Step 10: Output Safety Check ──────────────────────────────────────────
|
| 313 |
+
safe_fallback = NO_CONTEXT_RESPONSE.format(book_title=book_title)
|
| 314 |
+
raw_response = scrub_unsafe_response(raw_response, safe_fallback)
|
| 315 |
+
|
| 316 |
+
if not is_response_safe(raw_response)[0]:
|
| 317 |
+
raw_response = JAILBREAK_RESPONSE.format(
|
| 318 |
+
bot_name=author.bot_name,
|
| 319 |
+
author_name=author.full_name or "the author",
|
| 320 |
+
)
|
| 321 |
+
|
| 322 |
+
# ── Step 11: Upsell Strategy ──────────────────────────────────────────────
|
| 323 |
+
effective_intent = "full_story_request" if is_full_story_request(query) else intent_result.intent
|
| 324 |
+
strategy = _upsell_engine.select_strategy(effective_intent, session_context)
|
| 325 |
+
show_link = _upsell_engine.should_include_link(effective_intent, session_context, strategy)
|
| 326 |
+
|
| 327 |
+
top_book_id = search_book_id or (top_chunks[0].book_id if top_chunks else None)
|
| 328 |
+
purchase_url, preview_url = await get_book_links(top_book_id, author.id, db)
|
| 329 |
+
|
| 330 |
+
# ── Step 12: Format Response ──────────────────────────────────────────────
|
| 331 |
+
# Hook text removed — the buy button IS the CTA. Text ends naturally.
|
| 332 |
+
formatted = _formatter.format(
|
| 333 |
+
response_text=raw_response,
|
| 334 |
+
upsell_hook=None,
|
| 335 |
+
purchase_url=purchase_url,
|
| 336 |
+
preview_url=preview_url,
|
| 337 |
+
show_link=show_link and bool(purchase_url),
|
| 338 |
+
)
|
| 339 |
+
|
| 340 |
+
elapsed_ms = int((time.monotonic() - start_ms) * 1000)
|
| 341 |
+
log.info("Pipeline complete", ms=elapsed_ms, faithfulness=faithfulness_score)
|
| 342 |
+
|
| 343 |
+
result = PipelineResult(
|
| 344 |
+
response=formatted,
|
| 345 |
+
intent=intent_result.intent,
|
| 346 |
+
intent_confidence=intent_result.confidence,
|
| 347 |
+
faithfulness_score=faithfulness_score,
|
| 348 |
+
hallucination_detected=hallucination_detected,
|
| 349 |
+
boundary_triggered=False,
|
| 350 |
+
upsell_strategy=strategy,
|
| 351 |
+
link_shown=formatted["has_links"],
|
| 352 |
+
prompt_tokens=prompt_tokens,
|
| 353 |
+
completion_tokens=completion_tokens,
|
| 354 |
+
response_ms=elapsed_ms,
|
| 355 |
+
top_book_ids=list({c.book_id for c in top_chunks}),
|
| 356 |
+
)
|
| 357 |
+
|
| 358 |
+
# Cache for future identical questions
|
| 359 |
+
if intent_result.intent not in ("purchase_intent", "complaint", "greeting"):
|
| 360 |
+
ck = cache_key(author.id, search_book_id, query)
|
| 361 |
+
cache_set(ck, result)
|
| 362 |
+
log.debug("Answer cached", key=ck[:8])
|
| 363 |
+
|
| 364 |
+
return result
|
app/services/pipeline/dedup.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""pipeline/dedup.py — Chunk deduplication by word-overlap ratio.
|
| 2 |
+
|
| 3 |
+
Removes near-duplicate chunks that arise from overlapping sliding-window
|
| 4 |
+
chunking. Prevents the LLM from seeing the same content repeated, which
|
| 5 |
+
wastes context tokens and can cause repetitive answers.
|
| 6 |
+
|
| 7 |
+
Source: adapted from RAG 1.2 pattern, book-domain tuned.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def deduplicate_chunks(chunks: list, similarity_threshold: float = 0.82) -> list:
|
| 12 |
+
"""Remove near-duplicate chunks based on word overlap ratio.
|
| 13 |
+
|
| 14 |
+
Uses a min-overlap Jaccard variant: overlap / min(len_a, len_b).
|
| 15 |
+
This catches cases where one chunk is a subset of another.
|
| 16 |
+
|
| 17 |
+
Args:
|
| 18 |
+
chunks: List of chunk objects with a .text attribute or dict 'text' key.
|
| 19 |
+
similarity_threshold: Overlap ratio at or above which a chunk is a duplicate.
|
| 20 |
+
0.82 removes near-identical windows while preserving
|
| 21 |
+
legitimately similar passages about the same topic.
|
| 22 |
+
|
| 23 |
+
Returns:
|
| 24 |
+
Deduplicated list, preserving original order.
|
| 25 |
+
"""
|
| 26 |
+
if len(chunks) <= 1:
|
| 27 |
+
return chunks
|
| 28 |
+
|
| 29 |
+
def get_words(chunk) -> set[str]:
|
| 30 |
+
text = chunk.text if hasattr(chunk, "text") else chunk.get("text", "")
|
| 31 |
+
return set(text.lower().split())
|
| 32 |
+
|
| 33 |
+
unique = [chunks[0]]
|
| 34 |
+
for candidate in chunks[1:]:
|
| 35 |
+
cw = get_words(candidate)
|
| 36 |
+
if not cw:
|
| 37 |
+
unique.append(candidate)
|
| 38 |
+
continue
|
| 39 |
+
is_dup = any(
|
| 40 |
+
len(cw & get_words(existing)) / min(len(cw), max(len(get_words(existing)), 1))
|
| 41 |
+
>= similarity_threshold
|
| 42 |
+
for existing in unique
|
| 43 |
+
)
|
| 44 |
+
if not is_dup:
|
| 45 |
+
unique.append(candidate)
|
| 46 |
+
|
| 47 |
+
return unique
|
app/services/pipeline/guards.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""pipeline/guards.py — Lightweight query guard detectors.
|
| 2 |
+
|
| 3 |
+
Pure Python, zero API cost. Used by both the pipeline core and the
|
| 4 |
+
intent classifier. Single source of truth — never duplicated elsewhere.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
# ── Full story / Spoiler ──────────────────────────────────────────────────────
|
| 9 |
+
|
| 10 |
+
_FULL_STORY_PHRASES: tuple[str, ...] = (
|
| 11 |
+
"complete story", "full story", "whole story", "entire story", "entire book",
|
| 12 |
+
"whole book", "full plot", "whole plot", "summarize the book", "summary of the book",
|
| 13 |
+
"tell me everything", "what happens in the book", "what happens in the story",
|
| 14 |
+
"end of the book", "how does it end", "how does the book end", "full summary",
|
| 15 |
+
"complete summary", "recap the book", "recap the story",
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def is_full_story_request(query: str) -> bool:
|
| 20 |
+
"""Return True if the query asks for the complete plot / ending / full summary."""
|
| 21 |
+
q = query.lower()
|
| 22 |
+
return any(phrase in q for phrase in _FULL_STORY_PHRASES)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
# ── Catalog / Book list ───────────────────────────────────────────────────────
|
| 26 |
+
|
| 27 |
+
_CATALOG_PHRASES: tuple[str, ...] = (
|
| 28 |
+
"uploaded book", "your books", "what books", "which books", "list book",
|
| 29 |
+
"books do you", "books you have", "books available", "about the books",
|
| 30 |
+
"tell me about book", "tell me about your book", "tell me about the books",
|
| 31 |
+
"what is the book", "what's the book", "in the catalog", "in your catalog",
|
| 32 |
+
"show me the book", "show me your book",
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
_CATALOG_EXACT: frozenset[str] = frozenset({
|
| 36 |
+
"books", "tell me about books", "about books", "the books",
|
| 37 |
+
})
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def is_catalog_question(query: str) -> bool:
|
| 41 |
+
"""Return True if the query is asking about the author's book catalog."""
|
| 42 |
+
q = query.lower()
|
| 43 |
+
if any(phrase in q for phrase in _CATALOG_PHRASES):
|
| 44 |
+
return True
|
| 45 |
+
return q in _CATALOG_EXACT
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
# ── Book selection turn ───────────────────────────────────────────────────────
|
| 49 |
+
|
| 50 |
+
def is_book_selection_turn(
|
| 51 |
+
query: str,
|
| 52 |
+
selected_book_id: str | None,
|
| 53 |
+
books: list,
|
| 54 |
+
) -> bool:
|
| 55 |
+
"""Return True if this turn is the reader selecting a book by name.
|
| 56 |
+
|
| 57 |
+
Used to trigger the warm book-intro response instead of Q&A retrieval.
|
| 58 |
+
"""
|
| 59 |
+
if not selected_book_id:
|
| 60 |
+
return False
|
| 61 |
+
book = next((b for b in books if b.id == selected_book_id), None)
|
| 62 |
+
if not book:
|
| 63 |
+
return False
|
| 64 |
+
q = query.lower().strip().strip("\"'")
|
| 65 |
+
title_lower = book.title.lower()
|
| 66 |
+
if q == title_lower:
|
| 67 |
+
return True
|
| 68 |
+
if q.startswith("tell me about") and title_lower in q:
|
| 69 |
+
return True
|
| 70 |
+
return q.startswith("i'm interested in") and title_lower in q
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
# ── Greeting ─────────────────────────────────────────────────────────────────
|
| 74 |
+
# NOTE: Intentionally kept here as the SINGLE source of truth.
|
| 75 |
+
# Import this function anywhere greeting detection is needed.
|
| 76 |
+
# Do NOT redefine _is_greeting in other modules.
|
| 77 |
+
|
| 78 |
+
_GREETINGS: frozenset[str] = frozenset({
|
| 79 |
+
"hi", "hello", "hey", "hiya", "howdy", "yo", "sup",
|
| 80 |
+
"good morning", "good afternoon", "good evening", "good day",
|
| 81 |
+
})
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def is_greeting(query: str) -> bool:
|
| 85 |
+
"""Return True if the query is a simple greeting with no book content."""
|
| 86 |
+
q = query.strip().lower().rstrip("!.?")
|
| 87 |
+
return q in _GREETINGS or (len(q) <= 12 and q.startswith(("hi ", "hey ", "hello ")))
|
app/services/pipeline/handlers.py
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""pipeline/handlers.py — Short-circuit response handlers.
|
| 2 |
+
|
| 3 |
+
Each function handles a specific case that bypasses full RAG retrieval.
|
| 4 |
+
All functions return a PipelineResult and are responsible for formatting
|
| 5 |
+
their own response using the formatter and upsell engine.
|
| 6 |
+
|
| 7 |
+
RULE: Handlers never call the LLM. They are fast, deterministic exits.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
import time
|
| 11 |
+
from dataclasses import dataclass, field
|
| 12 |
+
|
| 13 |
+
import structlog
|
| 14 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 15 |
+
|
| 16 |
+
from app.models.user import User
|
| 17 |
+
from app.repositories.book_repo import BookRepository
|
| 18 |
+
from app.services.formatter import ResponseFormatter
|
| 19 |
+
from app.services.prompter import (
|
| 20 |
+
BOOK_SELECTED_RESPONSE,
|
| 21 |
+
CATALOG_RESPONSE,
|
| 22 |
+
FULL_STORY_RESPONSE,
|
| 23 |
+
GREETING_RESPONSE,
|
| 24 |
+
NO_CONTEXT_RESPONSE,
|
| 25 |
+
PIRACY_RESPONSE,
|
| 26 |
+
)
|
| 27 |
+
from app.services.session_core.manager import SessionContext
|
| 28 |
+
from app.services.upsell_engine import UpsellEngine
|
| 29 |
+
from app.services.pipeline.guards import is_catalog_question
|
| 30 |
+
from app.services.pipeline.helpers import (
|
| 31 |
+
book_hook,
|
| 32 |
+
book_selector_items,
|
| 33 |
+
book_tease,
|
| 34 |
+
find_book,
|
| 35 |
+
get_book_links,
|
| 36 |
+
selected_book_title,
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
logger = structlog.get_logger(__name__)
|
| 40 |
+
|
| 41 |
+
_formatter = ResponseFormatter()
|
| 42 |
+
_upsell_engine = UpsellEngine()
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
# ── PipelineResult (defined here, re-exported via __init__) ──────────────────
|
| 46 |
+
|
| 47 |
+
@dataclass
|
| 48 |
+
class PipelineResult:
|
| 49 |
+
"""Full result from one RAG pipeline execution."""
|
| 50 |
+
response: dict
|
| 51 |
+
intent: str = "question"
|
| 52 |
+
intent_confidence: float = 0.7
|
| 53 |
+
faithfulness_score: float = 1.0
|
| 54 |
+
hallucination_detected: bool = False
|
| 55 |
+
boundary_triggered: bool = False
|
| 56 |
+
upsell_strategy: str | None = None
|
| 57 |
+
link_shown: bool = False
|
| 58 |
+
prompt_tokens: int = 0
|
| 59 |
+
completion_tokens: int = 0
|
| 60 |
+
response_ms: int = 0
|
| 61 |
+
top_book_ids: list[str] = field(default_factory=list)
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
# ── Shared Formatter Utilities ────────────────────────────────────────────────
|
| 65 |
+
|
| 66 |
+
async def build_purchase_response(
|
| 67 |
+
text: str,
|
| 68 |
+
book_id: str | None,
|
| 69 |
+
author_id: str,
|
| 70 |
+
db: AsyncSession,
|
| 71 |
+
*,
|
| 72 |
+
force_link: bool = False,
|
| 73 |
+
upsell_hook: str | None = None,
|
| 74 |
+
) -> dict:
|
| 75 |
+
"""Format a response dict that may include a purchase button."""
|
| 76 |
+
purchase_url, preview_url = await get_book_links(book_id, author_id, db)
|
| 77 |
+
show = force_link or bool(purchase_url)
|
| 78 |
+
return _formatter.format(
|
| 79 |
+
response_text=text,
|
| 80 |
+
upsell_hook=upsell_hook,
|
| 81 |
+
purchase_url=purchase_url,
|
| 82 |
+
preview_url=preview_url,
|
| 83 |
+
show_link=show and bool(purchase_url),
|
| 84 |
+
)
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def books_list_response(
|
| 88 |
+
text: str,
|
| 89 |
+
books: list,
|
| 90 |
+
start_ms: float,
|
| 91 |
+
intent: str = "greeting",
|
| 92 |
+
) -> PipelineResult:
|
| 93 |
+
"""Return a PipelineResult that shows the book selector widget."""
|
| 94 |
+
elapsed_ms = int((time.monotonic() - start_ms) * 1000)
|
| 95 |
+
formatted = _formatter.format_book_selector(book_selector_items(books), intro=text)
|
| 96 |
+
return PipelineResult(
|
| 97 |
+
response=formatted,
|
| 98 |
+
intent=intent,
|
| 99 |
+
response_ms=elapsed_ms,
|
| 100 |
+
)
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def boundary_response(
|
| 104 |
+
text: str,
|
| 105 |
+
start_ms: float,
|
| 106 |
+
violation_type: str,
|
| 107 |
+
) -> PipelineResult:
|
| 108 |
+
"""Return a simple boundary violation response (no links, no retrieval)."""
|
| 109 |
+
elapsed_ms = int((time.monotonic() - start_ms) * 1000)
|
| 110 |
+
return PipelineResult(
|
| 111 |
+
response={"text": text, "links": [], "has_links": False},
|
| 112 |
+
boundary_triggered=True,
|
| 113 |
+
intent=violation_type,
|
| 114 |
+
response_ms=elapsed_ms,
|
| 115 |
+
)
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
# ── Specific Handler Functions ────────────────────────────────────────────────
|
| 119 |
+
|
| 120 |
+
def no_books_response(start_ms: float) -> PipelineResult:
|
| 121 |
+
"""No books in the catalog yet."""
|
| 122 |
+
return PipelineResult(
|
| 123 |
+
response={
|
| 124 |
+
"text": "The book catalog is being set up. Check back soon!",
|
| 125 |
+
"links": [],
|
| 126 |
+
"has_links": False,
|
| 127 |
+
},
|
| 128 |
+
response_ms=int((time.monotonic() - start_ms) * 1000),
|
| 129 |
+
)
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def book_selector_response(books: list, start_ms: float) -> PipelineResult:
|
| 133 |
+
"""Show the book selector when the user's intent is ambiguous."""
|
| 134 |
+
return books_list_response(
|
| 135 |
+
"Which book are you curious about?",
|
| 136 |
+
books,
|
| 137 |
+
start_ms,
|
| 138 |
+
intent="comparison",
|
| 139 |
+
)
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def greeting_response(
|
| 143 |
+
author: User,
|
| 144 |
+
books: list,
|
| 145 |
+
session_context: SessionContext,
|
| 146 |
+
start_ms: float,
|
| 147 |
+
) -> PipelineResult:
|
| 148 |
+
"""Warm greeting — shows book selector or re-engages with selected book."""
|
| 149 |
+
if session_context.selected_book_id:
|
| 150 |
+
book = find_book(books, session_context.selected_book_id)
|
| 151 |
+
title = book.title if book else "your book"
|
| 152 |
+
text = f"Hello again! Still exploring {title}? Ask me anything — I'm here for it."
|
| 153 |
+
return PipelineResult(
|
| 154 |
+
response={"text": text, "links": [], "has_links": False},
|
| 155 |
+
intent="greeting",
|
| 156 |
+
response_ms=int((time.monotonic() - start_ms) * 1000),
|
| 157 |
+
)
|
| 158 |
+
return books_list_response(GREETING_RESPONSE, books, start_ms, intent="greeting")
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
async def piracy_response(
|
| 162 |
+
author: User,
|
| 163 |
+
session_context: SessionContext,
|
| 164 |
+
db: AsyncSession,
|
| 165 |
+
start_ms: float,
|
| 166 |
+
) -> PipelineResult:
|
| 167 |
+
"""Anti-piracy handler — shows buy button instead of free download."""
|
| 168 |
+
book_repo = BookRepository(db)
|
| 169 |
+
books = await book_repo.list_active_for_author(author.id)
|
| 170 |
+
book_title = selected_book_title(books, session_context.selected_book_id)
|
| 171 |
+
book = find_book(books, session_context.selected_book_id) or (books[0] if books else None)
|
| 172 |
+
text = PIRACY_RESPONSE.format(book_title=book_title)
|
| 173 |
+
elapsed_ms = int((time.monotonic() - start_ms) * 1000)
|
| 174 |
+
|
| 175 |
+
if book:
|
| 176 |
+
formatted = await build_purchase_response(text, book.id, author.id, db, force_link=True)
|
| 177 |
+
return PipelineResult(
|
| 178 |
+
response=formatted,
|
| 179 |
+
boundary_triggered=True,
|
| 180 |
+
intent="piracy_request",
|
| 181 |
+
response_ms=elapsed_ms,
|
| 182 |
+
link_shown=formatted["has_links"],
|
| 183 |
+
)
|
| 184 |
+
return boundary_response(text, start_ms, "piracy_request")
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
async def catalog_response(
|
| 188 |
+
author: User,
|
| 189 |
+
books: list,
|
| 190 |
+
session_context: SessionContext,
|
| 191 |
+
db: AsyncSession,
|
| 192 |
+
start_ms: float,
|
| 193 |
+
) -> PipelineResult:
|
| 194 |
+
"""Show book list, or re-engage with already-selected book."""
|
| 195 |
+
if session_context.selected_book_id:
|
| 196 |
+
book = find_book(books, session_context.selected_book_id)
|
| 197 |
+
if book:
|
| 198 |
+
return await book_selected_response(book, author.id, db, start_ms)
|
| 199 |
+
return books_list_response(CATALOG_RESPONSE, books, start_ms, intent="meta")
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
async def book_selected_response(
|
| 203 |
+
book,
|
| 204 |
+
author_id: str,
|
| 205 |
+
db: AsyncSession,
|
| 206 |
+
start_ms: float,
|
| 207 |
+
) -> PipelineResult:
|
| 208 |
+
"""Warm intro response when a reader first selects a book."""
|
| 209 |
+
elapsed_ms = int((time.monotonic() - start_ms) * 1000)
|
| 210 |
+
text = BOOK_SELECTED_RESPONSE.format(book_title=book.title, hook=book_hook(book))
|
| 211 |
+
formatted = await build_purchase_response(text, book.id, author_id, db)
|
| 212 |
+
return PipelineResult(
|
| 213 |
+
response=formatted,
|
| 214 |
+
intent="question",
|
| 215 |
+
response_ms=elapsed_ms,
|
| 216 |
+
link_shown=formatted["has_links"],
|
| 217 |
+
)
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
async def full_story_response(
|
| 221 |
+
book,
|
| 222 |
+
author_id: str,
|
| 223 |
+
db: AsyncSession,
|
| 224 |
+
start_ms: float,
|
| 225 |
+
) -> PipelineResult:
|
| 226 |
+
"""Anti-spoiler handler — teases one hook and shows the buy button."""
|
| 227 |
+
elapsed_ms = int((time.monotonic() - start_ms) * 1000)
|
| 228 |
+
text = FULL_STORY_RESPONSE.format(book_title=book.title, hook=book_tease(book))
|
| 229 |
+
formatted = await build_purchase_response(
|
| 230 |
+
text, book.id, author_id, db, force_link=True,
|
| 231 |
+
upsell_hook=_upsell_engine.build_hook("DIRECT_CTA"),
|
| 232 |
+
)
|
| 233 |
+
return PipelineResult(
|
| 234 |
+
response=formatted,
|
| 235 |
+
intent="full_story_request",
|
| 236 |
+
response_ms=elapsed_ms,
|
| 237 |
+
link_shown=formatted["has_links"],
|
| 238 |
+
)
|
| 239 |
+
|
| 240 |
+
|
| 241 |
+
async def no_context_response(
|
| 242 |
+
query: str,
|
| 243 |
+
author: User,
|
| 244 |
+
books: list,
|
| 245 |
+
session_context: SessionContext,
|
| 246 |
+
db: AsyncSession,
|
| 247 |
+
start_ms: float,
|
| 248 |
+
) -> PipelineResult:
|
| 249 |
+
"""Fallback when retrieval returns no usable chunks."""
|
| 250 |
+
elapsed_ms = int((time.monotonic() - start_ms) * 1000)
|
| 251 |
+
book_title = selected_book_title(books, session_context.selected_book_id)
|
| 252 |
+
|
| 253 |
+
if len(books) == 1:
|
| 254 |
+
book = books[0]
|
| 255 |
+
book_is_ready = (
|
| 256 |
+
book.status in ("ready", "active")
|
| 257 |
+
or (book.chunk_count and book.chunk_count > 0 and book.chroma_collection_id)
|
| 258 |
+
)
|
| 259 |
+
if not book_is_ready:
|
| 260 |
+
text = (
|
| 261 |
+
f"{book.title} is still being indexed — give it a minute and ask again. "
|
| 262 |
+
"I'll be able to answer detailed questions once processing finishes."
|
| 263 |
+
)
|
| 264 |
+
return PipelineResult(
|
| 265 |
+
response={"text": text, "links": [], "has_links": False},
|
| 266 |
+
intent="question",
|
| 267 |
+
response_ms=elapsed_ms,
|
| 268 |
+
)
|
| 269 |
+
|
| 270 |
+
if is_catalog_question(query) and not session_context.selected_book_id:
|
| 271 |
+
return await catalog_response(author, books, session_context, db, start_ms)
|
| 272 |
+
|
| 273 |
+
default_robotic = "I want to give you the most accurate answer"
|
| 274 |
+
fallback = (author.fallback_message or "").strip()
|
| 275 |
+
text = fallback if (fallback and default_robotic not in fallback) else NO_CONTEXT_RESPONSE.format(book_title=book_title)
|
| 276 |
+
|
| 277 |
+
return PipelineResult(
|
| 278 |
+
response={"text": text, "links": [], "has_links": False},
|
| 279 |
+
response_ms=elapsed_ms,
|
| 280 |
+
)
|
app/services/pipeline/helpers.py
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""pipeline/helpers.py — Pure utility functions for the RAG pipeline.
|
| 2 |
+
|
| 3 |
+
All functions here are stateless. They take explicit arguments and return
|
| 4 |
+
values with no side effects. This makes them independently testable.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import structlog
|
| 8 |
+
from openai import AsyncOpenAI
|
| 9 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 10 |
+
|
| 11 |
+
from app.config import get_settings
|
| 12 |
+
from app.models.user import User
|
| 13 |
+
from app.repositories.link_repo import LinkRepository
|
| 14 |
+
from app.services.guardrails import sanitize_history_line
|
| 15 |
+
from app.services.intent import IntentResult
|
| 16 |
+
from app.services.session_core.manager import SessionContext
|
| 17 |
+
|
| 18 |
+
logger = structlog.get_logger(__name__)
|
| 19 |
+
cfg = get_settings()
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
# ── LLM Call ─────────────────────────────────────────────────────────────────
|
| 23 |
+
|
| 24 |
+
async def call_llm(
|
| 25 |
+
messages: list[dict],
|
| 26 |
+
temperature: float | None = None,
|
| 27 |
+
) -> tuple[str, int, int]:
|
| 28 |
+
"""Call OpenAI chat completions and return response + token counts.
|
| 29 |
+
|
| 30 |
+
Args:
|
| 31 |
+
messages: List of message dicts for the API call.
|
| 32 |
+
temperature: Optional temperature override (defaults to config value).
|
| 33 |
+
|
| 34 |
+
Returns:
|
| 35 |
+
Tuple of (response_text, prompt_tokens, completion_tokens).
|
| 36 |
+
"""
|
| 37 |
+
client = AsyncOpenAI(api_key=cfg.OPENAI_API_KEY)
|
| 38 |
+
response = await client.chat.completions.create(
|
| 39 |
+
model=cfg.OPENAI_CHAT_MODEL,
|
| 40 |
+
messages=messages,
|
| 41 |
+
max_tokens=cfg.RAG_MAX_RESPONSE_TOKENS,
|
| 42 |
+
temperature=temperature if temperature is not None else cfg.RAG_TEMPERATURE,
|
| 43 |
+
)
|
| 44 |
+
content = response.choices[0].message.content or ""
|
| 45 |
+
usage = response.usage
|
| 46 |
+
return content, usage.prompt_tokens, usage.completion_tokens
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
# ── Book Resolution ───────────────────────────────────────────────────────────
|
| 50 |
+
|
| 51 |
+
async def resolve_book(
|
| 52 |
+
intent_result: IntentResult,
|
| 53 |
+
session_context: SessionContext,
|
| 54 |
+
active_books: list,
|
| 55 |
+
author_id: str,
|
| 56 |
+
) -> str | None:
|
| 57 |
+
"""Determine the target book UUID for vector retrieval.
|
| 58 |
+
|
| 59 |
+
Args:
|
| 60 |
+
intent_result: Classified intent with optional book_reference.
|
| 61 |
+
session_context: Current session state.
|
| 62 |
+
active_books: All active books for this author.
|
| 63 |
+
author_id: Author UUID (unused, reserved for future cross-author search).
|
| 64 |
+
|
| 65 |
+
Returns:
|
| 66 |
+
Book UUID to search, or None for cross-book search.
|
| 67 |
+
"""
|
| 68 |
+
# Query explicitly references a known book title
|
| 69 |
+
if (
|
| 70 |
+
intent_result.book_reference
|
| 71 |
+
and intent_result.book_confidence >= cfg.RAG_BOOK_CONFIDENCE_THRESHOLD
|
| 72 |
+
):
|
| 73 |
+
ref_lower = intent_result.book_reference.lower()
|
| 74 |
+
for book in active_books:
|
| 75 |
+
if ref_lower in book.title.lower() or book.title.lower() in ref_lower:
|
| 76 |
+
return book.id
|
| 77 |
+
|
| 78 |
+
# Single-book catalog: always use the only book
|
| 79 |
+
if len(active_books) == 1:
|
| 80 |
+
return active_books[0].id
|
| 81 |
+
|
| 82 |
+
return None
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
# ── History Formatting ────────────────────────────────────────────────────────
|
| 86 |
+
|
| 87 |
+
def format_history(history: list[dict]) -> str:
|
| 88 |
+
"""Format conversation history for the system prompt.
|
| 89 |
+
|
| 90 |
+
Args:
|
| 91 |
+
history: List of message dicts with 'role' and 'content' keys.
|
| 92 |
+
|
| 93 |
+
Returns:
|
| 94 |
+
Formatted string suitable for injection into the system prompt.
|
| 95 |
+
"""
|
| 96 |
+
if not history:
|
| 97 |
+
return "This is the start of the conversation."
|
| 98 |
+
lines = []
|
| 99 |
+
for msg in history[-6:]:
|
| 100 |
+
role = "Visitor" if msg["role"] == "user" else "You"
|
| 101 |
+
content = sanitize_history_line(msg.get("content", ""))
|
| 102 |
+
if content:
|
| 103 |
+
lines.append(f"{role}: {content}")
|
| 104 |
+
return "\n".join(lines)
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
# ── Link Fetching ─────────────────────────────────────────────────────────────
|
| 108 |
+
|
| 109 |
+
async def get_book_links(
|
| 110 |
+
book_id: str | None,
|
| 111 |
+
author_id: str,
|
| 112 |
+
db: AsyncSession,
|
| 113 |
+
) -> tuple[str | None, str | None]:
|
| 114 |
+
"""Fetch purchase and preview URLs for a book.
|
| 115 |
+
|
| 116 |
+
Tries the LinkRepository first (normalized), falls back to book.buy_url.
|
| 117 |
+
|
| 118 |
+
Args:
|
| 119 |
+
book_id: UUID of the book to look up.
|
| 120 |
+
author_id: UUID of the author (used for ownership validation).
|
| 121 |
+
db: Active async database session.
|
| 122 |
+
|
| 123 |
+
Returns:
|
| 124 |
+
Tuple of (purchase_url | None, preview_url | None).
|
| 125 |
+
"""
|
| 126 |
+
if not book_id:
|
| 127 |
+
return None, None
|
| 128 |
+
try:
|
| 129 |
+
link_repo = LinkRepository(db)
|
| 130 |
+
link = await link_repo.get_for_book(book_id, author_id)
|
| 131 |
+
if link and (link.purchase_url or link.preview_url):
|
| 132 |
+
return link.purchase_url, link.preview_url
|
| 133 |
+
|
| 134 |
+
from app.repositories.book_repo import BookRepository
|
| 135 |
+
book_repo = BookRepository(db)
|
| 136 |
+
book = await book_repo.get_by_id(book_id)
|
| 137 |
+
if book and book.author_id == author_id:
|
| 138 |
+
return book.buy_url, book.preview_url
|
| 139 |
+
except Exception:
|
| 140 |
+
pass
|
| 141 |
+
return None, None
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
# ── Book List Utilities ───────────────────────────────────────────────────────
|
| 145 |
+
|
| 146 |
+
def find_book(books: list, book_id: str | None):
|
| 147 |
+
"""Return the book object with the given ID, or None."""
|
| 148 |
+
if not book_id:
|
| 149 |
+
return None
|
| 150 |
+
return next((b for b in books if b.id == book_id), None)
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def selected_book_title(books: list, selected_book_id: str | None) -> str:
|
| 154 |
+
"""Return the title of the selected book, or a sensible fallback."""
|
| 155 |
+
book = find_book(books, selected_book_id)
|
| 156 |
+
if book:
|
| 157 |
+
return book.title
|
| 158 |
+
if len(books) == 1:
|
| 159 |
+
return books[0].title
|
| 160 |
+
return "the books"
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
def book_selector_items(books: list) -> list[dict]:
|
| 164 |
+
"""Build the list of book selector items for the widget UI."""
|
| 165 |
+
items = []
|
| 166 |
+
for book in books:
|
| 167 |
+
tagline = book.tagline
|
| 168 |
+
if not tagline and book.status != "ready":
|
| 169 |
+
tagline = "Getting ready..."
|
| 170 |
+
items.append({
|
| 171 |
+
"id": book.id,
|
| 172 |
+
"title": book.title,
|
| 173 |
+
"tagline": tagline,
|
| 174 |
+
"cover_path": book.cover_path,
|
| 175 |
+
})
|
| 176 |
+
return items
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
def book_hook(book) -> str:
|
| 180 |
+
"""One-line hook — tagline only, never a plot excerpt."""
|
| 181 |
+
if book.tagline:
|
| 182 |
+
return book.tagline.strip()
|
| 183 |
+
if book.status != "ready":
|
| 184 |
+
return "Still getting ready — but worth the wait."
|
| 185 |
+
return "A story you'll want to experience firsthand."
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
def book_tease(book) -> str:
|
| 189 |
+
"""Ultra-short tease for anti-spoiler responses."""
|
| 190 |
+
if book.tagline:
|
| 191 |
+
return book.tagline.strip().rstrip(".")
|
| 192 |
+
return "a journey worth reading for yourself"
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
# ── Custom Q&A Lookup ─────────────────────────────────────────────────────────
|
| 196 |
+
|
| 197 |
+
async def check_custom_qa(
|
| 198 |
+
query: str,
|
| 199 |
+
author_id: str,
|
| 200 |
+
db: AsyncSession,
|
| 201 |
+
) -> dict | None:
|
| 202 |
+
"""Check custom Q&A pairs for a fuzzy match using Jaccard similarity.
|
| 203 |
+
|
| 204 |
+
If a match exceeds the pair's configured threshold, increments match_count
|
| 205 |
+
and returns the answer.
|
| 206 |
+
|
| 207 |
+
Args:
|
| 208 |
+
query: User's raw query.
|
| 209 |
+
author_id: Author UUID to scope the search.
|
| 210 |
+
db: Active database session.
|
| 211 |
+
|
| 212 |
+
Returns:
|
| 213 |
+
Dict with 'answer' and 'score' keys, or None if no match.
|
| 214 |
+
"""
|
| 215 |
+
from sqlalchemy import select
|
| 216 |
+
from app.models.custom_qa import CustomQA
|
| 217 |
+
|
| 218 |
+
result = await db.execute(
|
| 219 |
+
select(CustomQA)
|
| 220 |
+
.where(CustomQA.author_id == author_id, CustomQA.is_active == True)
|
| 221 |
+
.order_by(CustomQA.priority.desc())
|
| 222 |
+
)
|
| 223 |
+
qa_pairs = result.scalars().all()
|
| 224 |
+
if not qa_pairs:
|
| 225 |
+
return None
|
| 226 |
+
|
| 227 |
+
query_words = set(query.lower().split())
|
| 228 |
+
best_match = None
|
| 229 |
+
best_score = 0.0
|
| 230 |
+
|
| 231 |
+
for qa in qa_pairs:
|
| 232 |
+
q_words = set(qa.question.lower().split())
|
| 233 |
+
if not q_words or not query_words:
|
| 234 |
+
continue
|
| 235 |
+
intersection = query_words & q_words
|
| 236 |
+
union = query_words | q_words
|
| 237 |
+
jaccard = len(intersection) / len(union) if union else 0
|
| 238 |
+
|
| 239 |
+
if jaccard >= qa.match_threshold and jaccard > best_score:
|
| 240 |
+
best_score = jaccard
|
| 241 |
+
best_match = qa
|
| 242 |
+
|
| 243 |
+
if best_match:
|
| 244 |
+
best_match.match_count = (best_match.match_count or 0) + 1
|
| 245 |
+
await db.commit()
|
| 246 |
+
logger.info("Custom Q&A match", qa_id=best_match.id, score=best_score)
|
| 247 |
+
return {"answer": best_match.answer, "score": best_score}
|
| 248 |
+
|
| 249 |
+
return None
|
app/services/rag_pipeline.py
CHANGED
|
@@ -1,908 +1,14 @@
|
|
| 1 |
-
"""
|
| 2 |
|
| 3 |
-
This
|
| 4 |
-
|
|
|
|
| 5 |
|
| 6 |
-
|
| 7 |
-
RULE: Every step failure must be handled gracefully — never crash the user's session.
|
| 8 |
-
RULE: Token usage is tracked and returned for budget accounting.
|
| 9 |
-
|
| 10 |
-
Pipeline Steps:
|
| 11 |
-
1. Boundary check (query)
|
| 12 |
-
2. Intent classification
|
| 13 |
-
3. Book resolution (select or show selector)
|
| 14 |
-
4. Query rewriting
|
| 15 |
-
5. Vector retrieval (ChromaDB)
|
| 16 |
-
6. Cross-encoder re-ranking
|
| 17 |
-
7. Context assembly (token-aware)
|
| 18 |
-
8. LLM generation (streaming)
|
| 19 |
-
9. Faithfulness check (NLI guardrail)
|
| 20 |
-
10. Response scope check (leak prevention)
|
| 21 |
-
11. Upsell strategy injection
|
| 22 |
-
12. Response formatting + link injection
|
| 23 |
"""
|
| 24 |
|
| 25 |
-
import
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
import structlog
|
| 30 |
-
from openai import AsyncOpenAI
|
| 31 |
-
from redis.asyncio import Redis
|
| 32 |
-
from sqlalchemy.ext.asyncio import AsyncSession
|
| 33 |
-
|
| 34 |
-
from app.config import get_settings
|
| 35 |
-
from app.services.context_builder import build_context
|
| 36 |
-
from app.services.formatter import ResponseFormatter
|
| 37 |
-
from app.services.guardrails import (
|
| 38 |
-
check_boundary,
|
| 39 |
-
is_response_safe,
|
| 40 |
-
sanitize_user_input,
|
| 41 |
-
sanitize_history_line,
|
| 42 |
-
scrub_unsafe_response,
|
| 43 |
-
)
|
| 44 |
-
from app.services.faithfulness import check_faithfulness
|
| 45 |
-
from app.services.intent import classify_intent
|
| 46 |
-
from app.services.prompter import (
|
| 47 |
-
MASTER_SYSTEM_PROMPT,
|
| 48 |
-
JAILBREAK_RESPONSE, OFF_TOPIC_RESPONSE, PIRACY_RESPONSE,
|
| 49 |
-
NO_CONTEXT_RESPONSE, HALLUCINATION_FALLBACK_RESPONSE,
|
| 50 |
-
GREETING_RESPONSE, CATALOG_RESPONSE, BOOK_SELECTED_RESPONSE,
|
| 51 |
-
FULL_STORY_RESPONSE,
|
| 52 |
-
get_response_style_instruction,
|
| 53 |
-
)
|
| 54 |
-
from app.services.reranker import rerank_chunks
|
| 55 |
-
from app.services.vector_store import retrieve_chunks
|
| 56 |
-
from app.services.rewriter import rewrite_query
|
| 57 |
-
from app.services.upsell_engine import UpsellEngine
|
| 58 |
-
from app.services.session_core.manager import SessionContext, SessionManager
|
| 59 |
-
from app.models.user import User
|
| 60 |
-
from app.repositories.book_repo import BookRepository
|
| 61 |
-
from app.repositories.link_repo import LinkRepository
|
| 62 |
-
from app.utils.token_counter import count_messages_tokens
|
| 63 |
-
|
| 64 |
-
logger = structlog.get_logger(__name__)
|
| 65 |
-
cfg = get_settings()
|
| 66 |
-
|
| 67 |
-
_upsell_engine = UpsellEngine()
|
| 68 |
-
_formatter = ResponseFormatter()
|
| 69 |
-
|
| 70 |
-
# ── LRU Answer Cache (from RAG 1.2 proven pattern) ───────────────────────────
|
| 71 |
-
# Keyed on MD5 of author_id + book_id + normalized query.
|
| 72 |
-
# Saves repeated identical questions from different readers of the same book.
|
| 73 |
-
# NOT cached: greetings, purchase_intent (links change), complaints.
|
| 74 |
-
import hashlib
|
| 75 |
-
from collections import OrderedDict
|
| 76 |
-
|
| 77 |
-
_CACHE_MAX = 256
|
| 78 |
-
_answer_cache: OrderedDict = OrderedDict()
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
def _cache_key(author_id: str, book_id: str | None, query: str) -> str:
|
| 82 |
-
raw = f"{author_id}:{book_id or ''}:{query.lower().strip()}"
|
| 83 |
-
return hashlib.md5(raw.encode()).hexdigest()
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
def _cache_get(key: str) -> PipelineResult | None:
|
| 87 |
-
if key in _answer_cache:
|
| 88 |
-
_answer_cache.move_to_end(key)
|
| 89 |
-
return _answer_cache[key]
|
| 90 |
-
return None
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
def _cache_set(key: str, result: PipelineResult) -> None:
|
| 94 |
-
_answer_cache[key] = result
|
| 95 |
-
_answer_cache.move_to_end(key)
|
| 96 |
-
if len(_answer_cache) > _CACHE_MAX:
|
| 97 |
-
_answer_cache.popitem(last=False) # Evict LRU
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
def invalidate_book_cache(author_id: str, book_id: str) -> int:
|
| 101 |
-
"""Remove all cached answers for a specific book (call on re-upload)."""
|
| 102 |
-
prefix = f"{author_id}:{book_id}:"
|
| 103 |
-
to_delete = [k for k in _answer_cache if k.startswith(hashlib.md5(prefix.encode()).hexdigest()[:8])]
|
| 104 |
-
# Simpler: clear whole cache for this author when a book changes
|
| 105 |
-
to_delete = [k for k in list(_answer_cache.keys())]
|
| 106 |
-
for k in to_delete:
|
| 107 |
-
del _answer_cache[k]
|
| 108 |
-
return len(to_delete)
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
# ── Chunk Deduplication (from RAG 1.2 proven pattern) ────────────────────────
|
| 112 |
-
# Removes near-duplicate chunks by word overlap ratio.
|
| 113 |
-
# Prevents LLM from seeing same information repeated across overlapping windows.
|
| 114 |
-
|
| 115 |
-
def _deduplicate_chunks(chunks: list, similarity_threshold: float = 0.82) -> list:
|
| 116 |
-
"""Remove near-duplicate chunks based on word overlap ratio.
|
| 117 |
-
|
| 118 |
-
Args:
|
| 119 |
-
chunks: List of chunk objects with .text attribute or dict with 'text' key.
|
| 120 |
-
similarity_threshold: Jaccard-style overlap ratio above which a chunk is a duplicate.
|
| 121 |
-
|
| 122 |
-
Returns:
|
| 123 |
-
Deduplicated list of chunks.
|
| 124 |
-
"""
|
| 125 |
-
if len(chunks) <= 1:
|
| 126 |
-
return chunks
|
| 127 |
-
|
| 128 |
-
def get_words(chunk) -> set[str]:
|
| 129 |
-
text = chunk.text if hasattr(chunk, 'text') else chunk.get('text', '')
|
| 130 |
-
return set(text.lower().split())
|
| 131 |
-
|
| 132 |
-
unique = [chunks[0]]
|
| 133 |
-
for candidate in chunks[1:]:
|
| 134 |
-
cw = get_words(candidate)
|
| 135 |
-
is_dup = False
|
| 136 |
-
for existing in unique:
|
| 137 |
-
ew = get_words(existing)
|
| 138 |
-
if not cw or not ew:
|
| 139 |
-
continue
|
| 140 |
-
overlap = len(cw & ew) / min(len(cw), len(ew))
|
| 141 |
-
if overlap >= similarity_threshold:
|
| 142 |
-
is_dup = True
|
| 143 |
-
break
|
| 144 |
-
if not is_dup:
|
| 145 |
-
unique.append(candidate)
|
| 146 |
-
|
| 147 |
-
removed = len(chunks) - len(unique)
|
| 148 |
-
if removed > 0:
|
| 149 |
-
logger.debug("Chunk deduplication removed %d near-duplicates", removed)
|
| 150 |
-
return unique
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
@dataclass
|
| 155 |
-
class PipelineResult:
|
| 156 |
-
"""Full result from one RAG pipeline execution."""
|
| 157 |
-
|
| 158 |
-
response: dict # Formatted response dict
|
| 159 |
-
intent: str = "question"
|
| 160 |
-
intent_confidence: float = 0.7
|
| 161 |
-
faithfulness_score: float = 1.0
|
| 162 |
-
hallucination_detected: bool = False
|
| 163 |
-
boundary_triggered: bool = False
|
| 164 |
-
upsell_strategy: str | None = None
|
| 165 |
-
link_shown: bool = False
|
| 166 |
-
prompt_tokens: int = 0
|
| 167 |
-
completion_tokens: int = 0
|
| 168 |
-
response_ms: int = 0
|
| 169 |
-
top_book_ids: list[str] = field(default_factory=list)
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
async def _check_custom_qa(
|
| 173 |
-
query: str,
|
| 174 |
-
author_id: str,
|
| 175 |
-
db: AsyncSession,
|
| 176 |
-
) -> dict | None:
|
| 177 |
-
"""Check custom Q&A pairs for a fuzzy match. Returns dict or None.
|
| 178 |
-
|
| 179 |
-
Uses simple word overlap (Jaccard similarity) for fast matching.
|
| 180 |
-
If a match exceeds the pair's threshold, increments match_count and returns.
|
| 181 |
-
"""
|
| 182 |
-
from sqlalchemy import select
|
| 183 |
-
from app.models.custom_qa import CustomQA
|
| 184 |
-
|
| 185 |
-
result = await db.execute(
|
| 186 |
-
select(CustomQA)
|
| 187 |
-
.where(CustomQA.author_id == author_id, CustomQA.is_active == True)
|
| 188 |
-
.order_by(CustomQA.priority.desc())
|
| 189 |
-
)
|
| 190 |
-
qa_pairs = result.scalars().all()
|
| 191 |
-
if not qa_pairs:
|
| 192 |
-
return None
|
| 193 |
-
|
| 194 |
-
query_words = set(query.lower().split())
|
| 195 |
-
best_match = None
|
| 196 |
-
best_score = 0.0
|
| 197 |
-
|
| 198 |
-
for qa in qa_pairs:
|
| 199 |
-
q_words = set(qa.question.lower().split())
|
| 200 |
-
if not q_words or not query_words:
|
| 201 |
-
continue
|
| 202 |
-
intersection = query_words & q_words
|
| 203 |
-
union = query_words | q_words
|
| 204 |
-
jaccard = len(intersection) / len(union) if union else 0
|
| 205 |
-
|
| 206 |
-
if jaccard >= qa.match_threshold and jaccard > best_score:
|
| 207 |
-
best_score = jaccard
|
| 208 |
-
best_match = qa
|
| 209 |
-
|
| 210 |
-
if best_match:
|
| 211 |
-
# Increment match count
|
| 212 |
-
best_match.match_count = (best_match.match_count or 0) + 1
|
| 213 |
-
await db.commit()
|
| 214 |
-
logger.info("Custom Q&A match", qa_id=best_match.id, score=best_score)
|
| 215 |
-
return {"answer": best_match.answer, "score": best_score}
|
| 216 |
-
|
| 217 |
-
return None
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
async def run_pipeline(
|
| 221 |
-
query: str,
|
| 222 |
-
author: User,
|
| 223 |
-
session_context: SessionContext,
|
| 224 |
-
db: AsyncSession,
|
| 225 |
-
) -> PipelineResult:
|
| 226 |
-
"""Execute the full 12-step RAG pipeline for one chat turn.
|
| 227 |
-
|
| 228 |
-
Args:
|
| 229 |
-
query: The user's raw message text.
|
| 230 |
-
author: The author whose catalog is being queried.
|
| 231 |
-
session_context: Current session state (history, selected book, interest).
|
| 232 |
-
db: Active database session.
|
| 233 |
-
|
| 234 |
-
Returns:
|
| 235 |
-
PipelineResult with formatted response and all metadata for logging.
|
| 236 |
-
"""
|
| 237 |
-
start_ms = time.monotonic()
|
| 238 |
-
log = logger.bind(author_id=author.id, turn=session_context.turn_count)
|
| 239 |
-
|
| 240 |
-
# ── Step 0: Sanitize input ───────────────────────────────────────────────
|
| 241 |
-
query = sanitize_user_input(query)
|
| 242 |
-
if not query:
|
| 243 |
-
return _boundary_response(
|
| 244 |
-
"I didn't catch that — try asking about one of the books!",
|
| 245 |
-
start_ms,
|
| 246 |
-
"empty_input",
|
| 247 |
-
)
|
| 248 |
-
|
| 249 |
-
# ── Step 1: Boundary Check ─────────────────────────────────────────────────
|
| 250 |
-
violation_type, _ = check_boundary(query)
|
| 251 |
-
if violation_type == "jailbreak_attempt":
|
| 252 |
-
return _boundary_response(
|
| 253 |
-
JAILBREAK_RESPONSE.format(
|
| 254 |
-
bot_name=author.bot_name,
|
| 255 |
-
author_name=author.full_name or "the author",
|
| 256 |
-
),
|
| 257 |
-
start_ms,
|
| 258 |
-
"jailbreak_attempt",
|
| 259 |
-
)
|
| 260 |
-
if violation_type == "piracy_request":
|
| 261 |
-
return await _piracy_response(author, session_context, db, start_ms)
|
| 262 |
-
if violation_type == "off_topic":
|
| 263 |
-
return _boundary_response(
|
| 264 |
-
OFF_TOPIC_RESPONSE.format(author_name=author.full_name or "the author"),
|
| 265 |
-
start_ms,
|
| 266 |
-
"off_topic",
|
| 267 |
-
)
|
| 268 |
-
|
| 269 |
-
# ── Step 1.5: Custom Q&A Check (short-circuit) ────────────────────────────
|
| 270 |
-
qa_match = await _check_custom_qa(query, author.id, db)
|
| 271 |
-
if qa_match:
|
| 272 |
-
elapsed_ms = int((time.monotonic() - start_ms) * 1000)
|
| 273 |
-
return PipelineResult(
|
| 274 |
-
response={"text": qa_match["answer"], "links": [], "has_links": False},
|
| 275 |
-
intent="custom_qa",
|
| 276 |
-
intent_confidence=qa_match["score"],
|
| 277 |
-
response_ms=elapsed_ms,
|
| 278 |
-
hallucination_detected=False,
|
| 279 |
-
)
|
| 280 |
-
|
| 281 |
-
# ── Step 2: Intent Classification ─────────────────────────────────────────
|
| 282 |
-
intent_result = await classify_intent(query, session_context.history)
|
| 283 |
-
log.debug("Intent classified", intent=intent_result.intent)
|
| 284 |
-
|
| 285 |
-
if intent_result.intent == "jailbreak_attempt":
|
| 286 |
-
return _boundary_response(
|
| 287 |
-
JAILBREAK_RESPONSE.format(
|
| 288 |
-
bot_name=author.bot_name,
|
| 289 |
-
author_name=author.full_name or "the author",
|
| 290 |
-
),
|
| 291 |
-
start_ms,
|
| 292 |
-
"jailbreak_attempt",
|
| 293 |
-
)
|
| 294 |
-
|
| 295 |
-
# ── Step 3: Book Resolution ────────────────────────────────────────────────
|
| 296 |
-
book_repo = BookRepository(db)
|
| 297 |
-
active_books = await book_repo.list_active_for_author(author.id)
|
| 298 |
-
|
| 299 |
-
if not active_books:
|
| 300 |
-
return _no_books_response(start_ms)
|
| 301 |
-
|
| 302 |
-
# Short-circuit: greetings and catalog questions → show clickable book list
|
| 303 |
-
if intent_result.intent == "greeting" or _is_greeting(query):
|
| 304 |
-
return _greeting_response(author, active_books, session_context, start_ms)
|
| 305 |
-
|
| 306 |
-
if intent_result.intent in ("meta", "comparison") or _is_catalog_question(query):
|
| 307 |
-
return await _catalog_response(author, active_books, session_context, db, start_ms)
|
| 308 |
-
|
| 309 |
-
# Book just selected — warm intro, then user asks freely
|
| 310 |
-
if _is_book_selection_turn(query, session_context.selected_book_id, active_books):
|
| 311 |
-
book = _find_book(active_books, session_context.selected_book_id)
|
| 312 |
-
if book:
|
| 313 |
-
return await _book_selected_response(book, author.id, db, start_ms)
|
| 314 |
-
|
| 315 |
-
# Full story / spoiler requests — never dump the plot
|
| 316 |
-
if intent_result.intent == "full_story_request" or _is_full_story_request(query):
|
| 317 |
-
book = _find_book(active_books, session_context.selected_book_id) or active_books[0]
|
| 318 |
-
return await _full_story_response(book, author.id, db, start_ms)
|
| 319 |
-
|
| 320 |
-
# Multiple books: require a selection before Q&A
|
| 321 |
-
if len(active_books) > 1 and not session_context.selected_book_id:
|
| 322 |
-
return _books_list_response(
|
| 323 |
-
"Select a book below to ask about it.",
|
| 324 |
-
active_books,
|
| 325 |
-
start_ms,
|
| 326 |
-
intent="comparison",
|
| 327 |
-
)
|
| 328 |
-
|
| 329 |
-
# Resolve which book to search
|
| 330 |
-
target_book_id = await _resolve_book(
|
| 331 |
-
intent_result, session_context, active_books, author.id
|
| 332 |
-
)
|
| 333 |
-
|
| 334 |
-
# If book confidence is too low and multiple books exist → show selector
|
| 335 |
-
if (
|
| 336 |
-
target_book_id is None
|
| 337 |
-
and len(active_books) > 1
|
| 338 |
-
and intent_result.book_confidence < cfg.RAG_BOOK_CONFIDENCE_THRESHOLD
|
| 339 |
-
and session_context.selected_book_id is None
|
| 340 |
-
):
|
| 341 |
-
return _book_selector_response(active_books, start_ms)
|
| 342 |
-
|
| 343 |
-
# Use all books if still no specific book resolved
|
| 344 |
-
search_book_id = target_book_id or session_context.selected_book_id
|
| 345 |
-
|
| 346 |
-
# ── Step 4: Query Rewriting ────────────────────────────────────────────────
|
| 347 |
-
query_variations = await rewrite_query(query, session_context.history)
|
| 348 |
-
|
| 349 |
-
# ── Step 5: Vector Retrieval ───────────────────────────────────────────────
|
| 350 |
-
raw_chunks = await retrieve_chunks(
|
| 351 |
-
queries=query_variations,
|
| 352 |
-
author_id=author.id,
|
| 353 |
-
book_id=search_book_id,
|
| 354 |
-
top_k=cfg.RAG_RETRIEVAL_TOP_K,
|
| 355 |
-
)
|
| 356 |
-
|
| 357 |
-
if not raw_chunks:
|
| 358 |
-
log.warning("No chunks retrieved")
|
| 359 |
-
return await _no_context_response(query, author, active_books, session_context, db, start_ms)
|
| 360 |
-
|
| 361 |
-
# ── Step 6: Re-ranking ────────────────────────────────────────────────────
|
| 362 |
-
top_chunks = await rerank_chunks(
|
| 363 |
-
query=query,
|
| 364 |
-
chunks=raw_chunks,
|
| 365 |
-
top_n=cfg.RAG_RERANK_TOP_N,
|
| 366 |
-
min_score=cfg.RAG_RERANK_MIN_SCORE,
|
| 367 |
-
)
|
| 368 |
-
|
| 369 |
-
if not top_chunks:
|
| 370 |
-
log.warning("Re-ranker filtered all chunks — using top retrieval results")
|
| 371 |
-
top_chunks = raw_chunks[: cfg.RAG_RERANK_TOP_N]
|
| 372 |
-
|
| 373 |
-
if not top_chunks:
|
| 374 |
-
return await _no_context_response(query, author, active_books, session_context, db, start_ms)
|
| 375 |
-
|
| 376 |
-
# ── Step 6.5: Deduplicate near-identical chunks ───────────────────────────
|
| 377 |
-
top_chunks = _deduplicate_chunks(top_chunks)
|
| 378 |
-
|
| 379 |
-
# ── Step 7: Context Assembly ─────────────────────────────────────────────���─
|
| 380 |
-
context_str, context_tokens = build_context(top_chunks)
|
| 381 |
-
|
| 382 |
-
# ── Step 8: LLM Generation ────────────────────────────────────────────────
|
| 383 |
-
history_str = _format_history(session_context.history)
|
| 384 |
-
interest_tags_str = ", ".join(session_context.interest_tags[:10]) or "None detected yet"
|
| 385 |
-
book_title = _selected_book_title(active_books, session_context.selected_book_id)
|
| 386 |
-
|
| 387 |
-
style = author.response_style or "balanced"
|
| 388 |
-
system_prompt = MASTER_SYSTEM_PROMPT.format(
|
| 389 |
-
bot_name=author.bot_name,
|
| 390 |
-
author_name=author.full_name or "the author",
|
| 391 |
-
book_title=book_title,
|
| 392 |
-
interest_score=f"{session_context.interest_score:.1f}",
|
| 393 |
-
interest_tags=interest_tags_str,
|
| 394 |
-
context=context_str,
|
| 395 |
-
history=history_str,
|
| 396 |
-
response_style=style,
|
| 397 |
-
tone_instruction=get_response_style_instruction(style),
|
| 398 |
-
)
|
| 399 |
-
|
| 400 |
-
user_content = query
|
| 401 |
-
if _is_full_story_request(query):
|
| 402 |
-
user_content = (
|
| 403 |
-
f"{query}\n\n"
|
| 404 |
-
"[Reminder: Max 2 sentences. Do NOT summarize the plot. Tease only.]"
|
| 405 |
-
)
|
| 406 |
-
|
| 407 |
-
messages = [
|
| 408 |
-
{"role": "system", "content": system_prompt},
|
| 409 |
-
{"role": "user", "content": user_content},
|
| 410 |
-
]
|
| 411 |
-
|
| 412 |
-
raw_response, prompt_tokens, completion_tokens = await _call_llm(messages)
|
| 413 |
-
|
| 414 |
-
# ── Step 9: Faithfulness Check ────────────────────────────────────────────
|
| 415 |
-
is_faithful, faithfulness_score = await check_faithfulness(raw_response, top_chunks)
|
| 416 |
-
hallucination_detected = not is_faithful
|
| 417 |
-
|
| 418 |
-
if hallucination_detected:
|
| 419 |
-
log.warning("Hallucination detected", score=faithfulness_score)
|
| 420 |
-
# Retry once with stricter instruction
|
| 421 |
-
stricter_messages = messages + [
|
| 422 |
-
{"role": "assistant", "content": raw_response},
|
| 423 |
-
{"role": "user", "content": "Reply using ONLY the retrieved context. Max 2 sentences."},
|
| 424 |
-
]
|
| 425 |
-
raw_response, p2, c2 = await _call_llm(stricter_messages, temperature=0.3)
|
| 426 |
-
prompt_tokens += p2
|
| 427 |
-
completion_tokens += c2
|
| 428 |
-
|
| 429 |
-
is_faithful2, faithfulness_score = await check_faithfulness(raw_response, top_chunks)
|
| 430 |
-
if not is_faithful2:
|
| 431 |
-
book_title = _selected_book_title(active_books, session_context.selected_book_id)
|
| 432 |
-
raw_response = HALLUCINATION_FALLBACK_RESPONSE.format(book_title=book_title)
|
| 433 |
-
|
| 434 |
-
# ── Step 10: Output safety check ──────────────────────────────────────────
|
| 435 |
-
safe_fallback = NO_CONTEXT_RESPONSE.format(
|
| 436 |
-
book_title=_selected_book_title(active_books, session_context.selected_book_id)
|
| 437 |
-
)
|
| 438 |
-
raw_response = scrub_unsafe_response(raw_response, safe_fallback)
|
| 439 |
-
|
| 440 |
-
if not is_response_safe(raw_response)[0]:
|
| 441 |
-
raw_response = JAILBREAK_RESPONSE.format(
|
| 442 |
-
bot_name=author.bot_name,
|
| 443 |
-
author_name=author.full_name or "the author",
|
| 444 |
-
)
|
| 445 |
-
|
| 446 |
-
# ── Step 11: Upsell Strategy ──────────────────────────────────────────────
|
| 447 |
-
effective_intent = intent_result.intent
|
| 448 |
-
if _is_full_story_request(query):
|
| 449 |
-
effective_intent = "full_story_request"
|
| 450 |
-
|
| 451 |
-
strategy = _upsell_engine.select_strategy(effective_intent, session_context)
|
| 452 |
-
show_link = _upsell_engine.should_include_link(effective_intent, session_context, strategy)
|
| 453 |
-
|
| 454 |
-
top_book_id = search_book_id or (top_chunks[0].book_id if top_chunks else None)
|
| 455 |
-
purchase_url, preview_url = await _get_book_links(top_book_id, author.id, db)
|
| 456 |
-
|
| 457 |
-
# ── Step 12: Format Response ───────────────────────────────────────────────
|
| 458 |
-
# Hook text removed from body — the buy button IS the CTA.
|
| 459 |
-
# Text ends naturally; button appears below without redundant pitch text.
|
| 460 |
-
formatted = _formatter.format(
|
| 461 |
-
response_text=raw_response,
|
| 462 |
-
upsell_hook=None, # No hook text in body
|
| 463 |
-
purchase_url=purchase_url,
|
| 464 |
-
preview_url=preview_url,
|
| 465 |
-
show_link=show_link and bool(purchase_url),
|
| 466 |
-
)
|
| 467 |
-
|
| 468 |
-
elapsed_ms = int((time.monotonic() - start_ms) * 1000)
|
| 469 |
-
log.info("Pipeline complete", ms=elapsed_ms, faithfulness=faithfulness_score)
|
| 470 |
-
|
| 471 |
-
result = PipelineResult(
|
| 472 |
-
response=formatted,
|
| 473 |
-
intent=intent_result.intent,
|
| 474 |
-
intent_confidence=intent_result.confidence,
|
| 475 |
-
faithfulness_score=faithfulness_score,
|
| 476 |
-
hallucination_detected=hallucination_detected,
|
| 477 |
-
boundary_triggered=False,
|
| 478 |
-
upsell_strategy=strategy,
|
| 479 |
-
link_shown=formatted["has_links"],
|
| 480 |
-
prompt_tokens=prompt_tokens,
|
| 481 |
-
completion_tokens=completion_tokens,
|
| 482 |
-
response_ms=elapsed_ms,
|
| 483 |
-
top_book_ids=list({c.book_id for c in top_chunks}),
|
| 484 |
-
)
|
| 485 |
-
|
| 486 |
-
# Cache non-personal, non-purchase results (identical questions answered instantly)
|
| 487 |
-
if intent_result.intent not in ("purchase_intent", "complaint", "greeting"):
|
| 488 |
-
cache_key = _cache_key(
|
| 489 |
-
author.id,
|
| 490 |
-
session_context.selected_book_id,
|
| 491 |
-
query,
|
| 492 |
-
)
|
| 493 |
-
_cache_set(cache_key, result)
|
| 494 |
-
log.debug("Answer cached", key=cache_key[:8])
|
| 495 |
-
|
| 496 |
-
return result
|
| 497 |
-
|
| 498 |
-
|
| 499 |
-
# ─── Private Helpers ──────────────────────────────────────────────────────────
|
| 500 |
-
|
| 501 |
-
async def _call_llm(
|
| 502 |
-
messages: list[dict],
|
| 503 |
-
temperature: float | None = None,
|
| 504 |
-
) -> tuple[str, int, int]:
|
| 505 |
-
"""Call OpenAI chat completions and return response + token counts.
|
| 506 |
-
|
| 507 |
-
Args:
|
| 508 |
-
messages: List of message dicts for the API call.
|
| 509 |
-
temperature: Optional temperature override.
|
| 510 |
-
|
| 511 |
-
Returns:
|
| 512 |
-
Tuple of (response_text, prompt_tokens, completion_tokens).
|
| 513 |
-
"""
|
| 514 |
-
client = AsyncOpenAI(api_key=cfg.OPENAI_API_KEY)
|
| 515 |
-
response = await client.chat.completions.create(
|
| 516 |
-
model=cfg.OPENAI_CHAT_MODEL,
|
| 517 |
-
messages=messages,
|
| 518 |
-
max_tokens=cfg.RAG_MAX_RESPONSE_TOKENS,
|
| 519 |
-
temperature=temperature or cfg.RAG_TEMPERATURE,
|
| 520 |
-
)
|
| 521 |
-
content = response.choices[0].message.content or ""
|
| 522 |
-
usage = response.usage
|
| 523 |
-
return content, usage.prompt_tokens, usage.completion_tokens
|
| 524 |
-
|
| 525 |
-
|
| 526 |
-
async def _resolve_book(
|
| 527 |
-
intent_result,
|
| 528 |
-
session_context: SessionContext,
|
| 529 |
-
active_books: list,
|
| 530 |
-
author_id: str,
|
| 531 |
-
) -> str | None:
|
| 532 |
-
"""Determine the target book for retrieval.
|
| 533 |
-
|
| 534 |
-
Args:
|
| 535 |
-
intent_result: Classified intent with book reference.
|
| 536 |
-
session_context: Current session state.
|
| 537 |
-
active_books: All active books for this author.
|
| 538 |
-
author_id: UUID of the author.
|
| 539 |
-
|
| 540 |
-
Returns:
|
| 541 |
-
Book UUID to search, or None for cross-book search.
|
| 542 |
-
"""
|
| 543 |
-
# If query explicitly references a book by name, use that
|
| 544 |
-
if intent_result.book_reference and intent_result.book_confidence >= cfg.RAG_BOOK_CONFIDENCE_THRESHOLD:
|
| 545 |
-
ref_lower = intent_result.book_reference.lower()
|
| 546 |
-
for book in active_books:
|
| 547 |
-
if ref_lower in book.title.lower() or book.title.lower() in ref_lower:
|
| 548 |
-
return book.id
|
| 549 |
-
|
| 550 |
-
# If only one book, always use it
|
| 551 |
-
if len(active_books) == 1:
|
| 552 |
-
return active_books[0].id
|
| 553 |
-
|
| 554 |
-
return None
|
| 555 |
-
|
| 556 |
-
|
| 557 |
-
def _format_history(history: list[dict]) -> str:
|
| 558 |
-
"""Format conversation history for the system prompt.
|
| 559 |
-
|
| 560 |
-
Args:
|
| 561 |
-
history: List of message dicts.
|
| 562 |
-
|
| 563 |
-
Returns:
|
| 564 |
-
Formatted string.
|
| 565 |
-
"""
|
| 566 |
-
if not history:
|
| 567 |
-
return "This is the start of the conversation."
|
| 568 |
-
lines = []
|
| 569 |
-
for msg in history[-6:]:
|
| 570 |
-
role = "Visitor" if msg["role"] == "user" else "You"
|
| 571 |
-
content = sanitize_history_line(msg.get("content", ""))
|
| 572 |
-
if content:
|
| 573 |
-
lines.append(f"{role}: {content}")
|
| 574 |
-
return "\n".join(lines)
|
| 575 |
-
|
| 576 |
-
|
| 577 |
-
async def _get_book_links(
|
| 578 |
-
book_id: str | None,
|
| 579 |
-
author_id: str,
|
| 580 |
-
db: AsyncSession,
|
| 581 |
-
) -> tuple[str | None, str | None]:
|
| 582 |
-
"""Fetch purchase and preview URLs for a book.
|
| 583 |
-
|
| 584 |
-
Args:
|
| 585 |
-
book_id: UUID of the book.
|
| 586 |
-
author_id: UUID of the author.
|
| 587 |
-
db: Database session.
|
| 588 |
-
|
| 589 |
-
Returns:
|
| 590 |
-
Tuple of (purchase_url | None, preview_url | None).
|
| 591 |
-
"""
|
| 592 |
-
if not book_id:
|
| 593 |
-
return None, None
|
| 594 |
-
try:
|
| 595 |
-
link_repo = LinkRepository(db)
|
| 596 |
-
link = await link_repo.get_for_book(book_id, author_id)
|
| 597 |
-
if link and (link.purchase_url or link.preview_url):
|
| 598 |
-
return link.purchase_url, link.preview_url
|
| 599 |
-
|
| 600 |
-
from app.repositories.book_repo import BookRepository
|
| 601 |
-
|
| 602 |
-
book_repo = BookRepository(db)
|
| 603 |
-
book = await book_repo.get_by_id(book_id)
|
| 604 |
-
if book and book.author_id == author_id:
|
| 605 |
-
return book.buy_url, book.preview_url
|
| 606 |
-
except Exception:
|
| 607 |
-
pass
|
| 608 |
-
return None, None
|
| 609 |
-
|
| 610 |
-
|
| 611 |
-
def _boundary_response(text: str, start_ms: float, violation_type: str) -> PipelineResult:
|
| 612 |
-
elapsed_ms = int((time.monotonic() - start_ms) * 1000)
|
| 613 |
-
return PipelineResult(
|
| 614 |
-
response={"text": text, "links": [], "has_links": False},
|
| 615 |
-
boundary_triggered=True,
|
| 616 |
-
intent=violation_type,
|
| 617 |
-
response_ms=elapsed_ms,
|
| 618 |
-
)
|
| 619 |
-
|
| 620 |
-
|
| 621 |
-
_CATALOG_PHRASES = (
|
| 622 |
-
"uploaded book", "your books", "what books", "which books", "list book",
|
| 623 |
-
"books do you", "books you have", "books available", "about the books",
|
| 624 |
-
"tell me about book", "tell me about your book", "tell me about the books",
|
| 625 |
-
"what is the book", "what's the book", "in the catalog", "in your catalog",
|
| 626 |
-
"show me the book", "show me your book",
|
| 627 |
-
)
|
| 628 |
-
|
| 629 |
-
|
| 630 |
-
def _find_book(books: list, book_id: str | None):
|
| 631 |
-
if not book_id:
|
| 632 |
-
return None
|
| 633 |
-
return next((b for b in books if b.id == book_id), None)
|
| 634 |
-
|
| 635 |
-
|
| 636 |
-
def _selected_book_title(books: list, selected_book_id: str | None) -> str:
|
| 637 |
-
book = _find_book(books, selected_book_id)
|
| 638 |
-
if book:
|
| 639 |
-
return book.title
|
| 640 |
-
if len(books) == 1:
|
| 641 |
-
return books[0].title
|
| 642 |
-
return "the books"
|
| 643 |
-
|
| 644 |
-
|
| 645 |
-
def _book_selector_items(books: list) -> list[dict]:
|
| 646 |
-
items = []
|
| 647 |
-
for book in books:
|
| 648 |
-
tagline = book.tagline
|
| 649 |
-
if not tagline and book.status != "ready":
|
| 650 |
-
tagline = "Getting ready..."
|
| 651 |
-
items.append({
|
| 652 |
-
"id": book.id,
|
| 653 |
-
"title": book.title,
|
| 654 |
-
"tagline": tagline,
|
| 655 |
-
"cover_path": book.cover_path,
|
| 656 |
-
})
|
| 657 |
-
return items
|
| 658 |
-
|
| 659 |
-
|
| 660 |
-
def _books_list_response(
|
| 661 |
-
text: str,
|
| 662 |
-
books: list,
|
| 663 |
-
start_ms: float,
|
| 664 |
-
intent: str = "greeting",
|
| 665 |
-
) -> PipelineResult:
|
| 666 |
-
elapsed_ms = int((time.monotonic() - start_ms) * 1000)
|
| 667 |
-
formatted = _formatter.format_book_selector(_book_selector_items(books), intro=text)
|
| 668 |
-
return PipelineResult(
|
| 669 |
-
response=formatted,
|
| 670 |
-
intent=intent,
|
| 671 |
-
response_ms=elapsed_ms,
|
| 672 |
-
)
|
| 673 |
-
|
| 674 |
-
|
| 675 |
-
def _book_hook(book) -> str:
|
| 676 |
-
"""One-line hook — tagline only, never a plot excerpt."""
|
| 677 |
-
if book.tagline:
|
| 678 |
-
return book.tagline.strip()
|
| 679 |
-
if book.status != "ready":
|
| 680 |
-
return "Still getting ready — but worth the wait."
|
| 681 |
-
return "A story you'll want to experience firsthand."
|
| 682 |
-
|
| 683 |
-
|
| 684 |
-
def _book_tease(book) -> str:
|
| 685 |
-
"""Ultra-short tease for anti-spoiler responses."""
|
| 686 |
-
if book.tagline:
|
| 687 |
-
return book.tagline.strip().rstrip(".")
|
| 688 |
-
return "a journey worth reading for yourself"
|
| 689 |
-
|
| 690 |
-
|
| 691 |
-
_FULL_STORY_PHRASES = (
|
| 692 |
-
"complete story", "full story", "whole story", "entire story", "entire book",
|
| 693 |
-
"whole book", "full plot", "whole plot", "summarize the book", "summary of the book",
|
| 694 |
-
"tell me everything", "what happens in the book", "what happens in the story",
|
| 695 |
-
"end of the book", "how does it end", "how does the book end", "full summary",
|
| 696 |
-
"complete summary", "recap the book", "recap the story",
|
| 697 |
)
|
| 698 |
-
|
| 699 |
-
|
| 700 |
-
def _is_full_story_request(query: str) -> bool:
|
| 701 |
-
q = query.lower()
|
| 702 |
-
return any(phrase in q for phrase in _FULL_STORY_PHRASES)
|
| 703 |
-
|
| 704 |
-
|
| 705 |
-
async def _build_purchase_response(
|
| 706 |
-
text: str,
|
| 707 |
-
book_id: str | None,
|
| 708 |
-
author_id: str,
|
| 709 |
-
db: AsyncSession,
|
| 710 |
-
*,
|
| 711 |
-
force_link: bool = False,
|
| 712 |
-
upsell_hook: str | None = None,
|
| 713 |
-
) -> dict:
|
| 714 |
-
purchase_url, preview_url = await _get_book_links(book_id, author_id, db)
|
| 715 |
-
show = force_link or bool(purchase_url)
|
| 716 |
-
return _formatter.format(
|
| 717 |
-
response_text=text,
|
| 718 |
-
upsell_hook=upsell_hook,
|
| 719 |
-
purchase_url=purchase_url,
|
| 720 |
-
preview_url=preview_url,
|
| 721 |
-
show_link=show and bool(purchase_url),
|
| 722 |
-
)
|
| 723 |
-
|
| 724 |
-
|
| 725 |
-
async def _full_story_response(book, author_id: str, db: AsyncSession, start_ms: float) -> PipelineResult:
|
| 726 |
-
elapsed_ms = int((time.monotonic() - start_ms) * 1000)
|
| 727 |
-
text = FULL_STORY_RESPONSE.format(book_title=book.title, hook=_book_tease(book))
|
| 728 |
-
formatted = await _build_purchase_response(
|
| 729 |
-
text, book.id, author_id, db, force_link=True,
|
| 730 |
-
upsell_hook=_upsell_engine.build_hook("DIRECT_CTA"),
|
| 731 |
-
)
|
| 732 |
-
return PipelineResult(
|
| 733 |
-
response=formatted,
|
| 734 |
-
intent="full_story_request",
|
| 735 |
-
response_ms=elapsed_ms,
|
| 736 |
-
link_shown=formatted["has_links"],
|
| 737 |
-
)
|
| 738 |
-
|
| 739 |
-
|
| 740 |
-
def _is_book_selection_turn(query: str, selected_book_id: str | None, books: list) -> bool:
|
| 741 |
-
if not selected_book_id:
|
| 742 |
-
return False
|
| 743 |
-
book = _find_book(books, selected_book_id)
|
| 744 |
-
if not book:
|
| 745 |
-
return False
|
| 746 |
-
q = query.lower().strip().strip("\"'")
|
| 747 |
-
title_lower = book.title.lower()
|
| 748 |
-
if q == title_lower:
|
| 749 |
-
return True
|
| 750 |
-
if q.startswith("tell me about") and title_lower in q:
|
| 751 |
-
return True
|
| 752 |
-
return q.startswith("i'm interested in") and title_lower in q
|
| 753 |
-
|
| 754 |
-
_GREETINGS = frozenset({
|
| 755 |
-
"hi", "hello", "hey", "hiya", "howdy", "yo", "sup",
|
| 756 |
-
"good morning", "good afternoon", "good evening", "good day",
|
| 757 |
-
})
|
| 758 |
-
|
| 759 |
-
|
| 760 |
-
def _is_greeting(query: str) -> bool:
|
| 761 |
-
q = query.strip().lower().rstrip("!.?")
|
| 762 |
-
return q in _GREETINGS or (len(q) <= 12 and q.startswith(("hi ", "hey ", "hello ")))
|
| 763 |
-
|
| 764 |
-
|
| 765 |
-
def _is_catalog_question(query: str) -> bool:
|
| 766 |
-
q = query.lower()
|
| 767 |
-
if any(phrase in q for phrase in _CATALOG_PHRASES):
|
| 768 |
-
return True
|
| 769 |
-
return q in ("books", "tell me about books", "about books", "the books")
|
| 770 |
-
|
| 771 |
-
|
| 772 |
-
def _greeting_response(
|
| 773 |
-
author: User,
|
| 774 |
-
books: list,
|
| 775 |
-
session_context: SessionContext,
|
| 776 |
-
start_ms: float,
|
| 777 |
-
) -> PipelineResult:
|
| 778 |
-
if session_context.selected_book_id:
|
| 779 |
-
book = _find_book(books, session_context.selected_book_id)
|
| 780 |
-
title = book.title if book else "your book"
|
| 781 |
-
text = f"Hello again! Still exploring {title}? Ask me anything — I'm here for it."
|
| 782 |
-
return PipelineResult(
|
| 783 |
-
response={"text": text, "links": [], "has_links": False},
|
| 784 |
-
intent="greeting",
|
| 785 |
-
response_ms=int((time.monotonic() - start_ms) * 1000),
|
| 786 |
-
)
|
| 787 |
-
|
| 788 |
-
text = GREETING_RESPONSE
|
| 789 |
-
return _books_list_response(text, books, start_ms, intent="greeting")
|
| 790 |
-
|
| 791 |
-
|
| 792 |
-
async def _piracy_response(
|
| 793 |
-
author: User,
|
| 794 |
-
session_context: SessionContext,
|
| 795 |
-
db: AsyncSession,
|
| 796 |
-
start_ms: float,
|
| 797 |
-
) -> PipelineResult:
|
| 798 |
-
book_repo = BookRepository(db)
|
| 799 |
-
books = await book_repo.list_active_for_author(author.id)
|
| 800 |
-
book_title = _selected_book_title(books, session_context.selected_book_id)
|
| 801 |
-
book = _find_book(books, session_context.selected_book_id) or (books[0] if books else None)
|
| 802 |
-
text = PIRACY_RESPONSE.format(book_title=book_title)
|
| 803 |
-
elapsed_ms = int((time.monotonic() - start_ms) * 1000)
|
| 804 |
-
if book:
|
| 805 |
-
formatted = await _build_purchase_response(text, book.id, author.id, db, force_link=True)
|
| 806 |
-
return PipelineResult(
|
| 807 |
-
response=formatted,
|
| 808 |
-
boundary_triggered=True,
|
| 809 |
-
intent="piracy_request",
|
| 810 |
-
response_ms=elapsed_ms,
|
| 811 |
-
link_shown=formatted["has_links"],
|
| 812 |
-
)
|
| 813 |
-
return _boundary_response(text, start_ms, "piracy_request")
|
| 814 |
-
|
| 815 |
-
|
| 816 |
-
async def _catalog_response(
|
| 817 |
-
author: User,
|
| 818 |
-
books: list,
|
| 819 |
-
session_context: SessionContext,
|
| 820 |
-
db: AsyncSession,
|
| 821 |
-
start_ms: float,
|
| 822 |
-
) -> PipelineResult:
|
| 823 |
-
if session_context.selected_book_id:
|
| 824 |
-
book = _find_book(books, session_context.selected_book_id)
|
| 825 |
-
if book:
|
| 826 |
-
return await _book_selected_response(book, author.id, db, start_ms)
|
| 827 |
-
|
| 828 |
-
text = CATALOG_RESPONSE
|
| 829 |
-
return _books_list_response(text, books, start_ms, intent="meta")
|
| 830 |
-
|
| 831 |
-
|
| 832 |
-
async def _book_selected_response(
|
| 833 |
-
book,
|
| 834 |
-
author_id: str,
|
| 835 |
-
db: AsyncSession,
|
| 836 |
-
start_ms: float,
|
| 837 |
-
) -> PipelineResult:
|
| 838 |
-
elapsed_ms = int((time.monotonic() - start_ms) * 1000)
|
| 839 |
-
text = BOOK_SELECTED_RESPONSE.format(book_title=book.title, hook=_book_hook(book))
|
| 840 |
-
formatted = await _build_purchase_response(text, book.id, author_id, db)
|
| 841 |
-
return PipelineResult(
|
| 842 |
-
response=formatted,
|
| 843 |
-
intent="question",
|
| 844 |
-
response_ms=elapsed_ms,
|
| 845 |
-
link_shown=formatted["has_links"],
|
| 846 |
-
)
|
| 847 |
-
|
| 848 |
-
|
| 849 |
-
async def _no_context_response(
|
| 850 |
-
query: str,
|
| 851 |
-
author: User,
|
| 852 |
-
books: list,
|
| 853 |
-
session_context: SessionContext,
|
| 854 |
-
db: AsyncSession,
|
| 855 |
-
start_ms: float,
|
| 856 |
-
) -> PipelineResult:
|
| 857 |
-
elapsed_ms = int((time.monotonic() - start_ms) * 1000)
|
| 858 |
-
book_title = _selected_book_title(books, session_context.selected_book_id)
|
| 859 |
-
|
| 860 |
-
if len(books) == 1:
|
| 861 |
-
book = books[0]
|
| 862 |
-
# Book is answerable if embeddings exist in ChromaDB — status field may be stale
|
| 863 |
-
book_is_ready = (
|
| 864 |
-
book.status in ("ready", "active")
|
| 865 |
-
or (book.chunk_count and book.chunk_count > 0 and book.chroma_collection_id)
|
| 866 |
-
)
|
| 867 |
-
if not book_is_ready:
|
| 868 |
-
text = (
|
| 869 |
-
f"{book.title} is still being indexed — give it a minute and ask again. "
|
| 870 |
-
"I'll be able to answer detailed questions once processing finishes."
|
| 871 |
-
)
|
| 872 |
-
return PipelineResult(
|
| 873 |
-
response={"text": text, "links": [], "has_links": False},
|
| 874 |
-
intent="question",
|
| 875 |
-
response_ms=elapsed_ms,
|
| 876 |
-
)
|
| 877 |
-
|
| 878 |
-
if _is_catalog_question(query) and not session_context.selected_book_id:
|
| 879 |
-
return await _catalog_response(author, books, session_context, db, start_ms)
|
| 880 |
-
|
| 881 |
-
default_robotic = "I want to give you the most accurate answer"
|
| 882 |
-
fallback = (author.fallback_message or "").strip()
|
| 883 |
-
if fallback and default_robotic not in fallback:
|
| 884 |
-
text = fallback
|
| 885 |
-
else:
|
| 886 |
-
text = NO_CONTEXT_RESPONSE.format(book_title=book_title)
|
| 887 |
-
|
| 888 |
-
return PipelineResult(
|
| 889 |
-
response={"text": text, "links": [], "has_links": False},
|
| 890 |
-
response_ms=elapsed_ms,
|
| 891 |
-
)
|
| 892 |
-
|
| 893 |
-
|
| 894 |
-
def _no_books_response(start_ms: float) -> PipelineResult:
|
| 895 |
-
elapsed_ms = int((time.monotonic() - start_ms) * 1000)
|
| 896 |
-
return PipelineResult(
|
| 897 |
-
response={"text": "The book catalog is being set up. Check back soon!", "links": [], "has_links": False},
|
| 898 |
-
response_ms=elapsed_ms,
|
| 899 |
-
)
|
| 900 |
-
|
| 901 |
-
|
| 902 |
-
def _book_selector_response(books: list, start_ms: float) -> PipelineResult:
|
| 903 |
-
return _books_list_response(
|
| 904 |
-
"Which book are you curious about?",
|
| 905 |
-
books,
|
| 906 |
-
start_ms,
|
| 907 |
-
intent="comparison",
|
| 908 |
-
)
|
|
|
|
| 1 |
+
"""app/services/rag_pipeline.py — Compatibility shim.
|
| 2 |
|
| 3 |
+
This module has been refactored into the services/pipeline/ package.
|
| 4 |
+
This shim re-exports everything so existing imports continue to work
|
| 5 |
+
during the transition period.
|
| 6 |
|
| 7 |
+
DEPRECATED: Import directly from app.services.pipeline instead.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
"""
|
| 9 |
|
| 10 |
+
from app.services.pipeline import ( # noqa: F401
|
| 11 |
+
run_pipeline,
|
| 12 |
+
PipelineResult,
|
| 13 |
+
invalidate_book_cache,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|