Spaces:
Running
Running
AuthorBot Cursor commited on
Commit Β·
ff0847b
1
Parent(s): c5db49d
Fix analytics accuracy, tenant isolation, and event-loop blocking.
Browse filesCorrect 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 +5 -0
- app/api/ingest.py +5 -2
- app/models/analytics.py +7 -1
- app/services/analytics_core/aggregator.py +3 -1
- app/services/analytics_core/tracker.py +16 -2
- app/services/embeddings.py +8 -4
- app/services/faithfulness.py +16 -9
- app/services/intent.py +9 -2
- app/services/pipeline/helpers.py +8 -2
- app/services/reranker.py +7 -1
- app/services/rewriter.py +4 -2
- app/services/superadmin_service.py +3 -1
- app/services/vector_store.py +4 -1
- app/tasks/link_health_task.py +6 -2
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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
#
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 54 |
context = " ".join(c.text for c in chunks[:3])[:2000]
|
| 55 |
|
| 56 |
-
|
| 57 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
|
| 59 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 124 |
)
|
| 125 |
|
| 126 |
_PIRACY_SIGNALS: tuple[str, ...] = (
|
|
@@ -350,7 +353,11 @@ async def classify_intent(
|
|
| 350 |
)
|
| 351 |
|
| 352 |
try:
|
| 353 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|