AuthorBot Cursor commited on
Commit
ff0847b
Β·
1 Parent(s): c5db49d

Fix analytics accuracy, tenant isolation, and event-loop blocking.

Browse files

Correct funnel link-click aggregation, visitor fingerprint, and turn tracking; use full UUID Chroma collection names; offload CPU/DNS work to thread pool; share OpenAI client across intent/rewrite paths.

Co-authored-by: Cursor <cursoragent@cursor.com>

app/api/chat.py CHANGED
@@ -212,6 +212,9 @@ async def chat(
212
  book_id=session_ctx.selected_book_id,
213
  user_message=payload.message,
214
  result=result,
 
 
 
215
  )
216
 
217
  # Phase 5A (B8 fix): Warn author by email when token budget hits 80%.
@@ -309,6 +312,8 @@ async def stream_start(
309
  book_id=session_ctx.selected_book_id,
310
  user_message=payload.message,
311
  result=pipeline_result,
 
 
312
  )
313
 
314
  if hasattr(author, "_access_grant") and author._access_grant:
 
212
  book_id=session_ctx.selected_book_id,
213
  user_message=payload.message,
214
  result=result,
215
+ # DESIGN-5 fix: session_mgr.save() has already incremented turn_count above,
216
+ # so this is the correct completed-turn number for this event row.
217
+ turn_number=session_ctx.turn_count,
218
  )
219
 
220
  # Phase 5A (B8 fix): Warn author by email when token budget hits 80%.
 
312
  book_id=session_ctx.selected_book_id,
313
  user_message=payload.message,
314
  result=pipeline_result,
315
+ # DESIGN-5 fix: same as sync path β€” turn_count already incremented.
316
+ turn_number=session_ctx.turn_count,
317
  )
318
 
319
  if hasattr(author, "_access_grant") and author._access_grant:
app/api/ingest.py CHANGED
@@ -26,14 +26,17 @@ async def upload_book(
26
  file: UploadFile = File(...),
27
  title: str = "",
28
  genre: str = "",
29
- price: float = 0.0,
30
  buy_url: str = "",
31
  force_replace: bool = Query(False),
32
  current_user=Depends(get_current_author_scoped),
33
  db=Depends(get_db),
34
  redis=Depends(get_redis),
35
  ):
36
- """Upload a book file (PDF/EPUB/DOCX/TXT) and start async ingestion."""
 
 
 
 
37
  MAX_UPLOAD_BYTES = cfg.UPLOAD_MAX_FILE_SIZE_MB * 1024 * 1024
38
  contents = bytearray()
39
  while chunk := await file.read(64 * 1024):
 
26
  file: UploadFile = File(...),
27
  title: str = "",
28
  genre: str = "",
 
29
  buy_url: str = "",
30
  force_replace: bool = Query(False),
31
  current_user=Depends(get_current_author_scoped),
32
  db=Depends(get_db),
33
  redis=Depends(get_redis),
34
  ):
35
+ """Upload a book file (PDF/EPUB/DOCX/TXT) and start async ingestion.
36
+
37
+ MINOR-7 fix: removed 'price' param β€” start_file_upload() never accepted it
38
+ (it was silently dropped on every request).
39
+ """
40
  MAX_UPLOAD_BYTES = cfg.UPLOAD_MAX_FILE_SIZE_MB * 1024 * 1024
41
  contents = bytearray()
42
  while chunk := await file.read(64 * 1024):
app/models/analytics.py CHANGED
@@ -2,7 +2,7 @@
2
 
3
  import datetime
4
 
5
- from sqlalchemy import Boolean, Date, Float, ForeignKey, Integer, String
6
  from sqlalchemy.orm import Mapped, mapped_column
7
 
8
  from app.models.base import Base, TimestampMixin, generate_uuid
@@ -43,6 +43,12 @@ class AnalyticsDaily(Base):
43
  """Pre-aggregated daily analytics β€” populated by Celery task hourly."""
44
 
45
  __tablename__ = "analytics_daily"
 
 
 
 
 
 
46
 
47
  id: Mapped[str] = mapped_column(String(36), primary_key=True, default=generate_uuid)
48
  author_id: Mapped[str] = mapped_column(
 
2
 
3
  import datetime
4
 
5
+ from sqlalchemy import Boolean, Date, Float, ForeignKey, Integer, String, UniqueConstraint
6
  from sqlalchemy.orm import Mapped, mapped_column
7
 
8
  from app.models.base import Base, TimestampMixin, generate_uuid
 
43
  """Pre-aggregated daily analytics β€” populated by Celery task hourly."""
44
 
45
  __tablename__ = "analytics_daily"
46
+ # DESIGN-2 fix: add the unique constraint required by the ON CONFLICT upsert
47
+ # in aggregator.py. Without this, create_all creates the table without the
48
+ # constraint and the upsert silently fails, creating duplicate rows.
49
+ __table_args__ = (
50
+ UniqueConstraint("author_id", "date", name="uq_analytics_daily_author_date"),
51
+ )
52
 
53
  id: Mapped[str] = mapped_column(String(36), primary_key=True, default=generate_uuid)
54
  author_id: Mapped[str] = mapped_column(
app/services/analytics_core/aggregator.py CHANGED
@@ -49,7 +49,9 @@ async def _run_daily_rollup(target_date: date) -> dict:
49
  func.count(AnalyticsEvent.id).label("total_chats"),
50
  func.count(func.distinct(AnalyticsEvent.visitor_fingerprint)).label("unique_visitors"),
51
  func.sum(AnalyticsEvent.prompt_tokens + AnalyticsEvent.completion_tokens).label("total_tokens"),
52
- func.sum(AnalyticsEvent.link_shown.cast("int")).label("total_link_clicks"),
 
 
53
  func.avg(AnalyticsEvent.faithfulness_score).label("avg_faithfulness"),
54
  ).where(
55
  AnalyticsEvent.timestamp >= day_start,
 
49
  func.count(AnalyticsEvent.id).label("total_chats"),
50
  func.count(func.distinct(AnalyticsEvent.visitor_fingerprint)).label("unique_visitors"),
51
  func.sum(AnalyticsEvent.prompt_tokens + AnalyticsEvent.completion_tokens).label("total_tokens"),
52
+ # DESIGN-1 fix: was summing link_shown (button displayed) β€” should be
53
+ # link_clicked (button actually clicked). Fixes inflated conversion funnel.
54
+ func.sum(AnalyticsEvent.link_clicked.cast("int")).label("total_link_clicks"),
55
  func.avg(AnalyticsEvent.faithfulness_score).label("avg_faithfulness"),
56
  ).where(
57
  AnalyticsEvent.timestamp >= day_start,
app/services/analytics_core/tracker.py CHANGED
@@ -51,6 +51,7 @@ async def track_turn(
51
  book_id: str | None,
52
  user_message: str,
53
  result,
 
54
  ) -> None:
55
  """Log a chat turn to the analytics_events table.
56
 
@@ -62,9 +63,20 @@ async def track_turn(
62
  book_id: UUID of the selected book.
63
  user_message: The user's raw message (not stored β€” only metadata).
64
  result: PipelineResult from the RAG pipeline.
 
65
  """
66
  try:
67
  from datetime import datetime, timezone
 
 
 
 
 
 
 
 
 
 
68
 
69
  # Save messages to DB
70
  user_msg = ChatMessage(
@@ -99,7 +111,9 @@ async def track_turn(
99
  author_id=author_id,
100
  book_id=book_id or (result.top_book_ids[0] if result.top_book_ids else None),
101
  timestamp=datetime.now(timezone.utc),
102
- turn_number=0, # Will be updated by aggregator
 
 
103
  intent=result.intent,
104
  intent_confidence=result.intent_confidence,
105
  faithfulness_score=result.faithfulness_score,
@@ -110,7 +124,7 @@ async def track_turn(
110
  response_ms=result.response_ms,
111
  upsell_strategy=result.upsell_strategy,
112
  link_shown=result.link_shown,
113
- visitor_fingerprint="",
114
  )
115
  db.add(event)
116
 
 
51
  book_id: str | None,
52
  user_message: str,
53
  result,
54
+ turn_number: int = 0, # DESIGN-5 fix: actual completed turn count, passed by caller
55
  ) -> None:
56
  """Log a chat turn to the analytics_events table.
57
 
 
63
  book_id: UUID of the selected book.
64
  user_message: The user's raw message (not stored β€” only metadata).
65
  result: PipelineResult from the RAG pipeline.
66
+ turn_number: Completed turn count for this turn (from session_ctx.turn_count).
67
  """
68
  try:
69
  from datetime import datetime, timezone
70
+ from sqlalchemy import select
71
+ from app.models.chat_session import ChatSession
72
+
73
+ # DESIGN-4 fix: visitor_fingerprint was hardcoded to "" which made
74
+ # unique-visitor aggregation (COUNT DISTINCT visitor_fingerprint) always
75
+ # return 1. Now fetched from the ChatSession row written at session init.
76
+ fp_result = await db.execute(
77
+ select(ChatSession.visitor_fingerprint).where(ChatSession.id == session_id)
78
+ )
79
+ visitor_fingerprint = fp_result.scalar_one_or_none() or ""
80
 
81
  # Save messages to DB
82
  user_msg = ChatMessage(
 
111
  author_id=author_id,
112
  book_id=book_id or (result.top_book_ids[0] if result.top_book_ids else None),
113
  timestamp=datetime.now(timezone.utc),
114
+ # DESIGN-5 fix: use the actual completed turn number passed by the caller.
115
+ # Aggregator never updated this field (see audit DESIGN-5).
116
+ turn_number=turn_number,
117
  intent=result.intent,
118
  intent_confidence=result.intent_confidence,
119
  faithfulness_score=result.faithfulness_score,
 
124
  response_ms=result.response_ms,
125
  upsell_strategy=result.upsell_strategy,
126
  link_shown=result.link_shown,
127
+ visitor_fingerprint=visitor_fingerprint,
128
  )
129
  db.add(event)
130
 
app/services/embeddings.py CHANGED
@@ -47,10 +47,14 @@ def get_collection_name(author_id: str, book_id: str) -> str:
47
  Returns:
48
  Collection name string.
49
  """
50
- # Use first 8 chars of each UUID for brevity (still unique enough with combined key)
51
- short_author = author_id.replace("-", "")[:12]
52
- short_book = book_id.replace("-", "")[:12]
53
- return f"a{short_author}_b{short_book}"
 
 
 
 
54
 
55
 
56
  async def embed_and_store(
 
47
  Returns:
48
  Collection name string.
49
  """
50
+ # SEC-4 fix: use the full 32-char hex UUID for both segments instead of [:12]
51
+ # prefix. Truncation to 12 chars risked cross-tenant prefix collision between
52
+ # authors whose UUIDs share the same time-based prefix. Full UUID guarantees
53
+ # tenant isolation. Both creator (embed_and_store) and reader (vector_store.py)
54
+ # call this function, so one change fixes the entire naming contract.
55
+ full_author = author_id.replace("-", "")
56
+ full_book = book_id.replace("-", "")
57
+ return f"a{full_author}_b{full_book}"
58
 
59
 
60
  async def embed_and_store(
app/services/faithfulness.py CHANGED
@@ -4,8 +4,13 @@ Uses cross-encoder/nli-deberta-v3-small to verify the LLM response
4
  is grounded in the retrieved book text. Strips hallucinated claims.
5
 
6
  Per implementation plan β€” Step 9 of the RAG pipeline.
 
 
 
7
  """
8
 
 
 
9
  import structlog
10
  from sentence_transformers import CrossEncoder
11
 
@@ -50,18 +55,20 @@ async def check_faithfulness(
50
  threshold = threshold or cfg.RAG_FAITHFULNESS_THRESHOLD
51
 
52
  try:
53
- model = _get_model()
 
 
54
  context = " ".join(c.text for c in chunks[:3])[:2000]
55
 
56
- # NLI: premise = context, hypothesis = response
57
- score = model.predict([(context, response)])[0]
 
 
 
 
 
58
 
59
- # CrossEncoder NLI returns [contradiction, neutral, entailment]
60
- # score is array of 3; take entailment (index 2)
61
- if hasattr(score, "__len__") and len(score) == 3:
62
- entailment_score = float(score[2])
63
- else:
64
- entailment_score = float(score)
65
 
66
  is_faithful = entailment_score >= threshold
67
  logger.debug(
 
4
  is grounded in the retrieved book text. Strips hallucinated claims.
5
 
6
  Per implementation plan β€” Step 9 of the RAG pipeline.
7
+
8
+ BUG-5 fix: model loading and predict() are synchronous CPU-bound operations.
9
+ Both are now wrapped in asyncio.to_thread() to avoid blocking the event loop.
10
  """
11
 
12
+ import asyncio
13
+
14
  import structlog
15
  from sentence_transformers import CrossEncoder
16
 
 
55
  threshold = threshold or cfg.RAG_FAITHFULNESS_THRESHOLD
56
 
57
  try:
58
+ # BUG-5 fix: _get_model() loads a ~100MB model on first call (disk I/O),
59
+ # and predict() is CPU-bound inference β€” both block the event loop.
60
+ # asyncio.to_thread() offloads them to the thread pool executor.
61
  context = " ".join(c.text for c in chunks[:3])[:2000]
62
 
63
+ def _run_inference() -> float:
64
+ model = _get_model()
65
+ score = model.predict([(context, response)])[0]
66
+ # CrossEncoder NLI returns [contradiction, neutral, entailment]
67
+ if hasattr(score, "__len__") and len(score) == 3:
68
+ return float(score[2])
69
+ return float(score)
70
 
71
+ entailment_score = await asyncio.to_thread(_run_inference)
 
 
 
 
 
72
 
73
  is_faithful = entailment_score >= threshold
74
  logger.debug(
app/services/intent.py CHANGED
@@ -120,7 +120,10 @@ _JAILBREAK_SIGNALS: tuple[str, ...] = (
120
  "what are your instructions", "your rules", "repeat your",
121
  "repeat the above", "repeat everything",
122
  "no restrictions", "no rules", "without restrictions",
123
- "hypothetically speaking", "for a story", "in a fictional world",
 
 
 
124
  )
125
 
126
  _PIRACY_SIGNALS: tuple[str, ...] = (
@@ -350,7 +353,11 @@ async def classify_intent(
350
  )
351
 
352
  try:
353
- client = AsyncOpenAI(api_key=cfg.OPENAI_API_KEY)
 
 
 
 
354
  response = await client.chat.completions.create(
355
  model=cfg.OPENAI_CHAT_MODEL, # gpt-4o-mini β€” cheap and accurate
356
  messages=[{"role": "user", "content": prompt}],
 
120
  "what are your instructions", "your rules", "repeat your",
121
  "repeat the above", "repeat everything",
122
  "no restrictions", "no rules", "without restrictions",
123
+ # SEC-3 fix: removed "for a story" and "in a fictional world" β€” too broad,
124
+ # causing false positives on legitimate reader questions.
125
+ # The precise combined patterns in guardrails.py cover real attacks.
126
+ "hypothetically speaking",
127
  )
128
 
129
  _PIRACY_SIGNALS: tuple[str, ...] = (
 
353
  )
354
 
355
  try:
356
+ # BUG-1 fix: use the shared singleton client (same as helpers.py) instead of
357
+ # creating a new AsyncOpenAI() here β€” which spawned a new HTTP connection pool
358
+ # on every single Tier-2 intent classification call.
359
+ from app.services.pipeline.helpers import _get_openai_client
360
+ client = _get_openai_client()
361
  response = await client.chat.completions.create(
362
  model=cfg.OPENAI_CHAT_MODEL, # gpt-4o-mini β€” cheap and accurate
363
  messages=[{"role": "user", "content": prompt}],
app/services/pipeline/helpers.py CHANGED
@@ -167,7 +167,10 @@ def format_history(history: list[dict]) -> str:
167
  if not history:
168
  return "This is the start of the conversation."
169
  lines = []
170
- for msg in history[-8:]: # B6 fix: was -6 (= 3 turns), now -8 (= 4 turns)
 
 
 
171
  role = "Visitor" if msg["role"] == "user" else "You"
172
  content = sanitize_history_line(msg.get("content", ""))[:200] # cap per-line
173
  if content:
@@ -365,7 +368,10 @@ async def check_custom_qa(
365
 
366
  if best_match:
367
  best_match.match_count = (best_match.match_count or 0) + 1
368
- await db.commit()
 
 
 
369
  logger.info(
370
  "Custom Q&A match",
371
  qa_id=best_match.id,
 
167
  if not history:
168
  return "This is the start of the conversation."
169
  lines = []
170
+ # MINOR-6 fix: was hardcoded to -8 (4 turns), ignoring RAG_SESSION_HISTORY_TURNS config.
171
+ # Now uses the config value so the prompt window matches the session storage window.
172
+ max_messages = cfg.RAG_SESSION_HISTORY_TURNS * 2
173
+ for msg in history[-max_messages:]:
174
  role = "Visitor" if msg["role"] == "user" else "You"
175
  content = sanitize_history_line(msg.get("content", ""))[:200] # cap per-line
176
  if content:
 
368
 
369
  if best_match:
370
  best_match.match_count = (best_match.match_count or 0) + 1
371
+ # MINOR-5 fix: was db.commit() which permanently writes the match_count increment
372
+ # even if the pipeline fails afterwards. db.flush() keeps it in the current
373
+ # transaction so it's rolled back cleanly on any downstream exception.
374
+ await db.flush()
375
  logger.info(
376
  "Custom Q&A match",
377
  qa_id=best_match.id,
app/services/reranker.py CHANGED
@@ -4,8 +4,12 @@ Uses cross-encoder/ms-marco-MiniLM-L-6-v2 (free, local) to re-rank
4
  retrieved chunks by relevance to the original query.
5
  Significantly improves precision over cosine similarity alone.
6
  RULE: Keep top N chunks above minimum score threshold.
 
 
7
  """
8
 
 
 
9
  import structlog
10
 
11
  from app.config import get_settings
@@ -60,7 +64,9 @@ async def rerank_chunks(
60
 
61
  # Build (query, chunk) pairs for cross-encoder
62
  pairs = [(query, chunk.text) for chunk in chunks]
63
- scores = reranker.predict(pairs)
 
 
64
 
65
  # Apply scores
66
  for chunk, score in zip(chunks, scores):
 
4
  retrieved chunks by relevance to the original query.
5
  Significantly improves precision over cosine similarity alone.
6
  RULE: Keep top N chunks above minimum score threshold.
7
+
8
+ BUG-6 fix: reranker.predict() is CPU-bound sync β€” wrapped in asyncio.to_thread().
9
  """
10
 
11
+ import asyncio
12
+
13
  import structlog
14
 
15
  from app.config import get_settings
 
64
 
65
  # Build (query, chunk) pairs for cross-encoder
66
  pairs = [(query, chunk.text) for chunk in chunks]
67
+
68
+ # BUG-6 fix: predict() is synchronous CPU-bound inference β€” offload to thread pool.
69
+ scores = await asyncio.to_thread(reranker.predict, pairs)
70
 
71
  # Apply scores
72
  for chunk, score in zip(chunks, scores):
app/services/rewriter.py CHANGED
@@ -13,7 +13,6 @@ RULE: Always include original query in the returned list (first element).
13
  import re
14
 
15
  import structlog
16
- from openai import AsyncOpenAI
17
 
18
  from app.config import get_settings
19
 
@@ -257,7 +256,10 @@ async def _llm_rewrite(query: str, history: list[dict]) -> str | None:
257
  user_msg = f"History:\n{history_str}\n\nQuery: {query}"
258
 
259
  try:
260
- client = AsyncOpenAI(api_key=cfg.OPENAI_API_KEY)
 
 
 
261
  response = await client.chat.completions.create(
262
  model=cfg.OPENAI_CHAT_MODEL,
263
  messages=[
 
13
  import re
14
 
15
  import structlog
 
16
 
17
  from app.config import get_settings
18
 
 
256
  user_msg = f"History:\n{history_str}\n\nQuery: {query}"
257
 
258
  try:
259
+ # BUG-2 fix: use the shared singleton client instead of creating a new
260
+ # AsyncOpenAI() here β€” was spawning a new HTTP connection pool on every call.
261
+ from app.services.pipeline.helpers import _get_openai_client
262
+ client = _get_openai_client()
263
  response = await client.chat.completions.create(
264
  model=cfg.OPENAI_CHAT_MODEL,
265
  messages=[
app/services/superadmin_service.py CHANGED
@@ -169,7 +169,9 @@ class SuperAdminService:
169
  try:
170
  from app.core.chroma_client import get_chroma_client
171
  chroma = get_chroma_client()
172
- author_prefix = f"a{author_id.replace('-', '')[:12]}"
 
 
173
  for col in chroma.list_collections():
174
  col_name = col.name if hasattr(col, 'name') else str(col)
175
  if col_name.startswith(author_prefix):
 
169
  try:
170
  from app.core.chroma_client import get_chroma_client
171
  chroma = get_chroma_client()
172
+ # SEC-4 fix: use the full 32-char hex UUID to match get_collection_name().
173
+ # The old [:12] prefix would miss collections under the updated naming contract.
174
+ author_prefix = f"a{author_id.replace('-', '')}"
175
  for col in chroma.list_collections():
176
  col_name = col.name if hasattr(col, 'name') else str(col)
177
  if col_name.startswith(author_prefix):
app/services/vector_store.py CHANGED
@@ -134,7 +134,10 @@ async def _get_target_collections(
134
  logger.error("Failed to list ChromaDB collections", error=str(e))
135
  return []
136
 
137
- author_prefix = author_id.replace("-", "")[:12]
 
 
 
138
  author_tag = f"a{author_prefix}"
139
 
140
  targets = []
 
134
  logger.error("Failed to list ChromaDB collections", error=str(e))
135
  return []
136
 
137
+ # SEC-4 fix: use the full 32-char hex UUID instead of [:12] prefix β€”
138
+ # the prefix could collide between authors sharing similar UUID prefixes
139
+ # (common with time-based UUIDs). Full UUID guarantees tenant isolation.
140
+ author_prefix = author_id.replace("-", "")
141
  author_tag = f"a{author_prefix}"
142
 
143
  targets = []
app/tasks/link_health_task.py CHANGED
@@ -65,8 +65,12 @@ async def _check_url(client: httpx.AsyncClient, url: str) -> bool:
65
  Returns:
66
  True if URL is reachable (2xx/3xx), False otherwise.
67
  """
68
- # R-130: SSRF protection β€” block internal/private IPs before fetching
69
- if _is_internal_url(url):
 
 
 
 
70
  logger.warning("Link health: blocked internal URL", url=url[:100])
71
  return False
72
  try:
 
65
  Returns:
66
  True if URL is reachable (2xx/3xx), False otherwise.
67
  """
68
+ # R-130: SSRF protection β€” block internal/private IPs before fetching.
69
+ # BUG-7 fix: _is_internal_url() calls socket.gethostbyname() which is a
70
+ # blocking DNS call β€” offload to thread pool to avoid freezing the event loop.
71
+ import asyncio
72
+ is_internal = await asyncio.to_thread(_is_internal_url, url)
73
+ if is_internal:
74
  logger.warning("Link health: blocked internal URL", url=url[:100])
75
  return False
76
  try: