docugraph 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. docugraph/__init__.py +24 -0
  2. docugraph/_version.py +28 -0
  3. docugraph/agents/__init__.py +27 -0
  4. docugraph/agents/coding_agent.py +532 -0
  5. docugraph/agents/tools/__init__.py +62 -0
  6. docugraph/agents/tools/graph_query.py +203 -0
  7. docugraph/agents/tools/memory_ops.py +305 -0
  8. docugraph/agents/tools/search_code.py +305 -0
  9. docugraph/agents/tools/search_docs.py +148 -0
  10. docugraph/core/__init__.py +34 -0
  11. docugraph/core/config.py +183 -0
  12. docugraph/core/embeddings.py +394 -0
  13. docugraph/core/llm.py +892 -0
  14. docugraph/core/models.py +145 -0
  15. docugraph/ingestion/__init__.py +64 -0
  16. docugraph/ingestion/api_docs.py +646 -0
  17. docugraph/ingestion/chunker.py +382 -0
  18. docugraph/ingestion/crawler.py +305 -0
  19. docugraph/ingestion/git_indexer.py +556 -0
  20. docugraph/ingestion/local_files.py +516 -0
  21. docugraph/interfaces/__init__.py +1 -0
  22. docugraph/interfaces/cli.py +983 -0
  23. docugraph/interfaces/mcp_server.py +918 -0
  24. docugraph/retrieval/__init__.py +54 -0
  25. docugraph/retrieval/context_builder.py +522 -0
  26. docugraph/retrieval/hybrid_search.py +534 -0
  27. docugraph/retrieval/reranker.py +532 -0
  28. docugraph/storage/__init__.py +14 -0
  29. docugraph/storage/graph_store.py +602 -0
  30. docugraph/storage/memory_store.py +529 -0
  31. docugraph/storage/vector_store.py +367 -0
  32. docugraph-0.1.0.dist-info/METADATA +354 -0
  33. docugraph-0.1.0.dist-info/RECORD +37 -0
  34. docugraph-0.1.0.dist-info/WHEEL +4 -0
  35. docugraph-0.1.0.dist-info/entry_points.txt +3 -0
  36. docugraph-0.1.0.dist-info/licenses/LICENSE +201 -0
  37. docugraph-0.1.0.dist-info/licenses/NOTICE +10 -0
@@ -0,0 +1,382 @@
1
+ """Document chunking strategies."""
2
+
3
+ import re
4
+ from dataclasses import dataclass
5
+ from enum import StrEnum
6
+
7
+ from docugraph.core.models import Chunk, Document
8
+
9
+
10
+ class ChunkingStrategy(StrEnum):
11
+ """Available chunking strategies."""
12
+
13
+ FIXED = "fixed" # Fixed size with overlap
14
+ SEMANTIC = "semantic" # By markdown headings/sections
15
+ SENTENCE = "sentence" # By sentences
16
+ PARAGRAPH = "paragraph" # By paragraphs
17
+
18
+
19
+ @dataclass
20
+ class ChunkerConfig:
21
+ """Configuration for the chunker."""
22
+
23
+ strategy: ChunkingStrategy = ChunkingStrategy.SEMANTIC
24
+ chunk_size: int = 1000 # Target characters per chunk
25
+ chunk_overlap: int = 200 # Overlap between chunks
26
+ min_chunk_size: int = 100 # Minimum chunk size
27
+ max_chunk_size: int = 2000 # Maximum chunk size
28
+
29
+
30
+ class Chunker:
31
+ """Document chunker with multiple strategies."""
32
+
33
+ def __init__(self, config: ChunkerConfig | None = None):
34
+ """Initialize the chunker.
35
+
36
+ Args:
37
+ config: Chunker configuration.
38
+ """
39
+ self._config = config or ChunkerConfig()
40
+
41
+ def chunk_document(self, document: Document) -> list[Chunk]:
42
+ """Chunk a document into smaller pieces.
43
+
44
+ Args:
45
+ document: The document to chunk.
46
+
47
+ Returns:
48
+ List of chunks.
49
+ """
50
+ strategy = self._config.strategy
51
+
52
+ if strategy == ChunkingStrategy.SEMANTIC:
53
+ return self._chunk_semantic(document)
54
+ elif strategy == ChunkingStrategy.SENTENCE:
55
+ return self._chunk_sentences(document)
56
+ elif strategy == ChunkingStrategy.PARAGRAPH:
57
+ return self._chunk_paragraphs(document)
58
+ else:
59
+ return self._chunk_fixed(document)
60
+
61
+ def _chunk_fixed(self, document: Document) -> list[Chunk]:
62
+ """Chunk document with fixed size windows."""
63
+ content = document.content
64
+ chunks: list[Chunk] = []
65
+
66
+ chunk_size = self._config.chunk_size
67
+ overlap = self._config.chunk_overlap
68
+
69
+ start = 0
70
+ while start < len(content):
71
+ end = start + chunk_size
72
+
73
+ # Try to break at sentence/paragraph boundary
74
+ if end < len(content):
75
+ # Look for paragraph break
76
+ para_break = content.rfind("\n\n", start + chunk_size // 2, end + 100)
77
+ if para_break > start:
78
+ end = para_break
79
+ else:
80
+ # Look for sentence break
81
+ sent_break = content.rfind(". ", start + chunk_size // 2, end + 50)
82
+ if sent_break > start:
83
+ end = sent_break + 1
84
+
85
+ chunk_content = content[start:end].strip()
86
+
87
+ if len(chunk_content) >= self._config.min_chunk_size:
88
+ chunks.append(
89
+ Chunk(
90
+ document_id=document.id,
91
+ content=chunk_content,
92
+ start_char=start,
93
+ end_char=end,
94
+ metadata={
95
+ "source_url": document.source_url,
96
+ "source_path": document.source_path,
97
+ "title": document.title,
98
+ "chunk_index": len(chunks),
99
+ },
100
+ )
101
+ )
102
+
103
+ start = end - overlap
104
+ if start >= len(content):
105
+ break
106
+
107
+ return chunks
108
+
109
+ def _chunk_semantic(self, document: Document) -> list[Chunk]:
110
+ """Chunk document by markdown sections."""
111
+ content = document.content
112
+ chunks: list[Chunk] = []
113
+
114
+ # Split by headings
115
+ heading_pattern = r"^(#{1,6})\s+(.+?)$"
116
+ sections: list[tuple[int, int, str, str]] = [] # (start, level, title, content)
117
+
118
+ lines = content.split("\n")
119
+ current_section_start = 0
120
+ current_title = document.title or "Introduction"
121
+ current_level = 0
122
+ section_lines: list[str] = []
123
+
124
+ for i, line in enumerate(lines):
125
+ match = re.match(heading_pattern, line)
126
+ if match:
127
+ # Save previous section
128
+ if section_lines:
129
+ section_content = "\n".join(section_lines).strip()
130
+ if section_content:
131
+ sections.append(
132
+ (
133
+ current_section_start,
134
+ current_level,
135
+ current_title,
136
+ section_content,
137
+ )
138
+ )
139
+
140
+ # Start new section
141
+ current_level = len(match.group(1))
142
+ current_title = match.group(2).strip()
143
+ current_section_start = sum(len(line) + 1 for line in lines[:i])
144
+ section_lines = [line]
145
+ else:
146
+ section_lines.append(line)
147
+
148
+ # Add last section
149
+ if section_lines:
150
+ section_content = "\n".join(section_lines).strip()
151
+ if section_content:
152
+ sections.append(
153
+ (
154
+ current_section_start,
155
+ current_level,
156
+ current_title,
157
+ section_content,
158
+ )
159
+ )
160
+
161
+ # Convert sections to chunks, splitting large ones
162
+ for start, level, title, section_content in sections:
163
+ if len(section_content) <= self._config.max_chunk_size:
164
+ if len(section_content) >= self._config.min_chunk_size:
165
+ chunks.append(
166
+ Chunk(
167
+ document_id=document.id,
168
+ content=section_content,
169
+ start_char=start,
170
+ end_char=start + len(section_content),
171
+ metadata={
172
+ "source_url": document.source_url,
173
+ "source_path": document.source_path,
174
+ "title": document.title,
175
+ "section_title": title,
176
+ "heading_level": level,
177
+ "chunk_index": len(chunks),
178
+ },
179
+ )
180
+ )
181
+ else:
182
+ # Split large sections with fixed chunking
183
+ sub_chunks = self._split_large_section(
184
+ document, section_content, start, title, level, len(chunks)
185
+ )
186
+ chunks.extend(sub_chunks)
187
+
188
+ # If no chunks created (no headings found), fall back to fixed chunking
189
+ if not chunks:
190
+ return self._chunk_fixed(document)
191
+
192
+ return chunks
193
+
194
+ def _split_large_section(
195
+ self,
196
+ document: Document,
197
+ content: str,
198
+ base_start: int,
199
+ title: str,
200
+ level: int,
201
+ chunk_index_start: int,
202
+ ) -> list[Chunk]:
203
+ """Split a large section into smaller chunks."""
204
+ chunks: list[Chunk] = []
205
+
206
+ # Try to split by paragraphs first
207
+ paragraphs = content.split("\n\n")
208
+ current_chunk = ""
209
+ current_start = base_start
210
+ chunk_idx = chunk_index_start
211
+
212
+ for para in paragraphs:
213
+ if len(current_chunk) + len(para) + 2 <= self._config.chunk_size:
214
+ if current_chunk:
215
+ current_chunk += "\n\n" + para
216
+ else:
217
+ current_chunk = para
218
+ else:
219
+ # Save current chunk
220
+ if len(current_chunk) >= self._config.min_chunk_size:
221
+ chunks.append(
222
+ Chunk(
223
+ document_id=document.id,
224
+ content=current_chunk,
225
+ start_char=current_start,
226
+ end_char=current_start + len(current_chunk),
227
+ metadata={
228
+ "source_url": document.source_url,
229
+ "source_path": document.source_path,
230
+ "title": document.title,
231
+ "section_title": title,
232
+ "heading_level": level,
233
+ "chunk_index": chunk_idx,
234
+ },
235
+ )
236
+ )
237
+ chunk_idx += 1
238
+
239
+ current_start = base_start + content.find(para)
240
+ current_chunk = para
241
+
242
+ # Add last chunk
243
+ if current_chunk and len(current_chunk) >= self._config.min_chunk_size:
244
+ chunks.append(
245
+ Chunk(
246
+ document_id=document.id,
247
+ content=current_chunk,
248
+ start_char=current_start,
249
+ end_char=current_start + len(current_chunk),
250
+ metadata={
251
+ "source_url": document.source_url,
252
+ "source_path": document.source_path,
253
+ "title": document.title,
254
+ "section_title": title,
255
+ "heading_level": level,
256
+ "chunk_index": chunk_idx,
257
+ },
258
+ )
259
+ )
260
+
261
+ return chunks
262
+
263
+ def _chunk_sentences(self, document: Document) -> list[Chunk]:
264
+ """Chunk document by sentences, grouping to target size."""
265
+ content = document.content
266
+ chunks: list[Chunk] = []
267
+
268
+ # Simple sentence splitting
269
+ sentence_pattern = r"(?<=[.!?])\s+"
270
+ sentences = re.split(sentence_pattern, content)
271
+
272
+ current_chunk = ""
273
+ current_start = 0
274
+
275
+ for sentence in sentences:
276
+ if len(current_chunk) + len(sentence) + 1 <= self._config.chunk_size:
277
+ if current_chunk:
278
+ current_chunk += " " + sentence
279
+ else:
280
+ current_chunk = sentence
281
+ else:
282
+ if len(current_chunk) >= self._config.min_chunk_size:
283
+ chunks.append(
284
+ Chunk(
285
+ document_id=document.id,
286
+ content=current_chunk,
287
+ start_char=current_start,
288
+ end_char=current_start + len(current_chunk),
289
+ metadata={
290
+ "source_url": document.source_url,
291
+ "source_path": document.source_path,
292
+ "title": document.title,
293
+ "chunk_index": len(chunks),
294
+ },
295
+ )
296
+ )
297
+
298
+ current_start = content.find(sentence, current_start)
299
+ current_chunk = sentence
300
+
301
+ # Add last chunk
302
+ if current_chunk and len(current_chunk) >= self._config.min_chunk_size:
303
+ chunks.append(
304
+ Chunk(
305
+ document_id=document.id,
306
+ content=current_chunk,
307
+ start_char=current_start,
308
+ end_char=current_start + len(current_chunk),
309
+ metadata={
310
+ "source_url": document.source_url,
311
+ "source_path": document.source_path,
312
+ "title": document.title,
313
+ "chunk_index": len(chunks),
314
+ },
315
+ )
316
+ )
317
+
318
+ return chunks
319
+
320
+ def _chunk_paragraphs(self, document: Document) -> list[Chunk]:
321
+ """Chunk document by paragraphs, grouping to target size."""
322
+ content = document.content
323
+ chunks: list[Chunk] = []
324
+
325
+ paragraphs = content.split("\n\n")
326
+
327
+ current_chunk = ""
328
+ current_start = 0
329
+
330
+ for para in paragraphs:
331
+ para = para.strip()
332
+ if not para:
333
+ continue
334
+
335
+ if len(current_chunk) + len(para) + 2 <= self._config.chunk_size:
336
+ if current_chunk:
337
+ current_chunk += "\n\n" + para
338
+ else:
339
+ current_chunk = para
340
+ else:
341
+ if len(current_chunk) >= self._config.min_chunk_size:
342
+ chunks.append(
343
+ Chunk(
344
+ document_id=document.id,
345
+ content=current_chunk,
346
+ start_char=current_start,
347
+ end_char=current_start + len(current_chunk),
348
+ metadata={
349
+ "source_url": document.source_url,
350
+ "source_path": document.source_path,
351
+ "title": document.title,
352
+ "chunk_index": len(chunks),
353
+ },
354
+ )
355
+ )
356
+
357
+ current_start = content.find(para, current_start)
358
+ current_chunk = para
359
+
360
+ # Add last chunk
361
+ if current_chunk and len(current_chunk) >= self._config.min_chunk_size:
362
+ chunks.append(
363
+ Chunk(
364
+ document_id=document.id,
365
+ content=current_chunk,
366
+ start_char=current_start,
367
+ end_char=current_start + len(current_chunk),
368
+ metadata={
369
+ "source_url": document.source_url,
370
+ "source_path": document.source_path,
371
+ "title": document.title,
372
+ "chunk_index": len(chunks),
373
+ },
374
+ )
375
+ )
376
+
377
+ return chunks
378
+
379
+
380
+ def get_chunker(config: ChunkerConfig | None = None) -> Chunker:
381
+ """Get a chunker instance."""
382
+ return Chunker(config)
@@ -0,0 +1,305 @@
1
+ """Web crawler using Crawl4AI."""
2
+
3
+ import asyncio
4
+ import hashlib
5
+ from datetime import UTC, datetime
6
+ from urllib.parse import urljoin, urlparse
7
+
8
+ from docugraph.core.config import CrawlerConfig, get_config
9
+ from docugraph.core.models import ContentType, Document
10
+
11
+
12
+ class DocCrawler:
13
+ """Web crawler for documentation sites using Crawl4AI."""
14
+
15
+ def __init__(self, config: CrawlerConfig | None = None):
16
+ """Initialize the crawler.
17
+
18
+ Args:
19
+ config: Crawler configuration. If None, uses default from config.
20
+ """
21
+ self._config = config or get_config().crawler
22
+ self._cache_dir = get_config().storage.data_dir / "crawl_cache"
23
+ self._cache_dir.mkdir(parents=True, exist_ok=True)
24
+
25
+ def _get_cache_key(self, url: str) -> str:
26
+ """Generate a cache key for a URL."""
27
+ return hashlib.sha256(url.encode()).hexdigest()[:16]
28
+
29
+ def _get_cached(self, url: str) -> Document | None:
30
+ """Get cached document if available and fresh."""
31
+ cache_key = self._get_cache_key(url)
32
+ cache_file = self._cache_dir / f"{cache_key}.json"
33
+
34
+ if not cache_file.exists():
35
+ return None
36
+
37
+ import json
38
+
39
+ try:
40
+ with open(cache_file) as f:
41
+ data = json.load(f)
42
+
43
+ # Check TTL. A cache entry written before timestamps carried a
44
+ # timezone parses as naive, so it is read as UTC rather than
45
+ # subtracted from an aware "now" (which raises).
46
+ cached_at = datetime.fromisoformat(data["cached_at"])
47
+ if cached_at.tzinfo is None:
48
+ cached_at = cached_at.replace(tzinfo=UTC)
49
+ age = (datetime.now(UTC) - cached_at).total_seconds()
50
+ if age > self._config.cache_ttl:
51
+ return None
52
+
53
+ return Document(**data["document"])
54
+ except Exception:
55
+ return None
56
+
57
+ def _save_cache(self, url: str, document: Document) -> None:
58
+ """Save document to cache."""
59
+ cache_key = self._get_cache_key(url)
60
+ cache_file = self._cache_dir / f"{cache_key}.json"
61
+
62
+ import json
63
+
64
+ data = {
65
+ "cached_at": datetime.now(UTC).isoformat(),
66
+ "document": document.model_dump(mode="json"),
67
+ }
68
+
69
+ with open(cache_file, "w") as f:
70
+ json.dump(data, f)
71
+
72
+ async def crawl_single(
73
+ self,
74
+ url: str,
75
+ use_cache: bool = True,
76
+ ) -> Document:
77
+ """Crawl a single URL.
78
+
79
+ Args:
80
+ url: The URL to crawl.
81
+ use_cache: Whether to use cached results.
82
+
83
+ Returns:
84
+ The crawled document.
85
+ """
86
+ # Check cache first
87
+ if use_cache:
88
+ cached = self._get_cached(url)
89
+ if cached:
90
+ return cached
91
+
92
+ from crawl4ai import AsyncWebCrawler, BrowserConfig, CrawlerRunConfig
93
+ from crawl4ai.content_filter_strategy import PruningContentFilter
94
+ from crawl4ai.markdown_generation_strategy import DefaultMarkdownGenerator
95
+
96
+ browser_config = BrowserConfig(
97
+ headless=True,
98
+ verbose=False,
99
+ )
100
+
101
+ crawler_config = CrawlerRunConfig(
102
+ markdown_generator=DefaultMarkdownGenerator(
103
+ content_filter=PruningContentFilter(
104
+ threshold=0.48,
105
+ threshold_type="fixed",
106
+ min_word_threshold=0,
107
+ )
108
+ ),
109
+ wait_until="domcontentloaded",
110
+ )
111
+
112
+ async with AsyncWebCrawler(config=browser_config) as crawler:
113
+ result = await crawler.arun(url=url, config=crawler_config)
114
+
115
+ if not result.success:
116
+ raise RuntimeError(f"Failed to crawl {url}: {result.error_message}")
117
+
118
+ # Extract title from metadata or content
119
+ title = result.metadata.get("title") if result.metadata else None
120
+ if not title and result.markdown:
121
+ # Try to extract from first heading
122
+ lines = result.markdown.split("\n")
123
+ for line in lines:
124
+ if line.startswith("# "):
125
+ title = line[2:].strip()
126
+ break
127
+
128
+ document = Document(
129
+ source_url=url,
130
+ title=title,
131
+ content=result.markdown.fit_markdown
132
+ if hasattr(result.markdown, "fit_markdown")
133
+ else result.markdown,
134
+ content_type=ContentType.MARKDOWN,
135
+ metadata={
136
+ "raw_html_length": len(result.html) if result.html else 0,
137
+ "links_count": len(result.links.get("internal", [])) if result.links else 0,
138
+ "crawled_at": datetime.now(UTC).isoformat(),
139
+ },
140
+ )
141
+
142
+ # Cache the result
143
+ if use_cache:
144
+ self._save_cache(url, document)
145
+
146
+ return document
147
+
148
+ async def crawl_site(
149
+ self,
150
+ start_url: str,
151
+ max_pages: int = 100,
152
+ url_pattern: str | None = None,
153
+ use_cache: bool = True,
154
+ ) -> list[Document]:
155
+ """Crawl a documentation site starting from a URL.
156
+
157
+ Args:
158
+ start_url: The starting URL.
159
+ max_pages: Maximum number of pages to crawl.
160
+ url_pattern: Optional regex pattern to filter URLs.
161
+ use_cache: Whether to use cached results.
162
+
163
+ Returns:
164
+ List of crawled documents.
165
+ """
166
+ from crawl4ai import AsyncWebCrawler, BrowserConfig, CrawlerRunConfig
167
+ from crawl4ai.content_filter_strategy import PruningContentFilter
168
+ from crawl4ai.markdown_generation_strategy import DefaultMarkdownGenerator
169
+
170
+ browser_config = BrowserConfig(
171
+ headless=True,
172
+ verbose=False,
173
+ )
174
+
175
+ crawler_config = CrawlerRunConfig(
176
+ markdown_generator=DefaultMarkdownGenerator(
177
+ content_filter=PruningContentFilter(
178
+ threshold=0.48,
179
+ threshold_type="fixed",
180
+ min_word_threshold=0,
181
+ )
182
+ ),
183
+ wait_until="domcontentloaded",
184
+ )
185
+
186
+ # Parse base domain for filtering
187
+ parsed_start = urlparse(start_url)
188
+ base_domain = parsed_start.netloc
189
+ parsed_start.path.rsplit("/", 1)[0] if "/" in parsed_start.path else ""
190
+
191
+ visited: set[str] = set()
192
+ to_visit: list[str] = [start_url]
193
+ documents: list[Document] = []
194
+
195
+ import re
196
+
197
+ url_regex = re.compile(url_pattern) if url_pattern else None
198
+
199
+ async with AsyncWebCrawler(config=browser_config) as crawler:
200
+ while to_visit and len(documents) < max_pages:
201
+ # Get next URL to crawl
202
+ url = to_visit.pop(0)
203
+
204
+ if url in visited:
205
+ continue
206
+
207
+ visited.add(url)
208
+
209
+ # Check cache
210
+ if use_cache:
211
+ cached = self._get_cached(url)
212
+ if cached:
213
+ documents.append(cached)
214
+ continue
215
+
216
+ try:
217
+ result = await crawler.arun(url=url, config=crawler_config)
218
+
219
+ if not result.success:
220
+ continue
221
+
222
+ # Extract title
223
+ title = result.metadata.get("title") if result.metadata else None
224
+ if not title and result.markdown:
225
+ lines = result.markdown.split("\n")
226
+ for line in lines:
227
+ if line.startswith("# "):
228
+ title = line[2:].strip()
229
+ break
230
+
231
+ content = (
232
+ result.markdown.fit_markdown
233
+ if hasattr(result.markdown, "fit_markdown")
234
+ else result.markdown
235
+ )
236
+
237
+ document = Document(
238
+ source_url=url,
239
+ title=title,
240
+ content=content,
241
+ content_type=ContentType.MARKDOWN,
242
+ metadata={
243
+ "raw_html_length": len(result.html) if result.html else 0,
244
+ "crawled_at": datetime.now(UTC).isoformat(),
245
+ },
246
+ )
247
+
248
+ documents.append(document)
249
+
250
+ if use_cache:
251
+ self._save_cache(url, document)
252
+
253
+ # Extract and queue internal links
254
+ if result.links and "internal" in result.links:
255
+ for link in result.links["internal"]:
256
+ href = link.get("href", "")
257
+ if not href:
258
+ continue
259
+
260
+ # Resolve relative URLs
261
+ full_url = urljoin(url, href)
262
+ parsed = urlparse(full_url)
263
+
264
+ # Filter to same domain
265
+ if parsed.netloc != base_domain:
266
+ continue
267
+
268
+ # Filter by pattern if provided
269
+ if url_regex and not url_regex.search(full_url):
270
+ continue
271
+
272
+ # Skip anchors and query params
273
+ clean_url = f"{parsed.scheme}://{parsed.netloc}{parsed.path}"
274
+
275
+ if clean_url not in visited and clean_url not in to_visit:
276
+ to_visit.append(clean_url)
277
+
278
+ except Exception as e:
279
+ # Log error but continue crawling
280
+ print(f"Error crawling {url}: {e}")
281
+ continue
282
+
283
+ # Respect rate limit
284
+ await asyncio.sleep(1.0 / self._config.rate_limit)
285
+
286
+ return documents
287
+
288
+ def crawl_single_sync(self, url: str, use_cache: bool = True) -> Document:
289
+ """Synchronous wrapper for crawl_single."""
290
+ return asyncio.run(self.crawl_single(url, use_cache))
291
+
292
+ def crawl_site_sync(
293
+ self,
294
+ start_url: str,
295
+ max_pages: int = 100,
296
+ url_pattern: str | None = None,
297
+ use_cache: bool = True,
298
+ ) -> list[Document]:
299
+ """Synchronous wrapper for crawl_site."""
300
+ return asyncio.run(self.crawl_site(start_url, max_pages, url_pattern, use_cache))
301
+
302
+
303
+ def get_crawler() -> DocCrawler:
304
+ """Get the default crawler instance."""
305
+ return DocCrawler()