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 CHANGED
@@ -1,1344 +1,22 @@
1
- """Author RAG Per-Author Admin API Router.
2
 
3
- All routes require a valid Author JWT (Bearer token).
4
- Routes live at: /api/admin/{author_slug}/*
5
 
6
- Per implementation plan:
7
- GET /api/admin/{slug}/dashboard — Stats summary
8
- GET /api/admin/{slug}/sessions — Reader session list
9
- GET /api/admin/{slug}/sessions/{id} — Session detail + transcript
10
- POST /api/admin/{slug}/sessions/{id}/block
11
- POST /api/admin/{slug}/sessions/{id}/unblock
12
- POST /api/admin/{slug}/sessions/{id}/reply — Live agent takeover
13
- GET /api/admin/{slug}/books — Book list
14
- POST /api/admin/{slug}/books/{id}/delete
15
- GET /api/admin/{slug}/analytics — Charts data
16
- GET /api/admin/{slug}/export/chats — Export Excel/JSON
17
- GET /api/admin/{slug}/widget-config — Get widget settings
18
- PUT /api/admin/{slug}/widget-config — Update widget settings
19
- GET /api/admin/{slug}/smart-links — Smart links (buy/preview URLs)
20
- PUT /api/admin/{slug}/smart-links/{book_id}
21
- POST /api/admin/{slug}/password — Change password
22
  """
23
 
24
- from fastapi import APIRouter, Depends, File, Query, UploadFile
25
- from sqlalchemy import Integer
26
- from sqlalchemy.ext.asyncio import AsyncSession
27
 
28
- from app.dependencies import get_db, get_current_author_scoped, get_redis
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
- @router.get("/{author_slug}/dashboard")
43
- async def dashboard(
44
- author_slug: str,
45
- current_user=Depends(get_current_author_scoped),
46
- db: AsyncSession = Depends(get_db),
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
- """Author RAG Chatbot SaaS Master RAG Pipeline (12 Steps).
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
- 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 time
26
- from dataclasses import dataclass, field
27
- from typing import AsyncGenerator
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.pyCompatibility 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
  )