factag 0.1.0__tar.gz

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.
@@ -0,0 +1,14 @@
1
+ node_modules/
2
+ dist/
3
+ *.tsbuildinfo
4
+ .env
5
+ .env.*
6
+ __pycache__/
7
+ *.pyc
8
+ *.egg-info/
9
+ build/
10
+ .pytest_cache/
11
+ facts.jsonl
12
+ facts/
13
+ *.log
14
+ .DS_Store
factag-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,52 @@
1
+ Metadata-Version: 2.5
2
+ Name: factag
3
+ Version: 0.1.0
4
+ Summary: Fact-Augmented Generation (FAG) — RAG-shaped load → split → extract → normalize → store → assemble pipeline for verified facts. Pure standard library.
5
+ Project-URL: Homepage, https://github.com/factag-generator/fact-augmented-generation
6
+ Project-URL: Repository, https://github.com/factag-generator/fact-augmented-generation
7
+ Project-URL: Issues, https://github.com/factag-generator/fact-augmented-generation/issues
8
+ Author: FAG contributors
9
+ License: Apache-2.0
10
+ Keywords: fact-augmented-generation,fact-extraction,fag,grounding,llm,rag-alternative,retrieval
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
19
+ Classifier: Topic :: Text Processing :: Indexing
20
+ Requires-Python: >=3.10
21
+ Description-Content-Type: text/markdown
22
+
23
+ # factag (Python)
24
+
25
+ Python twin of the TypeScript [`factag`](https://www.npmjs.com/package/factag) package — same stage names, same JSONL fact-store format, same `=== VERIFIED FACTS ===` context block. Zero dependencies, standard library only.
26
+
27
+ ```python
28
+ import asyncio
29
+ from factag import DirectoryLoader, HeuristicExtractor, RecursiveHeadingSplitter, MemoryFactStore, IngestionPipeline, BudgetAssembler
30
+
31
+ async def main():
32
+ store = MemoryFactStore()
33
+ result = await IngestionPipeline(
34
+ loader=DirectoryLoader("./docs"),
35
+ splitter=RecursiveHeadingSplitter(chunk_size=800, chunk_overlap=120),
36
+ extractor=HeuristicExtractor(),
37
+ store=store,
38
+ ).run()
39
+ print(result) # IngestResult(documents=..., chunks=..., raw=..., kept=...)
40
+
41
+ block = await BudgetAssembler(store, budget=4000).to_context("what does the business plan cost?")
42
+ print(block)
43
+ # === VERIFIED FACTS ===
44
+ # - (file:///.../pricing.md) importance=5 category=pricing Business plan costs $99 per month...
45
+ # === END VERIFIED FACTS ===
46
+
47
+ asyncio.run(main())
48
+ ```
49
+
50
+ Install: `pip install factag`
51
+
52
+ Full documentation, design principles and the FAG spec: see the [repository README](https://github.com/factag-generator/fact-augmented-generation).
factag-0.1.0/README.md ADDED
@@ -0,0 +1,30 @@
1
+ # factag (Python)
2
+
3
+ Python twin of the TypeScript [`factag`](https://www.npmjs.com/package/factag) package — same stage names, same JSONL fact-store format, same `=== VERIFIED FACTS ===` context block. Zero dependencies, standard library only.
4
+
5
+ ```python
6
+ import asyncio
7
+ from factag import DirectoryLoader, HeuristicExtractor, RecursiveHeadingSplitter, MemoryFactStore, IngestionPipeline, BudgetAssembler
8
+
9
+ async def main():
10
+ store = MemoryFactStore()
11
+ result = await IngestionPipeline(
12
+ loader=DirectoryLoader("./docs"),
13
+ splitter=RecursiveHeadingSplitter(chunk_size=800, chunk_overlap=120),
14
+ extractor=HeuristicExtractor(),
15
+ store=store,
16
+ ).run()
17
+ print(result) # IngestResult(documents=..., chunks=..., raw=..., kept=...)
18
+
19
+ block = await BudgetAssembler(store, budget=4000).to_context("what does the business plan cost?")
20
+ print(block)
21
+ # === VERIFIED FACTS ===
22
+ # - (file:///.../pricing.md) importance=5 category=pricing Business plan costs $99 per month...
23
+ # === END VERIFIED FACTS ===
24
+
25
+ asyncio.run(main())
26
+ ```
27
+
28
+ Install: `pip install factag`
29
+
30
+ Full documentation, design principles and the FAG spec: see the [repository README](https://github.com/factag-generator/fact-augmented-generation).
@@ -0,0 +1,64 @@
1
+ """factag — Fact-Augmented Generation.
2
+
3
+ RAG-shaped pipeline: load → split → extract → normalize → store → assemble.
4
+ Pure standard library. Zero keys required (HeuristicExtractor default).
5
+ """
6
+
7
+ from .documents import Document, Fact, Metadata, source_url_of
8
+ from .extractors import Extractor, HeuristicExtractor, LLMExtractor, parse_fact_json, DEFAULT_LLM_SYSTEM_PROMPT
9
+ from .loaders import (
10
+ BaseLoader,
11
+ DirectoryLoader,
12
+ TextLoader,
13
+ URLLoader,
14
+ SitemapLoader,
15
+ html_to_text,
16
+ looks_like_html,
17
+ )
18
+ from .normalize import normalize_facts
19
+ from .pipeline import IngestionPipeline, IngestResult
20
+ from .retrievers import (
21
+ FACTS_FOOTER,
22
+ FACTS_HEADER,
23
+ BudgetAssembler,
24
+ KeywordRetriever,
25
+ fact_line,
26
+ to_context_block,
27
+ )
28
+ from .splitter import RecursiveHeadingSplitter, DEFAULT_SEPARATORS
29
+ from .stores import FileFactStore, MemoryFactStore
30
+
31
+ VERSION = "0.1.0"
32
+
33
+ __all__ = [
34
+ "Document",
35
+ "Fact",
36
+ "Metadata",
37
+ "source_url_of",
38
+ "Extractor",
39
+ "HeuristicExtractor",
40
+ "LLMExtractor",
41
+ "parse_fact_json",
42
+ "DEFAULT_LLM_SYSTEM_PROMPT",
43
+ "BaseLoader",
44
+ "DirectoryLoader",
45
+ "TextLoader",
46
+ "URLLoader",
47
+ "SitemapLoader",
48
+ "html_to_text",
49
+ "looks_like_html",
50
+ "normalize_facts",
51
+ "IngestionPipeline",
52
+ "IngestResult",
53
+ "FACTS_HEADER",
54
+ "FACTS_FOOTER",
55
+ "BudgetAssembler",
56
+ "KeywordRetriever",
57
+ "fact_line",
58
+ "to_context_block",
59
+ "RecursiveHeadingSplitter",
60
+ "DEFAULT_SEPARATORS",
61
+ "FileFactStore",
62
+ "MemoryFactStore",
63
+ "VERSION",
64
+ ]
@@ -0,0 +1,53 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Any
5
+
6
+ Metadata = dict[str, Any]
7
+
8
+
9
+ @dataclass
10
+ class Document:
11
+ page_content: str
12
+ metadata: Metadata = field(default_factory=dict)
13
+
14
+
15
+ @dataclass
16
+ class Fact:
17
+ text: str
18
+ importance: int
19
+ category: str
20
+ source_url: str
21
+ chunk_id: str | None = None
22
+ metadata: Metadata = field(default_factory=dict)
23
+
24
+ def to_wire(self) -> dict[str, Any]:
25
+ out: dict[str, Any] = {
26
+ "text": self.text,
27
+ "importance": self.importance,
28
+ "category": self.category,
29
+ "sourceUrl": self.source_url,
30
+ }
31
+ if self.chunk_id:
32
+ out["chunkId"] = self.chunk_id
33
+ if self.metadata:
34
+ out["metadata"] = self.metadata
35
+ return out
36
+
37
+ @staticmethod
38
+ def from_wire(data: dict[str, Any]) -> "Fact":
39
+ return Fact(
40
+ text=str(data.get("text", "")),
41
+ importance=int(data.get("importance", 3)),
42
+ category=str(data.get("category", "fact")),
43
+ source_url=str(data.get("sourceUrl", "unknown")),
44
+ chunk_id=data.get("chunkId"),
45
+ metadata=data.get("metadata") or {},
46
+ )
47
+
48
+
49
+ def source_url_of(doc: Document) -> str:
50
+ raw = doc.metadata.get("url") or doc.metadata.get("source")
51
+ if isinstance(raw, str) and raw:
52
+ return raw
53
+ return "unknown"
@@ -0,0 +1,214 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ import urllib.request
6
+ from concurrent.futures import ThreadPoolExecutor
7
+ from dataclasses import dataclass, field
8
+ from typing import Any, Protocol
9
+
10
+ from .documents import Document, Fact, Metadata, source_url_of
11
+
12
+ DEFAULT_LLM_SYSTEM_PROMPT = (
13
+ "You are a fact extraction engine. Extract atomic, verifiable, self-contained facts from the text. "
14
+ "Return ONLY a JSON array. Each element: "
15
+ '{"text": "...", "importance": 1-5, "category": "pricing|feature|limit|contact|policy|api|fact|date|entity|other"}. '
16
+ "Never invent facts. Never merge facts from different sentences. "
17
+ "Importance: 5 = must-have core fact, 3 = useful detail, 1 = trivia."
18
+ )
19
+
20
+ _SENT_SPLIT = re.compile(r"(?<=[.!?])\s+(?=[A-Z0-9\"'\(])|\n+")
21
+
22
+
23
+ class Extractor(Protocol):
24
+ def extract(self, doc: Document) -> list[Fact]: ...
25
+
26
+
27
+ def coerce_importance(value: Any) -> int:
28
+ try:
29
+ v = int(value)
30
+ except (TypeError, ValueError):
31
+ return 3
32
+ return max(1, min(5, v))
33
+
34
+
35
+ def parse_fact_json(raw: str, doc: Document, chunk_index: int = 0) -> list[Fact]:
36
+ text = raw.strip()
37
+ if text.startswith("```"):
38
+ text = re.sub(r"^```[a-zA-Z]*\n?", "", text)
39
+ text = re.sub(r"\n?```$", "", text)
40
+ m = re.search(r"\[[\s\S]*\]", text)
41
+ if m:
42
+ text = m.group(0)
43
+ try:
44
+ data = json.loads(text)
45
+ except json.JSONDecodeError:
46
+ return []
47
+ if not isinstance(data, list):
48
+ return []
49
+ url = source_url_of(doc)
50
+ out: list[Fact] = []
51
+ for item in data:
52
+ if not isinstance(item, dict):
53
+ continue
54
+ ftext = str(item.get("text", "")).strip()
55
+ if not ftext:
56
+ continue
57
+ out.append(
58
+ Fact(
59
+ text=ftext,
60
+ importance=coerce_importance(item.get("importance")),
61
+ category=str(item.get("category", "fact")).strip().lower() or "fact",
62
+ source_url=url,
63
+ chunk_id=f"{chunk_index}:::{ftext[:24]}",
64
+ metadata={"title": doc.metadata["title"]} if doc.metadata.get("title") else {},
65
+ )
66
+ )
67
+ return out
68
+
69
+
70
+ @dataclass
71
+ class HeuristicExtractor:
72
+ """Zero-key extractor: rule-based importance + category guess from the raw text."""
73
+
74
+ min_importance: int = 1
75
+ rules: list[tuple[int, re.Pattern[str]]] = field(
76
+ default_factory=lambda: [
77
+ (4, re.compile(r"\$\s?\d|\b\d+(\.\d+)?\s?(usd|eur|gbp|mb|gb|ms|s|rpm|tpm)\b|per month|per year|/mo|/yr", re.I)),
78
+ (3, re.compile(r"\b\d+(\.\d+)?%|\bfree\b|\bunlimited\b|\b\d+\s?(api|key|seat|user|request|token)", re.I)),
79
+ (2, re.compile(r"\bmust|required|requires|always|never|only|guarantee|warranty|support(ed)?\b", re.I)),
80
+ (2, re.compile(r"\b(https?://|www\.)\S+|[\w.+-]+@[\w-]+\.[a-z]{2,}", re.I)),
81
+ (1, re.compile(r"\b(available|launch(ed)?|founded|released|version)\b", re.I)),
82
+ (1, re.compile(r"\b\d+(\.\d+)?\b")),
83
+ ]
84
+ )
85
+
86
+ def _guess_category(self, text: str) -> str:
87
+ t = text.lower()
88
+ if re.search(r"\$|price|cost|plan|billing|invoice|[¥€£]", t):
89
+ return "pricing"
90
+ if re.search(r"@[\w.-]+|email|phone|address|contact", t):
91
+ return "contact"
92
+ if re.search(r"\b\d+\s?(rpm|tpm|qps|limit|quota|max|requests? per)\b", t):
93
+ return "limit"
94
+ if re.search(r"https?://|api|endpoint|sdk|library", t):
95
+ return "api"
96
+ if re.search(r"feature|dashboard|supports?|includes?", t):
97
+ return "feature"
98
+ if re.search(r"privacy|gdpr|terms|policy|retention", t):
99
+ return "policy"
100
+ if re.search(r"\d{4}-\d{2}-\d{2}|\b(19|20)\d{2}\b", t):
101
+ return "date"
102
+ return "fact"
103
+
104
+ def extract(self, doc: Document) -> list[Fact]:
105
+ url = source_url_of(doc)
106
+ out: list[Fact] = []
107
+ seen: set[str] = set()
108
+ for sentence in _SENT_SPLIT.split(doc.page_content):
109
+ s = sentence.strip()
110
+ if len(s) < 12:
111
+ continue
112
+ score = 0
113
+ for weight, pattern in self.rules:
114
+ if pattern.search(s):
115
+ score += weight
116
+ if score == 0:
117
+ continue
118
+ importance = max(self.min_importance, (score + 3) // 2)
119
+ key = re.sub(r"[^a-z0-9]+", " ", s.lower()).strip()[:120]
120
+ if key in seen:
121
+ continue
122
+ seen.add(key)
123
+ out.append(
124
+ Fact(
125
+ text=s,
126
+ importance=min(5, importance),
127
+ category=self._guess_category(s),
128
+ source_url=url,
129
+ chunk_id=f"0:::{key[:24]}",
130
+ metadata={"title": doc.metadata["title"]} if doc.metadata.get("title") else {},
131
+ )
132
+ )
133
+ return out
134
+
135
+
136
+ @dataclass
137
+ class LLMExtractor:
138
+ """OpenAI-compatible /chat/completions extractor. Requires api_key + base_url."""
139
+
140
+ api_key: str
141
+ base_url: str
142
+ model: str = "gpt-4o-mini"
143
+ system_prompt: str = DEFAULT_LLM_SYSTEM_PROMPT
144
+ batch_size: int = 6
145
+ concurrency: int = 3
146
+ timeout: float = 60.0
147
+ max_retries: int = 3
148
+ temperature: float = 0.0
149
+ chunk_char_budget: int = 6000
150
+
151
+ def _call(self, texts: list[str]) -> list[Fact]:
152
+ joined = "\n\n".join(f"[chunk {i}]\n{t}" for i, t in enumerate(texts))
153
+ payload = json.dumps(
154
+ {
155
+ "model": self.model,
156
+ "temperature": self.temperature,
157
+ "messages": [
158
+ {"role": "system", "content": self.system_prompt},
159
+ {"role": "user", "content": f"Extract facts:\n\n{joined[: self.chunk_char_budget]}"},
160
+ ],
161
+ }
162
+ ).encode("utf-8")
163
+ url = self.base_url.rstrip("/") + "/chat/completions"
164
+ last_err: Exception | None = None
165
+ for attempt in range(self.max_retries):
166
+ req = urllib.request.Request(
167
+ url,
168
+ data=payload,
169
+ headers={"content-type": "application/json", "authorization": f"Bearer {self.api_key}"},
170
+ method="POST",
171
+ )
172
+ try:
173
+ with urllib.request.urlopen(req, timeout=self.timeout) as resp: # noqa: S310
174
+ data = json.loads(resp.read().decode("utf-8"))
175
+ content = data["choices"][0]["message"]["content"]
176
+ return parse_fact_json(content, Document(page_content=joined), 0)
177
+ except Exception as exc: # noqa: BLE001
178
+ last_err = exc
179
+ if attempt < self.max_retries - 1:
180
+ import time
181
+
182
+ time.sleep(2**attempt)
183
+ continue
184
+ break
185
+ raise RuntimeError(f"LLM extraction failed: {last_err}")
186
+
187
+ def extract(self, doc: Document) -> list[Fact]:
188
+ text = doc.page_content
189
+ slices: list[str] = []
190
+ start = 0
191
+ while start < len(text):
192
+ end = min(len(text), start + self.chunk_char_budget)
193
+ if end < len(text):
194
+ nl = text.rfind("\n", start, end)
195
+ if nl > start + self.chunk_char_budget // 2:
196
+ end = nl
197
+ slices.append(text[start:end])
198
+ start = end
199
+ all_facts: list[Fact] = []
200
+ with ThreadPoolExecutor(max_workers=self.concurrency) as pool:
201
+ for batch_facts in pool.map(self._call_slices, [slices[i : i + self.batch_size] for i in range(0, len(slices), self.batch_size)]):
202
+ all_facts.extend(batch_facts)
203
+ url = source_url_of(doc)
204
+ for i, fact in enumerate(all_facts):
205
+ fact.source_url = url
206
+ fact.chunk_id = fact.chunk_id or f"{i}:::{fact.text[:24]}"
207
+ return all_facts
208
+
209
+ def _call_slices(self, batch: list[str]) -> list[Fact]:
210
+ return self._call(batch)
211
+
212
+
213
+ def chunks_of(text: str, size: int = 2000) -> list[str]:
214
+ return [text[i : i + size] for i in range(0, len(text), size)] or [text]
@@ -0,0 +1,223 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from abc import ABC, abstractmethod
5
+
6
+ from .documents import Document, Metadata, source_url_of
7
+
8
+ FACTAG_USER_AGENT = "FactAugmentedGeneration/0.1 (+https://github.com/factag-generator/factag)"
9
+
10
+ _NAMED = {
11
+ "amp": "&", "lt": "<", "gt": ">", "quot": '"', "apos": "'", "nbsp": " ",
12
+ "ndash": "–", "mdash": "—", "hellip": "…", "copy": "©", "reg": "®",
13
+ "trade": "™", "euro": "€", "pound": "£", "yen": "¥", "middot": "·", "bull": "•",
14
+ }
15
+
16
+ _DROP_ALWAYS = re.compile(
17
+ r"<(script|style|head|title|noscript|template|svg|iframe|canvas|form|button|select|textarea)[^>]*>[\s\S]*?</\1\s*>",
18
+ re.I,
19
+ )
20
+ _DROP_ALWAYS_SELF = re.compile(r"<(hr|br|img|input|meta|link|source|track|wbr|col)\b[^>]*\/?>", re.I)
21
+ _DROP_CHROME = re.compile(r"<(nav|footer|aside|header|dialog|menu)[^>]*>[\s\S]*?</\1\s*>", re.I)
22
+ _COMMENTS = re.compile(r"<!--[\s\S]*?-->")
23
+ _HEADING_OPEN = re.compile(r"<h([1-6])[^>]*>", re.I)
24
+ _HEADING_CLOSE = re.compile(r"</h[1-6]\s*>", re.I)
25
+ _LI_OPEN = re.compile(r"<li[^>]*>", re.I)
26
+ _BLOCK_CLOSE = re.compile(r"</(p|div|section|article|ul|ol|table|tbody|thead|tr|blockquote|pre|figure|figcaption|details|summary|dl|dt|dd)\s*>", re.I)
27
+ _BLOCK_OPEN = re.compile(
28
+ r"<(p|div|section|article|ul|ol|table|tbody|thead|tr|blockquote|pre|figure|figcaption|details|summary|dl|dt|dd|td|th|caption)\b[^>]*>",
29
+ re.I,
30
+ )
31
+ _ANY_TAG = re.compile(r"<[^>]+>")
32
+ _TITLE = re.compile(r"<title[^>]*>([\s\S]*?)</title>", re.I)
33
+
34
+
35
+ def decode_entities(text: str) -> str:
36
+ def hex_sub(m: re.Match[str]) -> str:
37
+ return chr(int(m.group(1), 16))
38
+
39
+ def dec_sub(m: re.Match[str]) -> str:
40
+ return chr(int(m.group(1)))
41
+
42
+ def name_sub(m: re.Match[str]) -> str:
43
+ return _NAMED.get(m.group(1).lower(), m.group(0))
44
+
45
+ text = re.sub(r"&#x([0-9a-f]+);", hex_sub, text, flags=re.I)
46
+ text = re.sub(r"&#(\d+);", dec_sub, text)
47
+ return re.sub(r"&([a-z]+);", name_sub, text, flags=re.I)
48
+
49
+
50
+ def extract_title(html: str) -> str:
51
+ m = _TITLE.search(html)
52
+ return re.sub(r"\s+", " ", decode_entities(m.group(1))).strip() if m else ""
53
+
54
+
55
+ def html_to_text(html: str, remove_chrome: bool = True) -> str:
56
+ out = _COMMENTS.sub("", html)
57
+ out = _DROP_ALWAYS.sub("", out)
58
+ out = _DROP_ALWAYS_SELF.sub(" ", out)
59
+ if remove_chrome:
60
+ out = _DROP_CHROME.sub("", out)
61
+ out = _HEADING_OPEN.sub(lambda m: "\n" + "#" * int(m.group(1)) + " ", out)
62
+ out = _HEADING_CLOSE.sub("\n", out)
63
+ out = _LI_OPEN.sub("\n- ", out)
64
+ out = _BLOCK_CLOSE.sub("\n", out)
65
+ out = _BLOCK_OPEN.sub("\n", out)
66
+ out = re.sub(r"<br\s*/?>", "\n", out, flags=re.I)
67
+ out = _ANY_TAG.sub("", out)
68
+ out = out.replace("&nbsp;", " ")
69
+ out = decode_entities(out)
70
+ lines = [re.sub(r"[ \t]+", " ", line).strip() for line in out.split("\n")]
71
+ out = "\n".join(lines)
72
+ out = re.sub(r"\n{3,}", "\n\n", out)
73
+ return out.strip()
74
+
75
+
76
+ def looks_like_html(content_type: str | None, sample: str) -> bool:
77
+ ct = (content_type or "").lower()
78
+ if "html" in ct or "xhtml" in ct:
79
+ return True
80
+ if "markdown" in ct or "text/plain" in ct:
81
+ return False
82
+ return bool(re.match(r"\s*(<!doctype html|<html|<head|<body|<h1|<div|<p>)", sample, re.I))
83
+
84
+
85
+ class BaseLoader(ABC):
86
+ @abstractmethod
87
+ def load(self) -> list[Document]:
88
+ raise NotImplementedError
89
+
90
+
91
+ def _fetch(url: str, timeout: float = 15.0) -> tuple[bytes, str | None]:
92
+ import urllib.request
93
+
94
+ req = urllib.request.Request(url, headers={"user-agent": FACTAG_USER_AGENT})
95
+ with urllib.request.urlopen(req, timeout=timeout) as resp: # noqa: S310
96
+ return resp.read(), resp.headers.get("content-type")
97
+
98
+
99
+ class TextLoader(BaseLoader):
100
+ def __init__(self, content: str, metadata: Metadata | None = None, fmt: str = "auto") -> None:
101
+ self.content = content
102
+ self.metadata = metadata or {}
103
+ self.fmt = fmt
104
+
105
+ def load(self) -> list[Document]:
106
+ is_html = self.fmt == "html" or (
107
+ self.fmt == "auto" and looks_like_html(None, self.content[:1000])
108
+ )
109
+ text = html_to_text(self.content) if is_html else self.content.strip()
110
+ meta: Metadata = {"source": "inline", "url": "inline", **self.metadata}
111
+ return [Document(page_content=text, metadata=meta)]
112
+
113
+
114
+ class URLLoader(BaseLoader):
115
+ def __init__(self, url: str, metadata: Metadata | None = None, timeout: float = 15.0) -> None:
116
+ self.url = url
117
+ self.metadata = metadata or {}
118
+ self.timeout = timeout
119
+
120
+ def load(self) -> list[Document]:
121
+ body_bytes, content_type = _fetch(self.url, self.timeout)
122
+ body = body_bytes.decode("utf-8", errors="replace")
123
+ meta: Metadata = {"source": self.url, "url": self.url, **self.metadata}
124
+ if looks_like_html(content_type, body[:500]):
125
+ title = extract_title(body)
126
+ if title:
127
+ meta["title"] = title
128
+ return [Document(page_content=html_to_text(body), metadata=meta)]
129
+ return [Document(page_content=body.strip(), metadata=meta)]
130
+
131
+
132
+ class SitemapLoader(BaseLoader):
133
+ def __init__(self, sitemap_url: str, max_pages: int = 50, timeout: float = 15.0) -> None:
134
+ self.sitemap_url = sitemap_url
135
+ self.max_pages = max_pages
136
+ self.timeout = timeout
137
+
138
+ def _discover(self, url: str, depth: int = 0) -> list[str]:
139
+ body_bytes, _ = _fetch(url, self.timeout)
140
+ xml = body_bytes.decode("utf-8", errors="replace")
141
+ locs = [m.strip() for m in re.findall(r"<loc>\s*([\s\S]*?)\s*</loc>", xml, re.I)]
142
+ if re.search(r"<sitemapindex[\s>]", xml, re.I) and depth < 2:
143
+ out: list[str] = []
144
+ for child in locs[:20]:
145
+ try:
146
+ out.extend(self._discover(child, depth + 1))
147
+ except Exception:
148
+ continue
149
+ return out
150
+ return locs
151
+
152
+ def load(self) -> list[Document]:
153
+ found = self._discover(self.sitemap_url)
154
+ urls = list(dict.fromkeys(u for u in found if re.match(r"^https?://", u, re.I)))[: self.max_pages]
155
+ docs: list[Document] = []
156
+ for url in urls:
157
+ try:
158
+ docs.extend(URLLoader(url, timeout=self.timeout).load())
159
+ except Exception:
160
+ continue
161
+ return docs
162
+
163
+
164
+ class DirectoryLoader(BaseLoader):
165
+ def __init__(
166
+ self,
167
+ dir_path: str,
168
+ extensions: list[str] | None = None,
169
+ recursive: bool = True,
170
+ max_files: int = 500,
171
+ metadata: Metadata | None = None,
172
+ ) -> None:
173
+ self.dir_path = dir_path
174
+ self.extensions = [e.lower() for e in (extensions or [".md", ".markdown", ".txt", ".html", ".htm"])]
175
+ self.recursive = recursive
176
+ self.max_files = max_files
177
+ self.metadata = metadata or {}
178
+
179
+ def _walk(self, root: str) -> list[str]:
180
+ import os
181
+
182
+ out: list[str] = []
183
+ for dirpath, dirnames, filenames in os.walk(root):
184
+ dirnames[:] = [d for d in dirnames if not d.startswith(".")]
185
+ for name in filenames:
186
+ out.append(os.path.join(dirpath, name))
187
+ if len(out) >= self.max_files:
188
+ return out
189
+ if not self.recursive:
190
+ break
191
+ return out
192
+
193
+ def load(self) -> list[Document]:
194
+ import os
195
+ import pathlib
196
+
197
+ target = os.path.abspath(self.dir_path)
198
+ if os.path.isfile(target):
199
+ files = [target]
200
+ else:
201
+ files = self._walk(target)
202
+ docs: list[Document] = []
203
+ for path in files:
204
+ ext = os.path.splitext(path)[1].lower()
205
+ if ext not in self.extensions:
206
+ continue
207
+ raw = pathlib.Path(path).read_text(encoding="utf-8", errors="replace")
208
+ content = html_to_text(raw) if ext in (".html", ".htm") else raw.strip()
209
+ if not content:
210
+ continue
211
+ file_url = pathlib.Path(path).as_uri()
212
+ meta: Metadata = {
213
+ "source": file_url,
214
+ "url": file_url,
215
+ "title": os.path.basename(path),
216
+ **self.metadata,
217
+ }
218
+ docs.append(Document(page_content=content, metadata=meta))
219
+ return docs
220
+
221
+
222
+ def source_url_of_doc(doc: Document) -> str:
223
+ return source_url_of(doc)