Coverage for /home/admin/Documents/AI/applications/lexigram-dev/lexigram/experimental/ai/lexigram-ai-workers/src/lexigram/ai/workers/document_ingestion/processor.py: 24%

96 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-25 07:19 +0800

1""" 

2Core document ingestion logic. 

3 

4Contains the main ingestion processing functionality. 

5""" 

6 

7from __future__ import annotations 

8 

9import asyncio 

10from dataclasses import dataclass 

11from pathlib import Path 

12from typing import TYPE_CHECKING, Any 

13 

14from lexigram.ai.workers.document_ingestion.parser import ( 

15 DocumentParser, 

16 UniversalDocumentParser, 

17) 

18from lexigram.ai.workers.document_ingestion.types import ( 

19 ChunkingConfigDict, 

20 Document, 

21 IngestionResult, 

22 IngestionStatus, 

23) 

24from lexigram.logging import ( 

25 get_logger, 

26) 

27 

28if TYPE_CHECKING: 

29 from lexigram.ai.workers.document_ingestion.progress import ProgressTracker 

30 from lexigram.contracts import VectorStoreProtocol 

31 from lexigram.contracts.ai.rag import ChunkProtocol 

32 from lexigram.contracts.ai.vector import ChunkerProtocol 

33 

34logger = get_logger(__name__) 

35 

36 

37@dataclass(slots=True) 

38class _ChunkRecord: 

39 """Minimal chunk structure used by the ingestion processor.""" 

40 

41 text: str 

42 metadata: dict[str, Any] 

43 score: float | None = None 

44 

45 

46class _SimpleChunker: 

47 """Fallback chunker used when no chunker implementation is injected.""" 

48 

49 def __init__(self, chunk_size: int = 1000, overlap: int = 200) -> None: 

50 normalized_chunk_size = max(1, chunk_size) 

51 normalized_overlap = max(0, overlap) 

52 self._chunk_size = normalized_chunk_size 

53 self._overlap = min(normalized_overlap, normalized_chunk_size - 1) 

54 

55 def chunk( 

56 self, 

57 text: str, 

58 metadata: dict[str, Any] | None = None, 

59 ) -> list[_ChunkRecord]: 

60 if not text: 

61 return [] 

62 

63 step = max(1, self._chunk_size - self._overlap) 

64 chunks: list[_ChunkRecord] = [] 

65 index = 0 

66 

67 for start in range(0, len(text), step): 

68 end = min(len(text), start + self._chunk_size) 

69 chunk_text = text[start:end] 

70 

71 if chunk_text.strip(): 

72 chunk_metadata = dict(metadata or {}) 

73 chunk_metadata.update( 

74 { 

75 "chunk_index": index, 

76 "start_index": start, 

77 "end_index": end, 

78 } 

79 ) 

80 chunks.append(_ChunkRecord(text=chunk_text, metadata=chunk_metadata)) 

81 index += 1 

82 

83 if end >= len(text): 

84 break 

85 

86 return chunks 

87 

88 

89class DocumentProcessor: 

90 """Handles the core document processing logic.""" 

91 

92 def __init__( 

93 self, 

94 vector_store: VectorStoreProtocol, 

95 progress_tracker: ProgressTracker, 

96 default_chunking_config: ChunkingConfigDict | None = None, 

97 chunker: ChunkerProtocol | None = None, 

98 document_parser: DocumentParser | None = None, 

99 ): 

100 """Initialize document processor.""" 

101 self.vector_store = vector_store 

102 self.progress_tracker = progress_tracker 

103 self.default_chunking_config = default_chunking_config or {} 

104 

105 chunk_size = self.default_chunking_config.get("chunk_size", 1000) 

106 overlap = self.default_chunking_config.get("overlap", 200) 

107 self._chunker = chunker or _SimpleChunker( 

108 chunk_size=int(chunk_size), 

109 overlap=int(overlap), 

110 ) 

111 self.document_parser = document_parser or UniversalDocumentParser() 

112 

113 async def process_document( 

114 self, 

115 document_id: str, 

116 file_path: str, 

117 collection_name: str, 

118 parser_name: str | None, 

119 metadata: dict[str, Any], 

120 batch_size: int = 50, 

121 ) -> IngestionResult: 

122 """ 

123 Process a document through the ingestion pipeline. 

124 

125 This is the main processing function that handles parsing, chunking, and storing. 

126 """ 

127 start_time = asyncio.get_event_loop().time() 

128 

129 try: 

130 # Select document parser 

131 if parser_name: 

132 logger.info( 

133 "Custom parser requested but using default", 

134 parser_name=parser_name, 

135 document_id=document_id, 

136 ) 

137 

138 # Update progress: parsing 

139 await self.progress_tracker.update_progress( 

140 document_id=document_id, 

141 status=IngestionStatus.PARSING, 

142 ) 

143 

144 # Parse document 

145 document = await self._parse_document(Path(file_path), metadata) 

146 logger.info( 

147 "Parsed document", 

148 document_id=document_id, 

149 pages=len(document.content.split("\n\n")), 

150 ) 

151 

152 # Update progress: chunking 

153 await self.progress_tracker.update_progress( 

154 document_id=document_id, 

155 status=IngestionStatus.CHUNKING, 

156 ) 

157 

158 # Chunk document 

159 chunks = await self._chunk_document(document) 

160 logger.info( 

161 "Chunked document", 

162 document_id=document_id, 

163 chunks=len(chunks), 

164 ) 

165 

166 # Update total chunks 

167 await self.progress_tracker.update_progress( 

168 document_id=document_id, 

169 total_chunks=len(chunks), 

170 ) 

171 

172 # Update progress: storing 

173 await self.progress_tracker.update_progress( 

174 document_id=document_id, 

175 status=IngestionStatus.STORING, 

176 ) 

177 

178 # Store chunks in batches 

179 chunks_created = 0 

180 for i in range(0, len(chunks), batch_size): 

181 batch = chunks[i : i + batch_size] 

182 

183 await self._store_chunks(batch, collection_name) 

184 chunks_created += len(batch) 

185 

186 # Update progress 

187 await self.progress_tracker.update_progress( 

188 document_id=document_id, 

189 chunks_processed=chunks_created, 

190 ) 

191 

192 logger.debug( 

193 "Stored chunk batch", 

194 document_id=document_id, 

195 batch_size=len(batch), 

196 total_chunks=chunks_created, 

197 ) 

198 

199 # Update progress: completed 

200 await self.progress_tracker.update_progress( 

201 document_id=document_id, 

202 status=IngestionStatus.COMPLETED, 

203 ) 

204 

205 duration = asyncio.get_event_loop().time() - start_time 

206 

207 logger.info( 

208 "Document ingestion completed", 

209 document_id=document_id, 

210 chunks_created=chunks_created, 

211 duration=f"{duration:.2f}s", 

212 ) 

213 

214 return IngestionResult.success_result( 

215 document_id=document_id, 

216 chunks_created=chunks_created, 

217 duration=duration, 

218 metadata={"collection": collection_name}, 

219 ) 

220 

221 except Exception as e: 

222 duration = asyncio.get_event_loop().time() - start_time 

223 error_msg = str(e) 

224 

225 # Update progress: failed 

226 await self.progress_tracker.update_progress( 

227 document_id=document_id, 

228 error=error_msg, 

229 ) 

230 

231 logger.exception( 

232 "Document ingestion failed", 

233 document_id=document_id, 

234 error=error_msg, 

235 ) 

236 

237 return IngestionResult.failure_result( 

238 document_id=document_id, 

239 error=error_msg, 

240 duration=duration, 

241 ) 

242 

243 async def _parse_document( 

244 self, 

245 file_path: Path, 

246 metadata: dict[str, Any], 

247 ) -> Document: 

248 """Parse document from file using configured parser.""" 

249 document = await self.document_parser.parse(file_path) 

250 document.metadata.update(metadata) 

251 return document 

252 

253 async def _chunk_document(self, document: Document) -> list[ChunkProtocol]: 

254 """Chunk document into smaller pieces.""" 

255 raw_chunks = self._chunker.chunk(document.content, metadata=document.metadata) 

256 chunks: list[ChunkProtocol] = [] 

257 

258 for index, chunk in enumerate(raw_chunks): 

259 chunk_text = str(getattr(chunk, "text", "")) 

260 chunk_metadata = getattr(chunk, "metadata", None) 

261 

262 merged_metadata = dict(document.metadata) 

263 if isinstance(chunk_metadata, dict): 

264 merged_metadata.update(chunk_metadata) 

265 merged_metadata.setdefault("chunk_index", index) 

266 

267 chunks.append( 

268 _ChunkRecord( 

269 text=chunk_text, 

270 metadata=merged_metadata, 

271 score=getattr(chunk, "score", None), 

272 ) 

273 ) 

274 

275 return chunks 

276 

277 async def _store_chunks( 

278 self, 

279 chunks: list[ChunkProtocol], 

280 collection_name: str, 

281 ) -> None: 

282 """Store chunks in vector store.""" 

283 texts = [chunk.text for chunk in chunks] 

284 metadatas = [chunk.metadata for chunk in chunks] 

285 

286 await self.vector_store.add_texts( 

287 texts=texts, 

288 metadatas=[m or {} for m in metadatas], 

289 collection_name=collection_name, 

290 )