Coverage for src/lilbee/server/handlers/ingest.py: 100%

166 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-09-17 10:02 +0000

1"""Sync and add-files handlers (SSE-streamed).""" 

2 

3from __future__ import annotations 

4 

5import asyncio 

6import contextlib 

7import hashlib 

8import logging 

9import os 

10import re 

11from collections.abc import AsyncGenerator, Callable, Coroutine 

12from pathlib import Path 

13from typing import TYPE_CHECKING, Any, TypeVar 

14 

15from lilbee.app.ingest import register_sources 

16from lilbee.app.services import get_services 

17from lilbee.core.config import cfg 

18from lilbee.core.security import validate_path_within 

19from lilbee.data.ingest.discovery import excluded_extension_reasons 

20from lilbee.runtime.ingest_lock import IngestLockRegistry 

21from lilbee.runtime.progress import SseEvent 

22from lilbee.server.handlers.sse import SseStream, sse_event 

23from lilbee.server.models import AddSummary, SyncSummary 

24 

25if TYPE_CHECKING: 

26 from lilbee.app.dataset import ImportSummary 

27 from lilbee.data.ingest import SyncResult 

28 

29log = logging.getLogger(__name__) 

30 

31# Payload carried alongside each source key through _ingest_stream: a server 

32# path (str) for /api/add, or an (filename, content) pair for /api/add/upload. 

33_Payload = TypeVar("_Payload") 

34 

35 

36async def _run_sync_with_sentinel( 

37 sse: SseStream, 

38 enable_ocr: bool | None, 

39 force_rebuild: bool = False, 

40 retry_skipped: bool = False, 

41 prune_ignored: bool = False, 

42) -> SyncResult: 

43 """Run ingest.sync() and guarantee the drain sentinel is enqueued.""" 

44 from lilbee.app.ingest import temporary_ocr_config 

45 from lilbee.data.ingest import sync 

46 

47 try: 

48 with temporary_ocr_config(enable_ocr): 

49 return await sync( 

50 quiet=True, 

51 on_progress=sse.callback, 

52 cancel=sse.cancel, 

53 force_rebuild=force_rebuild, 

54 retry_skipped=retry_skipped, 

55 prune_ignored=prune_ignored, 

56 ) 

57 finally: 

58 sse.queue.put_nowait(None) 

59 

60 

61async def sync_stream( 

62 *, 

63 enable_ocr: bool | None = None, 

64 force_rebuild: bool = False, 

65 retry_skipped: bool = False, 

66 prune_ignored: bool = False, 

67) -> AsyncGenerator[str, None]: 

68 """Trigger sync, yield SSE progress events, then done event. 

69 

70 When ``force_rebuild`` is true, the underlying sync drops every table and 

71 re-ingests from ``cfg.documents_dir`` (the REST equivalent of ``lilbee rebuild``). 

72 When ``retry_skipped`` is true, it clears the failed-file markers so files 

73 that were skipped on a previous sync get another attempt, without dropping 

74 the store. When ``prune_ignored`` is true, it also drops sources a 

75 ``.lilbeeignore`` now excludes. 

76 """ 

77 sse = SseStream() 

78 task = asyncio.create_task( 

79 _run_sync_with_sentinel(sse, enable_ocr, force_rebuild, retry_skipped, prune_ignored) 

80 ) 

81 async for event in sse.drain(task, "Sync stream"): 

82 yield event 

83 frame = sse.terminal_frame(task, lambda result: result.model_dump()) 

84 if frame is not None: 

85 yield frame 

86 

87 

88async def _run_add( 

89 paths: list[str], 

90 force: bool, 

91 enable_ocr: bool | None, 

92 ocr_timeout: float | None, 

93 sse: SseStream, 

94) -> AddSummary: 

95 """Copy files and sync, returning the summary for the final done event.""" 

96 from lilbee.app.ingest import temporary_ocr_config 

97 from lilbee.data.ingest import sync 

98 

99 try: 

100 errors: list[str] = [] 

101 valid: list[Path] = [] 

102 for p_str in paths: 

103 p = Path(p_str) 

104 if not p.exists(): 

105 errors.append(p_str) 

106 else: 

107 valid.append(p) 

108 

109 reg_result = register_sources(valid, force=force) 

110 

111 errors.extend(reg_result.refused) 

112 if sse.cancel.is_set(): 

113 return AddSummary( 

114 copied=reg_result.registered, 

115 name_taken=reg_result.name_taken, 

116 overlapping=reg_result.overlapping, 

117 tracked=reg_result.tracked, 

118 errors=errors, 

119 ) 

120 

121 if not reg_result.reached_corpus: 

122 return AddSummary(copied=[], name_taken=reg_result.name_taken, errors=errors) 

123 

124 with temporary_ocr_config(enable_ocr, ocr_timeout): 

125 sync_result = await sync(quiet=True, on_progress=sse.callback, cancel=sse.cancel) 

126 

127 return AddSummary( 

128 copied=reg_result.registered, 

129 name_taken=reg_result.name_taken, 

130 overlapping=reg_result.overlapping, 

131 tracked=reg_result.tracked, 

132 errors=errors, 

133 sync=SyncSummary(**sync_result.model_dump()), 

134 ) 

135 finally: 

136 sse.queue.put_nowait(None) 

137 

138 

139def validate_add_paths( 

140 data: dict[str, Any], 

141) -> tuple[list[str], bool, bool | None, float | None]: 

142 """Validate add-files input. Raises ValueError on bad input.""" 

143 paths = data.get("paths") 

144 if not isinstance(paths, list) or not paths: 

145 raise ValueError("'paths' must be a non-empty list of strings") 

146 # No file-count cap: the resource guard is the app's size-based 

147 # request_max_body_size; a count limit only breaks the point-lilbee-at- 

148 # your-codebase use case (hundreds of small files). 

149 

150 for p_str in paths: 

151 # The basename becomes the source root's label (its key prefix). It must 

152 # be a clean single segment: Path(x).name cannot traverse, so this only 

153 # rejects an empty name ("/", "a/") that would name no root. 

154 name = Path(p_str).name 

155 if not name: 

156 raise ValueError(f"{p_str!r} does not name a file") 

157 validate_path_within(cfg.documents_dir / name, cfg.documents_dir) 

158 

159 force = bool(data.get("force", False)) 

160 enable_ocr, ocr_timeout = _parse_ocr_params(data) 

161 return paths, force, enable_ocr, ocr_timeout 

162 

163 

164def _parse_ocr_params(data: dict[str, Any]) -> tuple[bool | None, float | None]: 

165 """Extract and coerce OCR parameters from a request dict.""" 

166 enable_ocr = data.get("enable_ocr") 

167 ocr_timeout = data.get("ocr_timeout") 

168 if enable_ocr is not None: 

169 enable_ocr = bool(enable_ocr) 

170 if ocr_timeout is not None: 

171 ocr_timeout = float(ocr_timeout) 

172 return enable_ocr, ocr_timeout 

173 

174 

175async def _ingest_stream( 

176 items: list[tuple[str, _Payload]], 

177 run: Callable[[list[_Payload], SseStream], Coroutine[Any, Any, AddSummary]], 

178 label: str, 

179) -> AsyncGenerator[str, None]: 

180 """Lock per source, run ``run`` over the acquired subset, and stream SSE. 

181 

182 Shared by /api/add (server paths) and /api/add/upload (uploaded content). 

183 Each item is ``(lock_key, payload)``: the key is the source identifier used 

184 for the per-source ingest lock; the payload is what ``run`` receives for the 

185 subset whose lock was acquired. Contended sources emit ``already_ingesting``. 

186 When every source is contended the stream closes with no ``done`` event, 

187 signalling the client to wait rather than retry. When only some are, the 

188 acquired subset still runs and the ``done`` summary names the contended ones 

189 in ``already_ingesting``, so a partial batch never reads as a full success. 

190 """ 

191 registry = get_services().ingest_lock_registry 

192 acquired, busy = await registry.acquire([key for key, _payload in items]) 

193 try: 

194 for name in busy: 

195 log.info("Rejecting %s for %s: already ingesting", label, name) 

196 yield sse_event(SseEvent.ALREADY_INGESTING, {"source": name}) 

197 

198 if not acquired: 

199 return 

200 

201 acquired_names = {name for name, _lock in acquired} 

202 locked = [payload for key, payload in items if key in acquired_names] 

203 sse = SseStream() 

204 task = asyncio.create_task(run(locked, sse)) 

205 try: 

206 async for event in sse.drain(task, label): 

207 yield event 

208 # already_ingesting names the sources this run never attempted, so 

209 # a client reading only the terminal event still sees a partial batch. 

210 frame = sse.terminal_frame( 

211 task, lambda s: s.model_copy(update={"already_ingesting": list(busy)}).model_dump() 

212 ) 

213 if frame is not None: 

214 yield frame 

215 finally: 

216 if not task.done(): 

217 task.cancel() 

218 with contextlib.suppress(asyncio.CancelledError, Exception): 

219 await task 

220 finally: 

221 registry.release(acquired) 

222 

223 

224async def add_files_stream( 

225 paths: list[str], 

226 *, 

227 force: bool = False, 

228 enable_ocr: bool | None = None, 

229 ocr_timeout: float | None = None, 

230) -> AsyncGenerator[str, None]: 

231 """Copy server-side files, sync, and yield SSE progress events. 

232 

233 Takes the already-validated/parsed values from ``validate_add_paths`` so the 

234 request dict is decoded once. 

235 """ 

236 async for event in _ingest_stream( 

237 [(IngestLockRegistry.canonical_source_name(p), p) for p in paths], 

238 lambda locked, sse: _run_add(locked, force, enable_ocr, ocr_timeout, sse), 

239 "Add files stream", 

240 ): 

241 yield event 

242 

243 

244def _clean_upload_name(name: str) -> str: 

245 """Normalize one upload filename to a safe relative path inside the corpus. 

246 

247 Relative paths are preserved (a source tree keeps its layout instead of 

248 colliding on basenames); absolute paths, drive letters, and ``..`` segments 

249 are rejected. Raises ValueError on bad input. 

250 """ 

251 normalized = name.replace("\\", "/") 

252 if normalized.startswith("/") or re.match(r"^[A-Za-z]:", normalized): 

253 raise ValueError(f"upload filename must be relative: {name!r}") 

254 parts = [part for part in normalized.split("/") if part not in ("", ".")] 

255 if not parts: 

256 raise ValueError(f"invalid upload filename: {name!r}") 

257 if ".." in parts: 

258 raise ValueError(f"upload filename may not contain '..': {name!r}") 

259 relative = "/".join(parts) 

260 reason = excluded_extension_reasons().get(Path(relative).suffix.lower()) 

261 if reason is not None: 

262 raise ValueError(f"{name!r}: {reason}") 

263 validate_path_within(cfg.documents_dir / relative, cfg.documents_dir) 

264 return relative 

265 

266 

267def validate_upload_names(names: list[str | None]) -> list[str]: 

268 """Validate uploaded filenames, returning the cleaned relative paths. 

269 

270 Names only, deliberately: the route validates before reading any part's 

271 bytes, so a request that will be rejected never costs a full copy of the 

272 payload in the server's own memory. Filenames keep their relative path, 

273 validated to stay inside ``cfg.documents_dir``, so an uploaded source tree 

274 preserves its layout instead of colliding on basenames. There is no 

275 file-count cap; the resource guard is the app's request_max_body_size. 

276 

277 Raises ValueError on bad input. 

278 """ 

279 if not names: 

280 raise ValueError("no files uploaded") 

281 # A multipart part is allowed to carry no filename at all; that is not a 

282 # file upload, and the cleaner's message should name it as missing rather 

283 # than crash on None. 

284 return [_clean_upload_name(name if name is not None else "") for name in names] 

285 

286 

287async def _run_upload(files: list[tuple[str, bytes]], sse: SseStream) -> AddSummary: 

288 """Write uploaded file bytes into ``cfg.documents_dir``, then sync. 

289 

290 The upload equivalent of :func:`_run_add`: instead of copying from a 

291 server-readable path, the client's content is written straight into the 

292 documents dir and the same ingest pipeline runs. This is what lets an 

293 external-mode client (a remote lilbee / GPU box) ingest files that live only 

294 on the client. Unchanged content is a no-op re-embed inside ``sync`` (it 

295 hashes each source), so there is no separate force flag. 

296 """ 

297 from lilbee.app.ingest import temporary_ocr_config 

298 from lilbee.data.ingest import sync 

299 

300 try: 

301 cfg.documents_dir.mkdir(parents=True, exist_ok=True) 

302 written: list[str] = [] 

303 for name, content in files: 

304 dest = cfg.documents_dir / name 

305 dest.parent.mkdir(parents=True, exist_ok=True) 

306 if not _move_same_content(name, content, dest): 

307 dest.write_bytes(content) 

308 written.append(name) 

309 with temporary_ocr_config(None): 

310 sync_result = await sync(quiet=True, on_progress=sse.callback, cancel=sse.cancel) 

311 return AddSummary( 

312 copied=written, 

313 errors=[], 

314 sync=SyncSummary(**sync_result.model_dump()), 

315 ) 

316 finally: 

317 sse.queue.put_nowait(None) 

318 

319 

320def _move_same_content(name: str, content: bytes, dest: Path) -> bool: 

321 """Move the one indexed file holding *content* to *dest*, so sync repoints its key.""" 

322 digest = hashlib.sha256(content).hexdigest() 

323 matches = [ 

324 s["filename"] 

325 for s in get_services().store.get_sources() 

326 if s["file_hash"] == digest and s["filename"] != name 

327 ] 

328 if len(matches) != 1: 

329 return False 

330 old_path = cfg.documents_dir / matches[0] 

331 if not old_path.is_file(): 

332 return False 

333 os.replace(old_path, dest) 

334 log.info("Moved %s to %s: the same content arrived under a new name", matches[0], name) 

335 return True 

336 

337 

338async def add_uploads_stream(files: list[tuple[str, bytes]]) -> AsyncGenerator[str, None]: 

339 """Ingest uploaded file content, yielding the same SSE progress as add_files_stream. 

340 

341 Locks per source name (the validated relative path) so an upload never 

342 races an in-flight add of the same source. 

343 """ 

344 async for event in _ingest_stream( 

345 [(name, (name, content)) for name, content in files], 

346 lambda locked, sse: _run_upload(locked, sse), 

347 "Add uploads stream", 

348 ): 

349 yield event 

350 

351 

352async def _run_import_with_sentinel(sse: SseStream, data: bytes, fmt: str) -> ImportSummary: 

353 """Run the dataset import and guarantee the drain sentinel is enqueued.""" 

354 from lilbee.app.dataset import import_from_bytes 

355 

356 try: 

357 return await import_from_bytes(data, fmt, on_progress=sse.callback) 

358 finally: 

359 sse.queue.put_nowait(None) 

360 

361 

362async def import_stream(data: bytes, fmt: str) -> AsyncGenerator[str, None]: 

363 """Import a dataset, yield SSE embed-progress events, then a done event.""" 

364 sse = SseStream() 

365 task = asyncio.create_task(_run_import_with_sentinel(sse, data, fmt)) 

366 async for event in sse.drain(task, "Import stream"): 

367 yield event 

368 frame = sse.terminal_frame(task, lambda result: result.model_dump()) 

369 if frame is not None: 

370 yield frame