Spaces:
Runtime error
Runtime error
feat(obs): safe text chunker and AI observability logging (#9)
Browse files* feat(obs): add structured json logging and safe text chunker
* test(obs): verify chunker overlap and ai call logging
- app/chunking.py +12 -1
- app/routers/hubs.py +16 -5
- app/services/observability.py +34 -0
- tests/test_chunking.py +19 -0
- tests/test_observability.py +14 -0
app/chunking.py
CHANGED
|
@@ -1,9 +1,20 @@
|
|
| 1 |
def chunk_text(text: str, size_tokens: int = 500, overlap_tokens: int = 50) -> list[tuple[str, int]]:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
words = text.split()
|
| 3 |
chunks = []
|
|
|
|
| 4 |
i = 0
|
|
|
|
| 5 |
while i < len(words):
|
| 6 |
chunk_words = words[i:i + size_tokens]
|
| 7 |
chunks.append((" ".join(chunk_words), len(chunk_words)))
|
| 8 |
-
i +=
|
|
|
|
| 9 |
return chunks
|
|
|
|
| 1 |
def chunk_text(text: str, size_tokens: int = 500, overlap_tokens: int = 50) -> list[tuple[str, int]]:
|
| 2 |
+
"""Splits text into chunks of `size_tokens` with `overlap_tokens` overlap."""
|
| 3 |
+
if not text.strip():
|
| 4 |
+
return []
|
| 5 |
+
|
| 6 |
+
# Safety check: overlap must be strictly less than size
|
| 7 |
+
if overlap_tokens >= size_tokens:
|
| 8 |
+
overlap_tokens = size_tokens - 1
|
| 9 |
+
|
| 10 |
words = text.split()
|
| 11 |
chunks = []
|
| 12 |
+
step = size_tokens - overlap_tokens
|
| 13 |
i = 0
|
| 14 |
+
|
| 15 |
while i < len(words):
|
| 16 |
chunk_words = words[i:i + size_tokens]
|
| 17 |
chunks.append((" ".join(chunk_words), len(chunk_words)))
|
| 18 |
+
i += step
|
| 19 |
+
|
| 20 |
return chunks
|
app/routers/hubs.py
CHANGED
|
@@ -15,6 +15,7 @@ from app.idempotency import (
|
|
| 15 |
from app.chunking import chunk_text
|
| 16 |
from app.services.embeddings import embed_texts
|
| 17 |
from app.services.gemini import hub_insights
|
|
|
|
| 18 |
|
| 19 |
router = APIRouter(prefix="/hubs", tags=["hubs"])
|
| 20 |
|
|
@@ -80,8 +81,14 @@ def create_hub(
|
|
| 80 |
release_claim(db, user_id, key)
|
| 81 |
raise AppError(409, "HUB_NAME_CONFLICT", "You already have a hub with this name.")
|
| 82 |
|
| 83 |
-
# 2. AI Summary
|
| 84 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
|
| 86 |
# 3. Create Hub
|
| 87 |
new_hub = Hub(
|
|
@@ -97,7 +104,6 @@ def create_hub(
|
|
| 97 |
doc_count = 0
|
| 98 |
chunk_count = 0
|
| 99 |
|
| 100 |
-
# ---- THIS IS THE BLOCK YOU ASKED ABOUT ----
|
| 101 |
if body.content.strip():
|
| 102 |
doc_count = 1
|
| 103 |
new_doc = Document(
|
|
@@ -110,7 +116,13 @@ def create_hub(
|
|
| 110 |
try:
|
| 111 |
chunks_data = chunk_text(body.content)
|
| 112 |
texts = [c[0] for c in chunks_data]
|
| 113 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
for i, ((text, tokens), emb) in enumerate(zip(chunks_data, embeddings)):
|
| 115 |
db.add(Chunk(
|
| 116 |
document_id=new_doc.id, hub_id=new_hub.id, owner_id=user_id,
|
|
@@ -122,7 +134,6 @@ def create_hub(
|
|
| 122 |
# Graceful degradation: AI failed, but we keep the hub
|
| 123 |
new_doc.status = "failed"
|
| 124 |
new_doc.error_message = "Embedding failed"
|
| 125 |
-
# -------------------------------------------
|
| 126 |
|
| 127 |
db.commit()
|
| 128 |
db.refresh(new_hub)
|
|
|
|
| 15 |
from app.chunking import chunk_text
|
| 16 |
from app.services.embeddings import embed_texts
|
| 17 |
from app.services.gemini import hub_insights
|
| 18 |
+
from app.services.observability import log_ai_call
|
| 19 |
|
| 20 |
router = APIRouter(prefix="/hubs", tags=["hubs"])
|
| 21 |
|
|
|
|
| 81 |
release_claim(db, user_id, key)
|
| 82 |
raise AppError(409, "HUB_NAME_CONFLICT", "You already have a hub with this name.")
|
| 83 |
|
| 84 |
+
# 2. AI Summary (Wrapped in observability)
|
| 85 |
+
if body.content:
|
| 86 |
+
summary, questions = log_ai_call(
|
| 87 |
+
"hub_insights", user_id, None,
|
| 88 |
+
hub_insights, body.content
|
| 89 |
+
)
|
| 90 |
+
else:
|
| 91 |
+
summary, questions = None, []
|
| 92 |
|
| 93 |
# 3. Create Hub
|
| 94 |
new_hub = Hub(
|
|
|
|
| 104 |
doc_count = 0
|
| 105 |
chunk_count = 0
|
| 106 |
|
|
|
|
| 107 |
if body.content.strip():
|
| 108 |
doc_count = 1
|
| 109 |
new_doc = Document(
|
|
|
|
| 116 |
try:
|
| 117 |
chunks_data = chunk_text(body.content)
|
| 118 |
texts = [c[0] for c in chunks_data]
|
| 119 |
+
|
| 120 |
+
# Embeddings (Wrapped in observability)
|
| 121 |
+
embeddings = log_ai_call(
|
| 122 |
+
"embed_texts", user_id, new_hub.id,
|
| 123 |
+
embed_texts, texts
|
| 124 |
+
)
|
| 125 |
+
|
| 126 |
for i, ((text, tokens), emb) in enumerate(zip(chunks_data, embeddings)):
|
| 127 |
db.add(Chunk(
|
| 128 |
document_id=new_doc.id, hub_id=new_hub.id, owner_id=user_id,
|
|
|
|
| 134 |
# Graceful degradation: AI failed, but we keep the hub
|
| 135 |
new_doc.status = "failed"
|
| 136 |
new_doc.error_message = "Embedding failed"
|
|
|
|
| 137 |
|
| 138 |
db.commit()
|
| 139 |
db.refresh(new_hub)
|
app/services/observability.py
CHANGED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import time
|
| 2 |
+
import json
|
| 3 |
+
import logging
|
| 4 |
+
from uuid import UUID
|
| 5 |
+
from typing import Callable, Any
|
| 6 |
+
from app.services.gemini import AiUnavailable
|
| 7 |
+
|
| 8 |
+
logger = logging.getLogger("uvicorn.error")
|
| 9 |
+
|
| 10 |
+
def log_ai_call(operation: str, user_id: UUID | str, hub_id: UUID | str | None, fn: Callable, *args, **kwargs) -> Any:
|
| 11 |
+
"""Wraps an AI call, logs the duration and status, and re-raises exceptions."""
|
| 12 |
+
start = time.monotonic()
|
| 13 |
+
status = "success"
|
| 14 |
+
|
| 15 |
+
try:
|
| 16 |
+
result = fn(*args, **kwargs)
|
| 17 |
+
return result
|
| 18 |
+
except Exception as e:
|
| 19 |
+
status = "error" if not isinstance(e, AiUnavailable) else e.kind
|
| 20 |
+
raise e
|
| 21 |
+
finally:
|
| 22 |
+
duration_ms = int((time.monotonic() - start) * 1000)
|
| 23 |
+
|
| 24 |
+
log_data = {
|
| 25 |
+
"event": "ai_call",
|
| 26 |
+
"operation": operation,
|
| 27 |
+
"user_id": str(user_id),
|
| 28 |
+
"hub_id": str(hub_id) if hub_id else None,
|
| 29 |
+
"duration_ms": duration_ms,
|
| 30 |
+
"status": status,
|
| 31 |
+
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
| 32 |
+
}
|
| 33 |
+
# This prints to stdout (or Uvicorn's error logger)
|
| 34 |
+
logger.info(json.dumps(log_data))
|
tests/test_chunking.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from app.chunking import chunk_text
|
| 2 |
+
|
| 3 |
+
def test_chunk_text_basic():
|
| 4 |
+
text = "word " * 1000
|
| 5 |
+
chunks = chunk_text(text, size_tokens=500, overlap_tokens=50)
|
| 6 |
+
assert len(chunks) == 3
|
| 7 |
+
# First chunk should have 500 words
|
| 8 |
+
assert chunks[0][1] == 500
|
| 9 |
+
# Second chunk should overlap by 50 words (starts at word 450)
|
| 10 |
+
assert chunks[1][1] == 500
|
| 11 |
+
|
| 12 |
+
def test_chunk_text_infinite_loop_prevention():
|
| 13 |
+
text = "word " * 1000
|
| 14 |
+
# This would cause an infinite loop if we didn't fix the math
|
| 15 |
+
chunks = chunk_text(text, size_tokens=500, overlap_tokens=600)
|
| 16 |
+
assert len(chunks) > 0 # It survived!
|
| 17 |
+
|
| 18 |
+
def test_chunk_text_empty():
|
| 19 |
+
assert chunk_text("") == []
|
tests/test_observability.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from unittest.mock import patch
|
| 2 |
+
from app.services.observability import log_ai_call
|
| 3 |
+
|
| 4 |
+
def test_log_ai_call_success(caplog):
|
| 5 |
+
import logging
|
| 6 |
+
caplog.set_level(logging.INFO, logger="uvicorn.error")
|
| 7 |
+
|
| 8 |
+
def dummy_ai(): return "ok"
|
| 9 |
+
|
| 10 |
+
result = log_ai_call("test_op", "user1", "hub1", dummy_ai)
|
| 11 |
+
assert result == "ok"
|
| 12 |
+
assert '"event": "ai_call"' in caplog.text
|
| 13 |
+
assert '"operation": "test_op"' in caplog.text
|
| 14 |
+
assert '"status": "success"' in caplog.text
|