windlass 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.
- windlass/__init__.py +243 -0
- windlass/_version.py +7 -0
- windlass/agent/__init__.py +44 -0
- windlass/agent/builder.py +709 -0
- windlass/agent/checkpoint.py +330 -0
- windlass/agent/graph.py +410 -0
- windlass/agent/runtime.py +633 -0
- windlass/agent/supervisor.py +373 -0
- windlass/api.py +469 -0
- windlass/cli.py +389 -0
- windlass/core/__init__.py +145 -0
- windlass/core/cache.py +336 -0
- windlass/core/concurrency.py +341 -0
- windlass/core/config.py +461 -0
- windlass/core/container.py +383 -0
- windlass/core/exceptions.py +465 -0
- windlass/core/lazy.py +208 -0
- windlass/core/logging.py +204 -0
- windlass/core/registry.py +626 -0
- windlass/core/retry.py +288 -0
- windlass/core/text.py +505 -0
- windlass/core/types.py +671 -0
- windlass/core/vectors.py +306 -0
- windlass/interfaces/__init__.py +81 -0
- windlass/interfaces/base.py +191 -0
- windlass/interfaces/chunker.py +279 -0
- windlass/interfaces/embedding.py +259 -0
- windlass/interfaces/evaluator.py +264 -0
- windlass/interfaces/guardrail.py +289 -0
- windlass/interfaces/llm.py +410 -0
- windlass/interfaces/loader.py +349 -0
- windlass/interfaces/mcp.py +312 -0
- windlass/interfaces/memory.py +178 -0
- windlass/interfaces/preprocessor.py +195 -0
- windlass/interfaces/retriever.py +245 -0
- windlass/interfaces/tool.py +339 -0
- windlass/interfaces/tracer.py +366 -0
- windlass/interfaces/vectordb.py +340 -0
- windlass/providers/__init__.py +642 -0
- windlass/providers/chunkers/__init__.py +7 -0
- windlass/providers/chunkers/hierarchical.py +254 -0
- windlass/providers/chunkers/recursive.py +260 -0
- windlass/providers/chunkers/semantic.py +204 -0
- windlass/providers/chunkers/structural.py +323 -0
- windlass/providers/embeddings/__init__.py +7 -0
- windlass/providers/embeddings/hash.py +127 -0
- windlass/providers/embeddings/hf_inference.py +284 -0
- windlass/providers/embeddings/huggingface.py +187 -0
- windlass/providers/embeddings/openai.py +126 -0
- windlass/providers/evaluation/__init__.py +7 -0
- windlass/providers/evaluation/builtin.py +428 -0
- windlass/providers/evaluation/external.py +356 -0
- windlass/providers/guardrails/__init__.py +7 -0
- windlass/providers/guardrails/nemo.py +241 -0
- windlass/providers/guardrails/rules.py +241 -0
- windlass/providers/llm/__init__.py +9 -0
- windlass/providers/llm/anthropic.py +355 -0
- windlass/providers/llm/fake.py +271 -0
- windlass/providers/llm/gemini.py +291 -0
- windlass/providers/llm/groq.py +361 -0
- windlass/providers/llm/ollama.py +313 -0
- windlass/providers/llm/openai.py +424 -0
- windlass/providers/loaders/__init__.py +7 -0
- windlass/providers/loaders/media.py +296 -0
- windlass/providers/loaders/office.py +511 -0
- windlass/providers/loaders/text.py +507 -0
- windlass/providers/loaders/web.py +452 -0
- windlass/providers/mcp/__init__.py +7 -0
- windlass/providers/mcp/fastmcp.py +635 -0
- windlass/providers/memory/__init__.py +7 -0
- windlass/providers/memory/conversation.py +322 -0
- windlass/providers/memory/longterm.py +304 -0
- windlass/providers/observability/__init__.py +7 -0
- windlass/providers/observability/console.py +259 -0
- windlass/providers/observability/multi.py +160 -0
- windlass/providers/observability/platforms.py +424 -0
- windlass/providers/preprocessors/__init__.py +7 -0
- windlass/providers/preprocessors/clean.py +306 -0
- windlass/providers/preprocessors/dedup.py +321 -0
- windlass/providers/preprocessors/enrich.py +303 -0
- windlass/providers/preprocessors/privacy.py +307 -0
- windlass/providers/retrievers/__init__.py +7 -0
- windlass/providers/retrievers/bm25.py +365 -0
- windlass/providers/retrievers/contextual.py +326 -0
- windlass/providers/retrievers/hybrid.py +255 -0
- windlass/providers/retrievers/vector.py +247 -0
- windlass/providers/vectordb/__init__.py +7 -0
- windlass/providers/vectordb/chroma.py +382 -0
- windlass/providers/vectordb/faiss.py +445 -0
- windlass/providers/vectordb/memory.py +361 -0
- windlass/providers/vectordb/pinecone.py +442 -0
- windlass/py.typed +1 -0
- windlass/rag/__init__.py +34 -0
- windlass/rag/builder.py +739 -0
- windlass/rag/loading.py +250 -0
- windlass/rag/pipeline.py +854 -0
- windlass/testing.py +376 -0
- windlass/tools/__init__.py +458 -0
- windlass/tools/schema.py +417 -0
- windlass-0.1.0.dist-info/METADATA +679 -0
- windlass-0.1.0.dist-info/RECORD +104 -0
- windlass-0.1.0.dist-info/WHEEL +4 -0
- windlass-0.1.0.dist-info/entry_points.txt +2 -0
- windlass-0.1.0.dist-info/licenses/LICENSE +205 -0
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
"""HTML, web-page and YouTube loaders.
|
|
2
|
+
|
|
3
|
+
The HTML loader works with no dependencies (falling back to a regex-based tag
|
|
4
|
+
stripper) but does a considerably better job with BeautifulSoup installed. The
|
|
5
|
+
web loader adds HTTP fetching, and the YouTube loader pulls transcripts.
|
|
6
|
+
|
|
7
|
+
Install the optional pieces with::
|
|
8
|
+
|
|
9
|
+
pip install "windlass[loaders]"
|
|
10
|
+
|
|
11
|
+
Example:
|
|
12
|
+
>>> HTMLLoader().load(b"<html><body><p>Hi</p></body></html>")[0].content
|
|
13
|
+
'Hi'
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import re
|
|
19
|
+
from typing import Any
|
|
20
|
+
from urllib.parse import urlparse
|
|
21
|
+
|
|
22
|
+
import httpx
|
|
23
|
+
|
|
24
|
+
from windlass.core.concurrency import gather_bounded, to_thread
|
|
25
|
+
from windlass.core.config import settings
|
|
26
|
+
from windlass.core.exceptions import IngestionError
|
|
27
|
+
from windlass.core.lazy import is_available, require
|
|
28
|
+
from windlass.core.registry import register
|
|
29
|
+
from windlass.core.text import normalize_whitespace, strip_html
|
|
30
|
+
from windlass.core.types import Document
|
|
31
|
+
from windlass.interfaces.loader import Loader, SourceLike
|
|
32
|
+
|
|
33
|
+
__all__ = ["HTMLLoader", "WebLoader", "YouTubeLoader"]
|
|
34
|
+
|
|
35
|
+
#: Elements whose text is never useful as document content.
|
|
36
|
+
_NOISE_TAGS = ("script", "style", "nav", "footer", "header", "aside", "noscript", "form")
|
|
37
|
+
|
|
38
|
+
#: Elements that usually hold the article body, tried in order.
|
|
39
|
+
_CONTENT_SELECTORS = ("article", "main", "[role=main]", "#content", ".content", ".post", "body")
|
|
40
|
+
|
|
41
|
+
_YOUTUBE_ID_RE = re.compile(
|
|
42
|
+
r"(?:youtube\.com/(?:watch\?v=|embed/|shorts/|live/)|youtu\.be/)([A-Za-z0-9_-]{11})"
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@register.loader(
|
|
47
|
+
"html",
|
|
48
|
+
aliases=("htm",),
|
|
49
|
+
description="HTML files, with boilerplate stripped.",
|
|
50
|
+
)
|
|
51
|
+
class HTMLLoader(Loader):
|
|
52
|
+
"""Extracts readable text from HTML.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
selector: CSS selector for the content region. When omitted the loader
|
|
56
|
+
tries :data:`_CONTENT_SELECTORS` in order.
|
|
57
|
+
remove_tags: Elements to strip entirely before extraction.
|
|
58
|
+
keep_links: Render links as ``text (url)`` so the model can cite them.
|
|
59
|
+
**config: Forwarded to :class:`~windlass.interfaces.loader.Loader`.
|
|
60
|
+
|
|
61
|
+
Note:
|
|
62
|
+
Works without ``beautifulsoup4`` using a regex stripper, which is fine
|
|
63
|
+
for well-formed markup. Install ``windlass[loaders]`` for real
|
|
64
|
+
parsing, boilerplate removal and CSS selectors.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
provider_name = "html"
|
|
68
|
+
extensions: tuple[str, ...] = (".html", ".htm", ".xhtml")
|
|
69
|
+
mimetypes = ("text/html",)
|
|
70
|
+
|
|
71
|
+
def __init__(
|
|
72
|
+
self,
|
|
73
|
+
*,
|
|
74
|
+
selector: str | None = None,
|
|
75
|
+
remove_tags: tuple[str, ...] = _NOISE_TAGS,
|
|
76
|
+
keep_links: bool = False,
|
|
77
|
+
**config: Any,
|
|
78
|
+
) -> None:
|
|
79
|
+
super().__init__(**config)
|
|
80
|
+
self.selector = selector
|
|
81
|
+
self.remove_tags = remove_tags
|
|
82
|
+
self.keep_links = keep_links
|
|
83
|
+
|
|
84
|
+
async def aload_source(self, source: SourceLike) -> list[Document]:
|
|
85
|
+
"""Extract text from one HTML document.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
source: A path or a bytes payload.
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
A single-element list, empty when no text could be extracted.
|
|
92
|
+
"""
|
|
93
|
+
raw = self._read_bytes(source)
|
|
94
|
+
html = raw.decode("utf-8", errors="replace")
|
|
95
|
+
path = str(source) if not isinstance(source, bytes) else None
|
|
96
|
+
base = self._base_metadata(source) if not isinstance(source, bytes) else {}
|
|
97
|
+
text, meta = await to_thread(self.extract, html)
|
|
98
|
+
if not text.strip():
|
|
99
|
+
return []
|
|
100
|
+
return [
|
|
101
|
+
Document(
|
|
102
|
+
content=text,
|
|
103
|
+
metadata={**base, **meta},
|
|
104
|
+
source=path,
|
|
105
|
+
mimetype="text/html",
|
|
106
|
+
)
|
|
107
|
+
]
|
|
108
|
+
|
|
109
|
+
def extract(self, html: str) -> tuple[str, dict[str, Any]]:
|
|
110
|
+
"""Turn HTML into text plus metadata.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
html: The HTML source.
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
A ``(text, metadata)`` pair. Metadata may include ``title``,
|
|
117
|
+
``description``, ``headings`` and ``links``.
|
|
118
|
+
|
|
119
|
+
Example:
|
|
120
|
+
>>> text, meta = HTMLLoader().extract("<title>T</title><p>Body</p>")
|
|
121
|
+
>>> meta["title"], text
|
|
122
|
+
('T', 'Body')
|
|
123
|
+
"""
|
|
124
|
+
if not is_available("bs4"):
|
|
125
|
+
title = re.search(r"<title[^>]*>(.*?)</title>", html, re.IGNORECASE | re.DOTALL)
|
|
126
|
+
fallback: dict[str, Any] = (
|
|
127
|
+
{"title": normalize_whitespace(title.group(1))} if title else {}
|
|
128
|
+
)
|
|
129
|
+
return strip_html(html), fallback
|
|
130
|
+
|
|
131
|
+
bs4 = require("bs4", extra="loaders", feature="The HTML loader")
|
|
132
|
+
parser = "lxml" if is_available("lxml") else "html.parser"
|
|
133
|
+
soup = bs4.BeautifulSoup(html, parser)
|
|
134
|
+
|
|
135
|
+
meta: dict[str, Any] = {}
|
|
136
|
+
if soup.title and soup.title.string:
|
|
137
|
+
meta["title"] = normalize_whitespace(str(soup.title.string))
|
|
138
|
+
description = soup.find("meta", attrs={"name": "description"})
|
|
139
|
+
if description and description.get("content"):
|
|
140
|
+
meta["description"] = normalize_whitespace(str(description["content"]))
|
|
141
|
+
|
|
142
|
+
for tag in soup(list(self.remove_tags)):
|
|
143
|
+
tag.decompose()
|
|
144
|
+
|
|
145
|
+
root = None
|
|
146
|
+
selectors = [self.selector] if self.selector else list(_CONTENT_SELECTORS)
|
|
147
|
+
for candidate in selectors:
|
|
148
|
+
if not candidate:
|
|
149
|
+
continue
|
|
150
|
+
found = soup.select_one(candidate)
|
|
151
|
+
if found is not None and found.get_text(strip=True):
|
|
152
|
+
root = found
|
|
153
|
+
break
|
|
154
|
+
root = root or soup
|
|
155
|
+
|
|
156
|
+
headings = [
|
|
157
|
+
normalize_whitespace(h.get_text())
|
|
158
|
+
for h in root.find_all(["h1", "h2", "h3"])
|
|
159
|
+
if h.get_text(strip=True)
|
|
160
|
+
]
|
|
161
|
+
if headings:
|
|
162
|
+
meta["headings"] = headings[:50]
|
|
163
|
+
|
|
164
|
+
if self.keep_links:
|
|
165
|
+
links = []
|
|
166
|
+
for anchor in root.find_all("a", href=True):
|
|
167
|
+
label = normalize_whitespace(anchor.get_text())
|
|
168
|
+
if label:
|
|
169
|
+
anchor.replace_with(f"{label} ({anchor['href']})")
|
|
170
|
+
links.append(anchor["href"])
|
|
171
|
+
if links:
|
|
172
|
+
meta["links"] = links[:100]
|
|
173
|
+
|
|
174
|
+
return normalize_whitespace(root.get_text(separator="\n")), meta
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@register.loader(
|
|
178
|
+
"web",
|
|
179
|
+
aliases=("url", "http"),
|
|
180
|
+
description="Fetches and extracts text from web pages.",
|
|
181
|
+
)
|
|
182
|
+
class WebLoader(HTMLLoader):
|
|
183
|
+
"""Fetches URLs over HTTP and extracts their text.
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
headers: Extra request headers. A browser-like ``User-Agent`` is sent by
|
|
187
|
+
default, because many sites reject the Python default.
|
|
188
|
+
follow_redirects: Follow 3xx responses.
|
|
189
|
+
timeout: Per-request timeout in seconds.
|
|
190
|
+
max_bytes: Refuse responses larger than this, so one enormous page
|
|
191
|
+
cannot exhaust memory.
|
|
192
|
+
**config: Forwarded to :class:`HTMLLoader`.
|
|
193
|
+
|
|
194
|
+
Raises:
|
|
195
|
+
IngestionError: On a network failure or a non-2xx response.
|
|
196
|
+
|
|
197
|
+
Performance:
|
|
198
|
+
URLs are fetched concurrently by the base class, bounded by the global
|
|
199
|
+
``max_concurrency`` setting. Be considerate of the sites you crawl.
|
|
200
|
+
"""
|
|
201
|
+
|
|
202
|
+
provider_name = "web"
|
|
203
|
+
handles_urls = True
|
|
204
|
+
extensions: tuple[str, ...] = ()
|
|
205
|
+
|
|
206
|
+
def __init__(
|
|
207
|
+
self,
|
|
208
|
+
*,
|
|
209
|
+
headers: dict[str, str] | None = None,
|
|
210
|
+
follow_redirects: bool = True,
|
|
211
|
+
timeout: float | None = None,
|
|
212
|
+
max_bytes: int = 10_000_000,
|
|
213
|
+
**config: Any,
|
|
214
|
+
) -> None:
|
|
215
|
+
super().__init__(**config)
|
|
216
|
+
self.headers = {
|
|
217
|
+
"User-Agent": "Mozilla/5.0 (compatible; WindlassBot/0.1; +https://github.com/windlass)",
|
|
218
|
+
"Accept": "text/html,application/xhtml+xml",
|
|
219
|
+
**(headers or {}),
|
|
220
|
+
}
|
|
221
|
+
self.follow_redirects = follow_redirects
|
|
222
|
+
self.timeout = timeout or settings().request_timeout
|
|
223
|
+
self.max_bytes = max_bytes
|
|
224
|
+
|
|
225
|
+
@classmethod
|
|
226
|
+
def can_handle(cls, source: SourceLike) -> bool:
|
|
227
|
+
"""Return whether ``source`` looks like an HTTP(S) URL."""
|
|
228
|
+
return isinstance(source, str) and source.startswith(("http://", "https://"))
|
|
229
|
+
|
|
230
|
+
async def aload_source(self, source: SourceLike) -> list[Document]:
|
|
231
|
+
"""Fetch a URL and extract its text.
|
|
232
|
+
|
|
233
|
+
Args:
|
|
234
|
+
source: The URL to fetch.
|
|
235
|
+
|
|
236
|
+
Returns:
|
|
237
|
+
A single-element list, empty when the page had no extractable text.
|
|
238
|
+
|
|
239
|
+
Raises:
|
|
240
|
+
IngestionError: On a network error or an HTTP error status.
|
|
241
|
+
"""
|
|
242
|
+
url = str(source)
|
|
243
|
+
try:
|
|
244
|
+
async with httpx.AsyncClient(
|
|
245
|
+
headers=self.headers,
|
|
246
|
+
follow_redirects=self.follow_redirects,
|
|
247
|
+
timeout=self.timeout,
|
|
248
|
+
) as client:
|
|
249
|
+
response = await client.get(url)
|
|
250
|
+
response.raise_for_status()
|
|
251
|
+
if len(response.content) > self.max_bytes:
|
|
252
|
+
raise IngestionError(
|
|
253
|
+
f"{url} returned {len(response.content)} bytes, "
|
|
254
|
+
f"over the {self.max_bytes} limit."
|
|
255
|
+
)
|
|
256
|
+
html = response.text
|
|
257
|
+
except httpx.HTTPStatusError as exc:
|
|
258
|
+
raise IngestionError(
|
|
259
|
+
f"{url} returned HTTP {exc.response.status_code}.",
|
|
260
|
+
context={"url": url, "status": exc.response.status_code},
|
|
261
|
+
) from exc
|
|
262
|
+
except httpx.HTTPError as exc:
|
|
263
|
+
raise IngestionError(f"Could not fetch {url}: {exc}", context={"url": url}) from exc
|
|
264
|
+
|
|
265
|
+
text, meta = await to_thread(self.extract, html)
|
|
266
|
+
if not text.strip():
|
|
267
|
+
return []
|
|
268
|
+
parsed = urlparse(url)
|
|
269
|
+
return [
|
|
270
|
+
Document(
|
|
271
|
+
content=text,
|
|
272
|
+
metadata={**meta, "url": url, "domain": parsed.netloc, "source": url},
|
|
273
|
+
source=url,
|
|
274
|
+
mimetype="text/html",
|
|
275
|
+
)
|
|
276
|
+
]
|
|
277
|
+
|
|
278
|
+
async def acrawl(self, urls: list[str], *, concurrency: int | None = None) -> list[Document]:
|
|
279
|
+
"""Fetch several URLs concurrently.
|
|
280
|
+
|
|
281
|
+
Args:
|
|
282
|
+
urls: The URLs to fetch.
|
|
283
|
+
concurrency: Maximum simultaneous requests.
|
|
284
|
+
|
|
285
|
+
Returns:
|
|
286
|
+
Every document that loaded successfully; failures are logged and
|
|
287
|
+
skipped.
|
|
288
|
+
"""
|
|
289
|
+
limit = concurrency or settings().max_concurrency
|
|
290
|
+
results = await gather_bounded(
|
|
291
|
+
[self.aload_source(u) for u in urls], limit=limit, return_exceptions=True
|
|
292
|
+
)
|
|
293
|
+
documents: list[Document] = []
|
|
294
|
+
for url, result in zip(urls, results, strict=True):
|
|
295
|
+
if isinstance(result, BaseException):
|
|
296
|
+
self._log.warning("Skipping %s: %s", url, result)
|
|
297
|
+
continue
|
|
298
|
+
documents.extend(result)
|
|
299
|
+
return documents
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
@register.loader(
|
|
303
|
+
"youtube",
|
|
304
|
+
aliases=("yt",),
|
|
305
|
+
description="YouTube video transcripts.",
|
|
306
|
+
)
|
|
307
|
+
class YouTubeLoader(Loader):
|
|
308
|
+
"""Loads transcripts for YouTube videos.
|
|
309
|
+
|
|
310
|
+
Args:
|
|
311
|
+
languages: Preferred transcript languages, in order of preference.
|
|
312
|
+
chunk_by_time: Emit one document per ``segment_seconds`` window, so
|
|
313
|
+
citations carry a timestamp you can link to.
|
|
314
|
+
segment_seconds: Window length when ``chunk_by_time`` is on.
|
|
315
|
+
**config: Forwarded to :class:`~windlass.interfaces.loader.Loader`.
|
|
316
|
+
|
|
317
|
+
Raises:
|
|
318
|
+
MissingDependencyError: When ``youtube-transcript-api`` is not installed.
|
|
319
|
+
IngestionError: When the video has no accessible transcript.
|
|
320
|
+
|
|
321
|
+
Note:
|
|
322
|
+
Only videos with captions (auto-generated or human) can be loaded. There
|
|
323
|
+
is no audio transcription here — use the ``audio`` loader for that.
|
|
324
|
+
"""
|
|
325
|
+
|
|
326
|
+
provider_name = "youtube"
|
|
327
|
+
handles_urls = True
|
|
328
|
+
extensions: tuple[str, ...] = ()
|
|
329
|
+
|
|
330
|
+
def __init__(
|
|
331
|
+
self,
|
|
332
|
+
*,
|
|
333
|
+
languages: tuple[str, ...] = ("en",),
|
|
334
|
+
chunk_by_time: bool = False,
|
|
335
|
+
segment_seconds: int = 300,
|
|
336
|
+
**config: Any,
|
|
337
|
+
) -> None:
|
|
338
|
+
super().__init__(**config)
|
|
339
|
+
self.languages = tuple(languages)
|
|
340
|
+
self.chunk_by_time = chunk_by_time
|
|
341
|
+
self.segment_seconds = segment_seconds
|
|
342
|
+
|
|
343
|
+
@classmethod
|
|
344
|
+
def can_handle(cls, source: SourceLike) -> bool:
|
|
345
|
+
"""Return whether ``source`` is a YouTube URL or a bare video id."""
|
|
346
|
+
if not isinstance(source, str):
|
|
347
|
+
return False
|
|
348
|
+
return bool(_YOUTUBE_ID_RE.search(source)) or (
|
|
349
|
+
len(source) == 11 and source.replace("-", "").replace("_", "").isalnum()
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
async def aload_source(self, source: SourceLike) -> list[Document]:
|
|
353
|
+
"""Fetch one video's transcript.
|
|
354
|
+
|
|
355
|
+
Args:
|
|
356
|
+
source: A YouTube URL or an 11-character video id.
|
|
357
|
+
|
|
358
|
+
Returns:
|
|
359
|
+
One document per time segment, or one for the whole transcript.
|
|
360
|
+
|
|
361
|
+
Raises:
|
|
362
|
+
IngestionError: When no transcript is available.
|
|
363
|
+
"""
|
|
364
|
+
api = require("youtube_transcript_api", extra="loaders", feature="The YouTube loader")
|
|
365
|
+
video_id = extract_video_id(str(source))
|
|
366
|
+
|
|
367
|
+
def _fetch() -> list[dict[str, Any]]:
|
|
368
|
+
try:
|
|
369
|
+
return api.YouTubeTranscriptApi.get_transcript(
|
|
370
|
+
video_id, languages=list(self.languages)
|
|
371
|
+
)
|
|
372
|
+
except Exception as exc:
|
|
373
|
+
raise IngestionError(
|
|
374
|
+
f"No transcript available for video {video_id}: {exc}",
|
|
375
|
+
hint="The video may have captions disabled, be private, or not "
|
|
376
|
+
f"offer any of {self.languages}.",
|
|
377
|
+
context={"video_id": video_id},
|
|
378
|
+
) from exc
|
|
379
|
+
|
|
380
|
+
entries = await to_thread(_fetch)
|
|
381
|
+
url = f"https://www.youtube.com/watch?v={video_id}"
|
|
382
|
+
base = {"video_id": video_id, "url": url, "source": url}
|
|
383
|
+
|
|
384
|
+
if not self.chunk_by_time:
|
|
385
|
+
text = " ".join(e["text"].strip() for e in entries if e.get("text"))
|
|
386
|
+
duration = entries[-1]["start"] + entries[-1].get("duration", 0) if entries else 0
|
|
387
|
+
return [
|
|
388
|
+
Document(
|
|
389
|
+
content=normalize_whitespace(text),
|
|
390
|
+
metadata={**base, "duration_seconds": round(duration)},
|
|
391
|
+
source=url,
|
|
392
|
+
mimetype="text/plain",
|
|
393
|
+
)
|
|
394
|
+
]
|
|
395
|
+
|
|
396
|
+
documents: list[Document] = []
|
|
397
|
+
buffer: list[str] = []
|
|
398
|
+
window_start = 0.0
|
|
399
|
+
for entry in entries:
|
|
400
|
+
start = float(entry.get("start", 0.0))
|
|
401
|
+
if buffer and start - window_start >= self.segment_seconds:
|
|
402
|
+
documents.append(self._segment(buffer, window_start, base, url))
|
|
403
|
+
buffer, window_start = [], start
|
|
404
|
+
buffer.append(entry.get("text", "").strip())
|
|
405
|
+
if buffer:
|
|
406
|
+
documents.append(self._segment(buffer, window_start, base, url))
|
|
407
|
+
return documents
|
|
408
|
+
|
|
409
|
+
@staticmethod
|
|
410
|
+
def _segment(parts: list[str], start: float, base: dict[str, Any], url: str) -> Document:
|
|
411
|
+
"""Build one time-windowed transcript document."""
|
|
412
|
+
stamp = f"{int(start) // 60:02d}:{int(start) % 60:02d}"
|
|
413
|
+
return Document(
|
|
414
|
+
content=normalize_whitespace(" ".join(p for p in parts if p)),
|
|
415
|
+
metadata={
|
|
416
|
+
**base,
|
|
417
|
+
"start_seconds": round(start),
|
|
418
|
+
"timestamp": stamp,
|
|
419
|
+
"url": f"{url}&t={int(start)}s",
|
|
420
|
+
},
|
|
421
|
+
source=url,
|
|
422
|
+
mimetype="text/plain",
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def extract_video_id(source: str) -> str:
|
|
427
|
+
"""Pull the 11-character video id out of a YouTube URL.
|
|
428
|
+
|
|
429
|
+
Args:
|
|
430
|
+
source: A YouTube URL in any of its common shapes, or a bare id.
|
|
431
|
+
|
|
432
|
+
Returns:
|
|
433
|
+
The video id.
|
|
434
|
+
|
|
435
|
+
Raises:
|
|
436
|
+
IngestionError: When no id can be found.
|
|
437
|
+
|
|
438
|
+
Example:
|
|
439
|
+
>>> extract_video_id("https://youtu.be/dQw4w9WgXcQ")
|
|
440
|
+
'dQw4w9WgXcQ'
|
|
441
|
+
>>> extract_video_id("dQw4w9WgXcQ")
|
|
442
|
+
'dQw4w9WgXcQ'
|
|
443
|
+
"""
|
|
444
|
+
match = _YOUTUBE_ID_RE.search(source)
|
|
445
|
+
if match:
|
|
446
|
+
return match.group(1)
|
|
447
|
+
if len(source) == 11:
|
|
448
|
+
return source
|
|
449
|
+
raise IngestionError(
|
|
450
|
+
f"Could not find a YouTube video id in {source!r}.",
|
|
451
|
+
hint="Pass a watch/embed/short URL or the bare 11-character id.",
|
|
452
|
+
)
|