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.
- factag-0.1.0/.gitignore +14 -0
- factag-0.1.0/PKG-INFO +52 -0
- factag-0.1.0/README.md +30 -0
- factag-0.1.0/factag/__init__.py +64 -0
- factag-0.1.0/factag/documents.py +53 -0
- factag-0.1.0/factag/extractors.py +214 -0
- factag-0.1.0/factag/loaders.py +223 -0
- factag-0.1.0/factag/normalize.py +84 -0
- factag-0.1.0/factag/pipeline.py +87 -0
- factag-0.1.0/factag/retrievers.py +101 -0
- factag-0.1.0/factag/splitter.py +150 -0
- factag-0.1.0/factag/stores.py +84 -0
- factag-0.1.0/pyproject.toml +41 -0
- factag-0.1.0/tests/test_factag.py +177 -0
- factag-0.1.0/tests/test_regressions.py +87 -0
factag-0.1.0/.gitignore
ADDED
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(" ", " ")
|
|
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)
|