Hamdy005 commited on
Commit
7b90b65
·
1 Parent(s): c19fa3c

feat: implement search functionality, add background task queue logic for title conflicts, and enhance auth state synchronization

Browse files
Files changed (6) hide show
  1. auth/routes.py +0 -22
  2. main.py +4 -0
  3. materials/routes.py +98 -17
  4. rag/batch_workers.py +202 -0
  5. rag/rag.py +41 -0
  6. store.py +26 -0
auth/routes.py CHANGED
@@ -1,7 +1,6 @@
1
  import uuid
2
  from fastapi import APIRouter, HTTPException
3
  from pydantic import BaseModel
4
- from typing import Optional
5
 
6
  from src.database import get_auth_supabase
7
  from src.store import create_user, get_user_by_email
@@ -20,12 +19,6 @@ class SignupRequest(BaseModel):
20
  password: str
21
 
22
 
23
- class GoogleAuthRequest(BaseModel):
24
- token: str
25
- name: Optional[str] = None
26
- email: Optional[str] = None
27
-
28
-
29
  @router.post("/login")
30
  async def login(body: LoginRequest):
31
  supabase = get_auth_supabase()
@@ -101,18 +94,3 @@ async def signup(body: SignupRequest):
101
  "token": str(uuid.uuid4()),
102
  "user": {"id": user["id"], "name": user["name"], "email": user["email"]},
103
  }
104
-
105
-
106
- @router.post("/google")
107
- async def google_auth(body: GoogleAuthRequest):
108
- # Google OAuth would require supabase.auth.sign_in_with_id_token — not wired up yet
109
- # Fall back to dev mode: create/fetch user by email
110
- email = body.email or f"google_{uuid.uuid4().hex[:8]}@google.com"
111
- name = body.name or "Google User"
112
- user = get_user_by_email(email)
113
- if not user:
114
- user = create_user(name, email, "")
115
- return {
116
- "token": str(uuid.uuid4()),
117
- "user": {"id": user["id"], "name": user["name"], "email": user["email"]},
118
- }
 
1
  import uuid
2
  from fastapi import APIRouter, HTTPException
3
  from pydantic import BaseModel
 
4
 
5
  from src.database import get_auth_supabase
6
  from src.store import create_user, get_user_by_email
 
19
  password: str
20
 
21
 
 
 
 
 
 
 
22
  @router.post("/login")
23
  async def login(body: LoginRequest):
24
  supabase = get_auth_supabase()
 
94
  "token": str(uuid.uuid4()),
95
  "user": {"id": user["id"], "name": user["name"], "email": user["email"]},
96
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
main.py CHANGED
@@ -48,6 +48,10 @@ async def lifespan(app: FastAPI):
48
  logger.info("Embedder loaded successfully.")
49
  except Exception as e:
50
  logger.warning(f"Embedder failed to load: {e}")
 
 
 
 
51
  yield
52
 
53
 
 
48
  logger.info("Embedder loaded successfully.")
49
  except Exception as e:
50
  logger.warning(f"Embedder failed to load: {e}")
51
+
52
+ from src.rag.batch_workers import start_workers
53
+ start_workers()
54
+
55
  yield
56
 
57
 
materials/routes.py CHANGED
@@ -2,13 +2,15 @@ import time
2
  import asyncio
3
  import logging
4
  import validators
5
- from fastapi import APIRouter, UploadFile, File, HTTPException, Depends, BackgroundTasks
6
  from pydantic import BaseModel
 
7
 
8
  from src.materials.text_utils import text_from_pdf, chunk_text, scrap_website
9
- from src.rag.rag import store_embeddings
10
- from src.store import create_material, get_material, update_material_status, save_chunks, list_materials, delete_material, rename_material
11
  from src.dependencies import get_current_user_id, get_current_user
 
12
 
13
  logger = logging.getLogger(__name__)
14
 
@@ -57,29 +59,43 @@ async def get_material_by_id(
57
  raise HTTPException(404, "Material not found")
58
  return mat
59
 
60
- def _process_pdf_background(material_id: str, file_content: bytes):
61
  try:
 
 
 
 
 
 
 
62
  from io import BytesIO
63
- raw = text_from_pdf(BytesIO(file_content))
64
- chunks = chunk_text(raw)
65
- chunk_ids = save_chunks(material_id, chunks)
66
-
67
  update_material_status(material_id, "processing")
68
- store_embeddings(material_id, chunk_ids, chunks)
69
  update_material_status(material_id, "ready")
70
  logger.info(f"Background processing complete for material {material_id}")
71
  except Exception as e:
72
  logger.error(f"Background processing failed for material {material_id}: {e}", exc_info=True)
73
  update_material_status(material_id, "failed", str(e))
74
 
75
- def _process_url_background(material_id: str, url: str):
76
  try:
77
- raw = scrap_website(url)
78
- chunks = chunk_text(raw, chunk_size=600, chunk_overlap=100)
79
- chunk_ids = save_chunks(material_id, chunks)
80
-
 
 
 
 
 
 
 
81
  update_material_status(material_id, "processing")
82
- store_embeddings(material_id, chunk_ids, chunks)
83
  update_material_status(material_id, "ready")
84
  logger.info(f"Background processing complete for URL material {material_id}")
85
  except Exception as e:
@@ -134,7 +150,9 @@ async def scrape_url(
134
  )
135
  material_id = material["id"]
136
 
137
- background_tasks.add_task(_process_url_background, material_id, input.url)
 
 
138
 
139
  return {
140
  "status": "processing_started",
@@ -184,7 +202,20 @@ async def rename_material_endpoint(
184
  raise HTTPException(404, "Material not found")
185
  if mat.get("user_id") != user_id:
186
  raise HTTPException(403, "Not authorized to rename this material")
187
- await loop.run_in_executor(None, lambda: rename_material(material_id, body.title))
 
 
 
 
 
 
 
 
 
 
 
 
 
188
  return {"status": "ok"}
189
 
190
  class TopicRequest(BaseModel):
@@ -195,6 +226,8 @@ async def create_topic(
195
  body: TopicRequest,
196
  user_id: str = Depends(get_current_user_id)
197
  ):
 
 
198
  mat = create_material(
199
  user_id=user_id,
200
  title=body.topic.strip(),
@@ -203,6 +236,54 @@ async def create_topic(
203
  update_material_status(mat["id"], "ready", "Topic ready")
204
  return {"material_id": mat["id"], "title": mat["title"]}
205
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
206
  @router.delete("/{material_id}")
207
  async def delete_material_endpoint(
208
  material_id: str,
 
2
  import asyncio
3
  import logging
4
  import validators
5
+ from fastapi import APIRouter, UploadFile, File, HTTPException, Depends, BackgroundTasks, Header
6
  from pydantic import BaseModel
7
+ from postgrest.exceptions import APIError
8
 
9
  from src.materials.text_utils import text_from_pdf, chunk_text, scrap_website
10
+ from src.rag.rag import store_embeddings, store_embeddings_async
11
+ from src.store import create_material, get_material, update_material_status, save_chunks, list_materials, delete_material, rename_material, is_title_taken
12
  from src.dependencies import get_current_user_id, get_current_user
13
+ from src.database import get_supabase, get_auth_supabase
14
 
15
  logger = logging.getLogger(__name__)
16
 
 
59
  raise HTTPException(404, "Material not found")
60
  return mat
61
 
62
+ async def _process_pdf_background(material_id: str, file_content: bytes):
63
  try:
64
+ # Skip processing if this user already has a material with this title
65
+ mat = get_material(material_id)
66
+ if mat and is_title_taken(mat.get("title", ""), exclude_id=material_id, user_id=mat.get("user_id")):
67
+ logger.info(f"Skipping processing for {material_id}: duplicate title, waiting for rename")
68
+ return
69
+
70
+ loop = asyncio.get_event_loop()
71
  from io import BytesIO
72
+ raw = await loop.run_in_executor(None, text_from_pdf, BytesIO(file_content))
73
+ chunks = await loop.run_in_executor(None, chunk_text, raw)
74
+ chunk_ids = await loop.run_in_executor(None, save_chunks, material_id, chunks)
75
+
76
  update_material_status(material_id, "processing")
77
+ await store_embeddings_async(material_id, chunk_ids, chunks)
78
  update_material_status(material_id, "ready")
79
  logger.info(f"Background processing complete for material {material_id}")
80
  except Exception as e:
81
  logger.error(f"Background processing failed for material {material_id}: {e}", exc_info=True)
82
  update_material_status(material_id, "failed", str(e))
83
 
84
+ async def _process_url_background(material_id: str, url: str):
85
  try:
86
+ # Skip if this user already has a material with this title
87
+ mat = get_material(material_id)
88
+ if mat and is_title_taken(mat.get("title", ""), exclude_id=material_id, user_id=mat.get("user_id")):
89
+ logger.info(f"Skipping URL processing for {material_id}: duplicate title, waiting for rename")
90
+ return
91
+
92
+ loop = asyncio.get_event_loop()
93
+ raw = await loop.run_in_executor(None, scrap_website, url)
94
+ chunks = await loop.run_in_executor(None, lambda: chunk_text(raw, chunk_size=600, chunk_overlap=100))
95
+ chunk_ids = await loop.run_in_executor(None, save_chunks, material_id, chunks)
96
+
97
  update_material_status(material_id, "processing")
98
+ await store_embeddings_async(material_id, chunk_ids, chunks)
99
  update_material_status(material_id, "ready")
100
  logger.info(f"Background processing complete for URL material {material_id}")
101
  except Exception as e:
 
150
  )
151
  material_id = material["id"]
152
 
153
+ # Skip processing if title conflicts — user must rename first
154
+ if not is_title_taken(input.url, exclude_id=material_id, user_id=user_id):
155
+ background_tasks.add_task(_process_url_background, material_id, input.url)
156
 
157
  return {
158
  "status": "processing_started",
 
202
  raise HTTPException(404, "Material not found")
203
  if mat.get("user_id") != user_id:
204
  raise HTTPException(403, "Not authorized to rename this material")
205
+ new_title = body.title.strip()
206
+ if not new_title:
207
+ raise HTTPException(400, "Title cannot be empty")
208
+ existing = await loop.run_in_executor(None, lambda: list_materials(user_id))
209
+ if any(m.get("id") != material_id and m.get("title", "").strip().lower() == new_title.lower() for m in existing):
210
+ raise HTTPException(409, "A material with this title already exists")
211
+ await loop.run_in_executor(None, lambda: rename_material(material_id, new_title))
212
+
213
+ # If the material was pending due to title conflict, try processing now
214
+ if mat.get("source_type") == "url" and mat.get("status") == "pending":
215
+ url = mat.get("url")
216
+ if url and not is_title_taken(new_title, exclude_id=material_id, user_id=user_id):
217
+ asyncio.ensure_future(_process_url_background(material_id, url))
218
+
219
  return {"status": "ok"}
220
 
221
  class TopicRequest(BaseModel):
 
226
  body: TopicRequest,
227
  user_id: str = Depends(get_current_user_id)
228
  ):
229
+ if is_title_taken(body.topic, user_id=user_id):
230
+ raise HTTPException(409, "A material with this title already exists")
231
  mat = create_material(
232
  user_id=user_id,
233
  title=body.topic.strip(),
 
236
  update_material_status(mat["id"], "ready", "Topic ready")
237
  return {"material_id": mat["id"], "title": mat["title"]}
238
 
239
+
240
+ class SearchQuery(BaseModel):
241
+ q: str
242
+
243
+ @router.post("/search")
244
+ async def search_materials(
245
+ body: SearchQuery,
246
+ authorization: str = Header(None),
247
+ ):
248
+ if not body.q.strip():
249
+ return {"results": []}
250
+
251
+ anon_client = get_auth_supabase()
252
+ if anon_client is None:
253
+ return {"results": []}
254
+
255
+ if not authorization:
256
+ raise HTTPException(401, "Missing authorization token")
257
+
258
+ token = authorization.strip()
259
+ if token.lower().startswith("bearer "):
260
+ token = token[7:].strip()
261
+ if not token:
262
+ raise HTTPException(401, "Missing authorization token")
263
+
264
+ anon_client.postgrest.auth(token)
265
+
266
+ try:
267
+ result = anon_client.rpc(
268
+ "search_materials_by_title",
269
+ {"p_query": body.q.strip()},
270
+ ).execute()
271
+ except APIError as e:
272
+ payload = e.args[0] if e.args else None
273
+ message = None
274
+ code = None
275
+ status = getattr(e, "status_code", None)
276
+ if isinstance(payload, dict):
277
+ message = payload.get("message")
278
+ code = payload.get("code")
279
+ status = payload.get("status") or status
280
+ message_text = (message or str(e) or "").lower()
281
+ if status == 401 or code == "PGRST303" or "jwt expired" in message_text or "unauthorized" in message_text:
282
+ raise HTTPException(401, "Session expired. Please sign in again.")
283
+ raise HTTPException(500, f"Search failed: {message or 'Unknown error'}")
284
+
285
+ return {"results": [row["material_id"] for row in (result.data or [])]}
286
+
287
  @router.delete("/{material_id}")
288
  async def delete_material_endpoint(
289
  material_id: str,
rag/batch_workers.py ADDED
@@ -0,0 +1,202 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Embedding Batch Workers — Async batching infrastructure for embedding inference.
3
+
4
+ Architecture:
5
+ - Single asyncio.Queue for all embedding jobs.
6
+ - Dedicated async worker coroutines drain the queue in micro-batches.
7
+ - Workers offload heavy inference to a thread via run_in_executor.
8
+ - A shared in-memory job_store dict tracks job status + results.
9
+ - Warmup loop periodically does a dummy forward pass to keep OpenMP threads alive.
10
+ """
11
+
12
+ import asyncio
13
+ import time
14
+ import uuid
15
+ import logging
16
+ from dataclasses import dataclass, field
17
+ from typing import Any
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ # ═══════════════════════ Job Store ════════════════════════
23
+
24
+ job_store: dict[str, dict[str, Any]] = {}
25
+ """
26
+ {
27
+ "<job_id>": {
28
+ "status": "pending" | "processing" | "done" | "error",
29
+ "result": <list[list[float]] for EmbeddingJob> | None,
30
+ "error": <str> | None,
31
+ }
32
+ }
33
+ """
34
+
35
+
36
+ def create_job() -> str:
37
+ """Create a new pending job and return its ID."""
38
+ job_id = str(uuid.uuid4())
39
+ job_store[job_id] = {"status": "pending", "result": None, "error": None}
40
+ return job_id
41
+
42
+
43
+ # ═══════════════════════ Request-in-Flight Gate ════════════════════════
44
+
45
+ _request_in_flight_count = 0
46
+
47
+
48
+ def set_request_in_flight(active: bool):
49
+ """Increment/decrement in-flight counter. Thread-safe enough for a gate."""
50
+ global _request_in_flight_count
51
+ if active:
52
+ _request_in_flight_count += 1
53
+ else:
54
+ _request_in_flight_count = max(0, _request_in_flight_count - 1)
55
+
56
+
57
+ def is_request_in_flight() -> bool:
58
+ return _request_in_flight_count > 0
59
+
60
+
61
+ # ═══════════════════════ Job Dataclasses ════════════════════════
62
+
63
+ @dataclass
64
+ class EmbeddingJob:
65
+ """Batch embedding of multiple texts (for store_embeddings)."""
66
+ job_id: str
67
+ texts: list[str]
68
+ done: asyncio.Event = field(default_factory=asyncio.Event)
69
+
70
+
71
+ # ═══════════════════════ Queue ════════════════════════
72
+
73
+ embedding_queue: asyncio.Queue[EmbeddingJob] = asyncio.Queue()
74
+
75
+
76
+ # ═══════════════════════ Workers ════════════════════════
77
+
78
+ _BATCH_MAX_SIZE = 8
79
+ _BATCH_WINDOW_S = 0.05
80
+
81
+
82
+ async def embedding_worker():
83
+ """
84
+ Drains up to {_BATCH_MAX_SIZE} embedding jobs every {_BATCH_WINDOW_S * 1000:.0f}ms.
85
+
86
+ One SentenceTransformer forward pass per batch:
87
+ 1. Collect texts from all jobs in the batch
88
+ 2. get_embedder().embed_documents(all_texts) → raw [B, D] embeddings
89
+ 3. Distribute results back to individual jobs
90
+
91
+ Results are written into job_store and each job's done Event is set.
92
+ """
93
+ from src.rag.rag import get_embedder
94
+
95
+ loop = asyncio.get_event_loop()
96
+
97
+ while True:
98
+ # Wait for at least one job
99
+ first_job: EmbeddingJob = await embedding_queue.get()
100
+ batch: list[EmbeddingJob] = [first_job]
101
+
102
+ # Collect up to 7 more within the time window
103
+ deadline = loop.time() + _BATCH_WINDOW_S
104
+ while len(batch) < _BATCH_MAX_SIZE:
105
+ remaining = deadline - loop.time()
106
+ if remaining <= 0:
107
+ break
108
+ try:
109
+ job = await asyncio.wait_for(embedding_queue.get(), timeout=remaining)
110
+ batch.append(job)
111
+ except asyncio.TimeoutError:
112
+ break
113
+
114
+ try:
115
+ set_request_in_flight(True)
116
+
117
+ # Gather all texts from all jobs in the batch
118
+ all_texts: list[str] = []
119
+ text_counts: list[int] = []
120
+ for job in batch:
121
+ all_texts.extend(job.texts)
122
+ text_counts.append(len(job.texts))
123
+
124
+ # Single forward pass for the entire batch
125
+ embedder = get_embedder()
126
+ all_embeddings = await loop.run_in_executor(
127
+ None, embedder.embed_documents, all_texts
128
+ )
129
+
130
+ # Distribute results back to individual jobs
131
+ idx = 0
132
+ for i, job in enumerate(batch):
133
+ n = text_counts[i]
134
+ job_result = all_embeddings[idx: idx + n]
135
+ idx += n
136
+
137
+ job_store[job.job_id]["status"] = "done"
138
+ job_store[job.job_id]["result"] = job_result
139
+ job.done.set()
140
+
141
+ except Exception as e:
142
+ logger.error(f"Embedding batch failed: {e}", exc_info=True)
143
+ for job in batch:
144
+ job_store[job.job_id]["status"] = "error"
145
+ job_store[job.job_id]["error"] = str(e)
146
+ job.done.set()
147
+ finally:
148
+ set_request_in_flight(False)
149
+
150
+
151
+ # ═══════════════════════ Warmup Loop ════════════════════════
152
+
153
+ _WARMUP_INTERVAL_S = 45
154
+
155
+
156
+ async def _warmup_loop():
157
+ """
158
+ Periodically does a dummy forward pass to prevent OpenMP/MKL thread pool
159
+ spin-down during idle periods.
160
+
161
+ Skipped entirely if a real request is in flight.
162
+ """
163
+ from src.rag.rag import warmup_embedder
164
+
165
+ loop = asyncio.get_event_loop()
166
+
167
+ while True:
168
+ await asyncio.sleep(_WARMUP_INTERVAL_S)
169
+ if is_request_in_flight():
170
+ continue
171
+ t0 = time.monotonic()
172
+ try:
173
+ await loop.run_in_executor(None, warmup_embedder)
174
+ except Exception as e:
175
+ logger.warning(f"Warmup cycle error (non-fatal): {e}")
176
+ continue
177
+ elapsed_ms = (time.monotonic() - t0) * 1000
178
+ logger.info(f"Warmup cycle done ({elapsed_ms:.0f}ms)")
179
+
180
+
181
+ # ═══════════════════════ Startup ════════════════════════
182
+
183
+ _workers_started = False
184
+
185
+
186
+ def start_workers():
187
+ """
188
+ Launch all async worker coroutines. Call once during app startup.
189
+
190
+ - 2 embedding workers (batched SentenceTransformer inference)
191
+ - 1 warmup loop (keeps OpenMP threads alive)
192
+ """
193
+ global _workers_started
194
+ if _workers_started:
195
+ return
196
+ _workers_started = True
197
+
198
+ for i in range(2):
199
+ asyncio.create_task(embedding_worker(), name=f"embedding_worker_{i}")
200
+ asyncio.create_task(_warmup_loop(), name="warmup_loop")
201
+
202
+ logger.info("Embedding batch workers started (2 workers + warmup loop)")
rag/rag.py CHANGED
@@ -1,4 +1,6 @@
1
  import os
 
 
2
  import logging
3
  from functools import lru_cache
4
  from typing import Optional
@@ -55,6 +57,45 @@ def store_embeddings(material_id: str, chunk_ids: list[str], chunks: list[str]):
55
  logger.info(f"Embeddings stored successfully for material {material_id}.")
56
 
57
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58
  def similarity_search(query: str, material_id: str, k: int = 5) -> list[dict]:
59
  embedder = get_embedder()
60
  query_embedding = embedder.embed_query(query)
 
1
  import os
2
+ import asyncio
3
+ import uuid
4
  import logging
5
  from functools import lru_cache
6
  from typing import Optional
 
57
  logger.info(f"Embeddings stored successfully for material {material_id}.")
58
 
59
 
60
+ def warmup_embedder():
61
+ """Dummy forward pass to keep OpenMP/MKL thread pool alive during idle periods."""
62
+ embedder = get_embedder()
63
+ embedder.embed_documents(["warmup"])
64
+
65
+
66
+ async def store_embeddings_async(material_id: str, chunk_ids: list[str], chunks: list[str]):
67
+ """
68
+ Async variant of store_embeddings that routes embedding inference through
69
+ the batch worker queue for batching across concurrent requests.
70
+ """
71
+ from src.rag.batch_workers import EmbeddingJob, embedding_queue, job_store
72
+
73
+ job = EmbeddingJob(job_id=str(uuid.uuid4()), texts=chunks)
74
+ await embedding_queue.put(job)
75
+ await job.done.wait()
76
+
77
+ entry = job_store[job.job_id]
78
+ if entry["status"] == "error":
79
+ raise RuntimeError(f"Embedding failed: {entry['error']}")
80
+
81
+ embeddings = entry["result"]
82
+
83
+ records = [
84
+ {"chunk_id": cid, "material_id": material_id, "embedding": emb}
85
+ for cid, emb in zip(chunk_ids, embeddings)
86
+ ]
87
+
88
+ db = get_supabase()
89
+ if db is None:
90
+ logger.warning("Supabase not connected — embeddings computed but NOT stored (no DB).")
91
+ return
92
+
93
+ logger.info(f"Storing {len(records)} embeddings in Supabase for material {material_id}...")
94
+ for i in range(0, len(records), 50):
95
+ db.table("material_embeddings").insert(records[i:i + 50]).execute()
96
+ logger.info(f"Embeddings stored successfully for material {material_id}.")
97
+
98
+
99
  def similarity_search(query: str, material_id: str, k: int = 5) -> list[dict]:
100
  embedder = get_embedder()
101
  query_embedding = embedder.embed_query(query)
store.py CHANGED
@@ -133,6 +133,32 @@ def list_materials(user_id: str) -> list[dict]:
133
  return list(reversed([r for r in records if r.get("user_id") == user_id]))
134
 
135
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
136
  def create_material(user_id: str, source_type: str, title: str,
137
  file_path: Optional[str] = None,
138
  url: Optional[str] = None,
 
133
  return list(reversed([r for r in records if r.get("user_id") == user_id]))
134
 
135
 
136
+ def is_title_taken(title: str, exclude_id: Optional[str] = None, user_id: Optional[str] = None) -> bool:
137
+ normalized = title.strip().lower()
138
+ if not normalized:
139
+ return False
140
+ try:
141
+ query = _table_supabase("materials").select("id,title")
142
+ if user_id:
143
+ query = query.eq("user_id", user_id)
144
+ result = _robust_execute(query)
145
+ for row in result.data:
146
+ if exclude_id and row.get("id") == exclude_id:
147
+ continue
148
+ if row.get("title", "").strip().lower() == normalized:
149
+ return True
150
+ except Exception:
151
+ pass
152
+ for row in _in_memory.get("materials", {}).values():
153
+ if exclude_id and row.get("id") == exclude_id:
154
+ continue
155
+ if user_id and row.get("user_id") != user_id:
156
+ continue
157
+ if row.get("title", "").strip().lower() == normalized:
158
+ return True
159
+ return False
160
+
161
+
162
  def create_material(user_id: str, source_type: str, title: str,
163
  file_path: Optional[str] = None,
164
  url: Optional[str] = None,