Coverage for /home/admin/Documents/AI/applications/lexigram-dev/lexigram/experimental/ai/lexigram-ai-rag/src/lexigram/ai/rag/loaders/p1_loaders.py: 21%
84 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-25 07:19 +0800
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-25 07:19 +0800
1"""P1 document loaders for RAG.
3Compatibility facade for office, web, code, and SQL loaders."""
5from __future__ import annotations
7from pathlib import Path
8import re
9from typing import Any
11from lexigram.ai.rag.chunking.types import Chunk
12from lexigram.ai.rag.loaders._io_utils import read_file_text
13from lexigram.ai.rag.loaders.office import DocxLoader, EmailLoader, ExcelLoader
14from lexigram.ai.rag.loaders.web import WebScraperLoader
15from lexigram.ai.rag.types import RAGError
16from lexigram.contracts.data import DatabaseProviderProtocol
18# ---------------------------------------------------------------------------
19# CodeLoader
20# ---------------------------------------------------------------------------
22# Regex patterns that detect the start of a top-level definition per language
23_CODE_SPLIT_PATTERNS: dict[str, str] = {
24 ".py": r"^(?:async\s+)?(?:def|class)\s+\w+",
25 ".js": r"^(?:function\s+\w+|class\s+\w+|const\s+\w+\s*=\s*(?:async\s+)?(?:function|\(|[a-zA-Z_]))",
26 ".ts": r"^(?:export\s+)?(?:async\s+)?(?:function\s+\w+|class\s+\w+|const\s+\w+\s*=)",
27 ".java": r"^(?:public|private|protected|static|\s)+(?:class|interface|enum|\w+\s*\()",
28 ".go": r"^func\s+",
29 ".rs": r"^(?:pub\s+)?(?:fn|struct|enum|impl|trait|mod)\s+\w+",
30 ".rb": r"^(?:def|class|module)\s+\w+",
31}
34class CodeLoader:
35 """Load source code files with language-aware chunking.
37 Splits at top-level function/class definitions using per-language
38 regex patterns. Falls back to fixed-size chunking for unknown languages.
40 No external dependencies required (optional: tree-sitter for precise AST
41 parsing in future versions).
42 """
44 def __init__(self, *, max_chunk_lines: int = 100) -> None:
45 """Initialize code loader.
47 Args:
48 max_chunk_lines: Maximum number of lines per chunk for fallback
49 fixed-size splitting (unknown language extension).
50 """
51 self.max_chunk_lines = max_chunk_lines
53 async def load(self, source: str | Path) -> list[Chunk]:
54 """Load a source code file.
56 Args:
57 source: Path to the source code file.
59 Returns:
60 List of chunks — one per top-level definition or fixed-size block.
62 Raises:
63 RAGError: If the file cannot be read.
64 """
65 try:
66 path = Path(source)
67 content = await read_file_text(path, encoding="utf-8")
68 ext = path.suffix.lower()
69 pattern = _CODE_SPLIT_PATTERNS.get(ext)
71 chunks: list[Chunk] = []
73 if pattern:
74 split_re = re.compile(pattern, re.MULTILINE)
75 lines = content.splitlines(keepends=True)
76 split_indices = [0]
77 for match in split_re.finditer(content):
78 # find line number of the match
79 line_no = content[: match.start()].count("\n")
80 if line_no > 0 and line_no not in split_indices:
81 split_indices.append(line_no)
82 split_indices.append(len(lines))
84 for i in range(len(split_indices) - 1):
85 block = "".join(lines[split_indices[i] : split_indices[i + 1]])
86 if block.strip():
87 chunks.append(
88 Chunk(
89 text=block,
90 source=str(path),
91 chunk_index=len(chunks),
92 metadata={
93 "source": str(path),
94 "type": "code",
95 "language": ext.lstrip("."),
96 "start_line": split_indices[i] + 1,
97 },
98 )
99 )
100 else:
101 # Fallback: fixed-size line batches
102 lines = content.splitlines(keepends=True)
103 for batch_start in range(0, len(lines), self.max_chunk_lines):
104 block = "".join(
105 lines[batch_start : batch_start + self.max_chunk_lines]
106 )
107 if block.strip():
108 chunks.append(
109 Chunk(
110 text=block,
111 source=str(path),
112 chunk_index=len(chunks),
113 metadata={
114 "source": str(path),
115 "type": "code",
116 "language": ext.lstrip("."),
117 "start_line": batch_start + 1,
118 },
119 )
120 )
122 return chunks
124 except (OSError, UnicodeDecodeError) as e:
125 msg = f"Failed to read code file {source}: {e}"
126 raise RAGError(msg) from e
127 except Exception as e:
128 msg = f"Unexpected error loading code file {source}: {e}"
129 raise RAGError(msg) from e
132# ---------------------------------------------------------------------------
133# SQLLoader
134# ---------------------------------------------------------------------------
137class SQLLoader:
138 """Load data from a SQL query result as document chunks.
140 Uses ``DatabaseProviderProtocol`` for all database access — no direct
141 driver imports. Each row becomes a chunk (or rows are batched).
143 The caller is responsible for registering the provider and injecting it
144 via constructor injection.
145 """
147 def __init__(
148 self,
149 db: DatabaseProviderProtocol,
150 *,
151 query: str,
152 params: dict[str, Any] | None = None,
153 text_column: str | None = None,
154 batch_size: int = 1,
155 table_name: str = "query",
156 ) -> None:
157 """Initialize SQL loader.
159 Args:
160 db: Database provider (injected via DI container).
161 query: SQL SELECT statement to execute.
162 params: Optional query parameters.
163 text_column: Column whose value is used as chunk text. When not
164 set, all columns are joined into a key: value string.
165 batch_size: Number of rows per chunk.
166 table_name: Label used in metadata ``source`` field.
167 """
168 self._db = db
169 self._query = query
170 self._params = params or {}
171 self._text_column = text_column
172 self._batch_size = batch_size
173 self._table_name = table_name
175 async def load(self, source: str | Path = "") -> list[Chunk]:
176 """Execute the SQL query and return a chunk per row (or batch).
178 Args:
179 source: Ignored — the data source is the DB connection passed at
180 construction. Kept for interface compatibility.
182 Returns:
183 List of chunks, one per row or per batch of rows.
185 Raises:
186 RAGError: If the query fails.
187 """
188 try:
189 async with self._db.scoped_context():
190 conn = await self._db.get_scoped_connection()
191 rows: list[dict[str, Any]] = await conn.fetch(
192 self._query, **self._params
193 )
195 label = f"sql://{self._table_name}"
196 chunks: list[Chunk] = []
197 batch: list[dict[str, Any]] = []
199 def _row_text(row: dict[str, Any]) -> str:
200 if self._text_column:
201 return str(row.get(self._text_column, ""))
202 return " ".join(f"{k}: {v}" for k, v in row.items())
204 for row_idx, row in enumerate(rows):
205 batch.append(dict(row))
206 if len(batch) >= self._batch_size:
207 text = "\n".join(_row_text(r) for r in batch)
208 chunk_idx = row_idx // self._batch_size
209 chunks.append(
210 Chunk(
211 text=text,
212 source=label,
213 chunk_index=chunk_idx,
214 metadata={
215 "source": label,
216 "type": "sql",
217 "table": self._table_name,
218 "row_start": chunk_idx * self._batch_size,
219 "row_end": row_idx,
220 },
221 )
222 )
223 batch = []
225 if batch:
226 chunk_idx = len(rows) // self._batch_size
227 text = "\n".join(_row_text(r) for r in batch)
228 chunks.append(
229 Chunk(
230 text=text,
231 source=label,
232 chunk_index=chunk_idx,
233 metadata={
234 "source": label,
235 "type": "sql",
236 "table": self._table_name,
237 "row_start": chunk_idx * self._batch_size,
238 "row_end": len(rows) - 1,
239 },
240 )
241 )
243 return chunks
245 except RAGError:
246 raise
247 except Exception as e:
248 msg = f"SQL query failed for '{self._table_name}': {e}"
249 raise RAGError(msg) from e
252__all__ = [
253 "CodeLoader",
254 "DocxLoader",
255 "EmailLoader",
256 "ExcelLoader",
257 "SQLLoader",
258 "WebScraperLoader",
259]