RamshaAnwar commited on
Commit
da24e62
·
unverified ·
1 Parent(s): b07d245

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 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 += size_tokens - overlap_tokens
 
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
- summary, questions = hub_insights(body.content) if body.content else (None, [])
 
 
 
 
 
 
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
- embeddings = embed_texts(texts)
 
 
 
 
 
 
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