loom-notes 1.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.
@@ -0,0 +1,142 @@
1
+ """Chunking structurel : découpage par titres markdown, puis retaille des sections trop
2
+ longues en paragraphes avec chevauchement. Chaque chunk connaît son chemin de titres."""
3
+
4
+ import re
5
+ from dataclasses import dataclass
6
+
7
+ from loom_notes.models import Chunk
8
+ from loom_notes.settings import Settings
9
+
10
+ _HEADING = re.compile(r"^(#{1,6})\s+(.+?)\s*#*\s*$")
11
+ _FENCE = re.compile(r"^\s*(```|~~~)")
12
+ _PARA_SPLIT = re.compile(r"\n\s*\n")
13
+ _SENTENCE_END = re.compile(r"(?<=[.!?…])\s+")
14
+
15
+
16
+ @dataclass(slots=True)
17
+ class _Section:
18
+ heading_path: str
19
+ body: str
20
+
21
+
22
+ def chunk_document(doc_id: str, title: str, text: str, settings: Settings) -> list[Chunk]:
23
+ sections = _split_sections(title, text)
24
+ pieces: list[tuple[str, str]] = []
25
+ for section in sections:
26
+ for piece in _split_body(section.body, settings):
27
+ pieces.append((section.heading_path, piece))
28
+ pieces = _merge_tiny(pieces, settings)
29
+ return [
30
+ Chunk(doc_id=doc_id, index=i, heading_path=path, text=body)
31
+ for i, (path, body) in enumerate(pieces)
32
+ ]
33
+
34
+
35
+ def _split_sections(title: str, text: str) -> list[_Section]:
36
+ """Une section par titre rencontré ; le titre du document est la racine du chemin."""
37
+ stack: list[tuple[int, str]] = []
38
+ sections: list[_Section] = []
39
+ buffer: list[str] = []
40
+ in_fence = False
41
+
42
+ def flush() -> None:
43
+ body = "\n".join(buffer).strip()
44
+ if body:
45
+ sections.append(_Section(_path(title, stack), body))
46
+ buffer.clear()
47
+
48
+ for line in text.splitlines():
49
+ if _FENCE.match(line):
50
+ in_fence = not in_fence
51
+ buffer.append(line)
52
+ continue
53
+ m = None if in_fence else _HEADING.match(line)
54
+ if m is None:
55
+ buffer.append(line)
56
+ continue
57
+ flush()
58
+ level, heading = len(m.group(1)), m.group(2).strip()
59
+ while stack and stack[-1][0] >= level:
60
+ stack.pop()
61
+ # On ne répète pas le titre du document s'il est aussi le premier H1.
62
+ if not (level == 1 and not stack and heading == title):
63
+ stack.append((level, heading))
64
+ flush()
65
+ return sections or [_Section(title, "")]
66
+
67
+
68
+ def _path(title: str, stack: list[tuple[int, str]]) -> str:
69
+ return " > ".join([title, *(h for _, h in stack)])
70
+
71
+
72
+ def _split_body(body: str, settings: Settings) -> list[str]:
73
+ if not body:
74
+ return []
75
+ if len(body) <= settings.chunk_max_chars:
76
+ return [body]
77
+ paragraphs = [p.strip() for p in _PARA_SPLIT.split(body) if p.strip()]
78
+ # Une unité seule doit tenir dans un chunk avec le chevauchement qui la précède.
79
+ unit_max = max(settings.chunk_target_chars - settings.chunk_overlap_chars, 1)
80
+ units: list[str] = []
81
+ for p in paragraphs:
82
+ units.extend(_split_long(p, unit_max) if len(p) > unit_max else [p])
83
+
84
+ chunks: list[str] = []
85
+ current: list[str] = []
86
+ size = 0
87
+ for unit in units:
88
+ if current and size + len(unit) + 2 > settings.chunk_target_chars:
89
+ chunks.append("\n\n".join(current))
90
+ tail = _tail(chunks[-1], settings.chunk_overlap_chars)
91
+ current = [tail] if tail else []
92
+ size = len(tail)
93
+ current.append(unit)
94
+ size += len(unit) + 2
95
+ if current:
96
+ chunks.append("\n\n".join(current))
97
+ return chunks
98
+
99
+
100
+ def _split_long(paragraph: str, max_chars: int) -> list[str]:
101
+ """Un paragraphe seul plus long que max : coupe aux phrases, sinon en dur."""
102
+ out: list[str] = []
103
+ current = ""
104
+ for sentence in _SENTENCE_END.split(paragraph):
105
+ if len(sentence) > max_chars:
106
+ if current:
107
+ out.append(current)
108
+ current = ""
109
+ out.extend(sentence[i : i + max_chars] for i in range(0, len(sentence), max_chars))
110
+ continue
111
+ if current and len(current) + len(sentence) + 1 > max_chars:
112
+ out.append(current)
113
+ current = sentence
114
+ else:
115
+ current = f"{current} {sentence}".strip()
116
+ if current:
117
+ out.append(current)
118
+ return out
119
+
120
+
121
+ def _tail(text: str, overlap: int) -> str:
122
+ if overlap <= 0 or len(text) <= overlap:
123
+ return ""
124
+ tail = text[-overlap:]
125
+ cut = tail.find(" ")
126
+ return tail[cut + 1 :] if cut != -1 else tail
127
+
128
+
129
+ def _merge_tiny(pieces: list[tuple[str, str]], settings: Settings) -> list[tuple[str, str]]:
130
+ """Absorbe les morceaux trop courts dans le précédent quand ça tient."""
131
+ merged: list[tuple[str, str]] = []
132
+ for path, body in pieces:
133
+ if (
134
+ merged
135
+ and len(body) < settings.chunk_min_chars
136
+ and len(merged[-1][1]) + len(body) + 2 <= settings.chunk_max_chars
137
+ ):
138
+ prev_path, prev_body = merged[-1]
139
+ merged[-1] = (prev_path, f"{prev_body}\n\n{body}")
140
+ else:
141
+ merged.append((path, body))
142
+ return merged
@@ -0,0 +1,16 @@
1
+ """Empreinte de contenu, insensible à la casse et aux espaces, pour la déduplication."""
2
+
3
+ import hashlib
4
+ import re
5
+ import unicodedata
6
+
7
+ _WS = re.compile(r"\s+")
8
+
9
+
10
+ def normalize(text: str) -> str:
11
+ text = unicodedata.normalize("NFKC", text).casefold()
12
+ return _WS.sub(" ", text).strip()
13
+
14
+
15
+ def content_hash(text: str) -> str:
16
+ return hashlib.sha256(normalize(text).encode("utf-8")).hexdigest()
@@ -0,0 +1,207 @@
1
+ """Extraction du texte selon la source : brut, page web, fichier markdown.
2
+
3
+ Pages web : conversion structurelle HTML → markdown (titres, listes, tableaux, code conservés)
4
+ après retrait du bruit évident (navigation, pied de page, scripts). On privilégie la structure à
5
+ la détection fine du contenu principal : les pages mémorisées sont choisies par l'utilisateur,
6
+ pas moissonnées — et un titre perdu coûte plus cher au retrieval qu'un menu résiduel.
7
+ """
8
+
9
+ import asyncio
10
+ import ipaddress
11
+ import re
12
+ import socket
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+ from typing import cast
16
+
17
+ import httpx
18
+ from lxml import etree
19
+ from lxml import html as lxml_html
20
+ from lxml.html import HtmlElement, tostring
21
+ from markdownify import markdownify
22
+
23
+ from loom_notes.settings import Settings
24
+
25
+ _H1 = re.compile(r"^#\s+(.+?)\s*$", re.MULTILINE)
26
+ _NOISE_XPATH = (
27
+ "//nav | //footer | //script | //style | //noscript | //iframe | //svg | //form | //template"
28
+ " | //*[@aria-hidden='true'] | //*[@role='navigation'] | //*[@role='banner' and .//nav]"
29
+ )
30
+ _INLINE_TAGS = ("span", "strong", "b", "em", "i", "code", "a", "small", "abbr", "time")
31
+
32
+
33
+ class ExtractionError(Exception):
34
+ pass
35
+
36
+
37
+ @dataclass(frozen=True, slots=True)
38
+ class Extracted:
39
+ title: str
40
+ text: str
41
+
42
+
43
+ def extract_from_html(html: str, url: str) -> Extracted:
44
+ """Contenu de la page en markdown structuré + titre. Lève ExtractionError si vide."""
45
+ try:
46
+ doc = cast(HtmlElement, lxml_html.fromstring(html)) # pyright: ignore[reportUnknownMemberType]
47
+ except (ValueError, etree.ParserError) as exc:
48
+ raise ExtractionError(f"HTML illisible : {url} ({exc})") from exc
49
+ title = _page_title(doc) or url
50
+ for node in _elements(doc, _NOISE_XPATH):
51
+ parent = node.getparent()
52
+ if parent is not None:
53
+ parent.remove(node)
54
+ _separate_glued_inlines(doc)
55
+ roots = _elements(doc, "//main") or _elements(doc, "//article") or _elements(doc, "//body")
56
+ fragment = cast(str, tostring(roots[0] if roots else doc, encoding="unicode"))
57
+ text = markdownify(
58
+ fragment, heading_style="ATX", strip=["img", "a"], bullets="-", code_language=""
59
+ )
60
+ text = _tidy(text)
61
+ if not text:
62
+ raise ExtractionError(f"aucun contenu exploitable extrait de {url}")
63
+ return Extracted(title=title, text=text)
64
+
65
+
66
+ async def fetch_url(url: str, settings: Settings) -> Extracted:
67
+ """Télécharge la page puis délègue à extract_from_html. Seul point réseau du projet.
68
+
69
+ Chaque requête, redirections comprises, passe par `_guard` : sans `fetch_allow_private`,
70
+ un hôte qui est ou se résout en adresse non publique est refusé. La page est lue par
71
+ morceaux et coupée au-delà de `fetch_max_bytes`.
72
+ """
73
+ try:
74
+ scheme = httpx.URL(url).scheme
75
+ except httpx.InvalidURL as exc:
76
+ raise ExtractionError(f"URL invalide : {url}") from exc
77
+ if scheme not in ("http", "https"):
78
+ raise ExtractionError(f"seules les URL http(s) sont acceptées : {url}")
79
+
80
+ async def guard(request: httpx.Request) -> None:
81
+ if not settings.fetch_allow_private:
82
+ await _refuse_non_public(request.url)
83
+
84
+ headers = {"User-Agent": settings.user_agent, "Accept": "text/html,application/xhtml+xml"}
85
+ try:
86
+ async with (
87
+ httpx.AsyncClient(
88
+ follow_redirects=True,
89
+ timeout=settings.fetch_timeout_s,
90
+ headers=headers,
91
+ event_hooks={"request": [guard]},
92
+ ) as client,
93
+ client.stream("GET", url) as resp,
94
+ ):
95
+ resp.raise_for_status()
96
+ ctype = resp.headers.get("content-type", "")
97
+ if "html" not in ctype and "xml" not in ctype:
98
+ raise ExtractionError(f"type de contenu non pris en charge : {ctype or 'inconnu'}")
99
+ body = await _read_capped(resp, settings.fetch_max_bytes)
100
+ encoding = resp.encoding or "utf-8"
101
+ final_url = str(resp.url)
102
+ except httpx.HTTPError as exc:
103
+ raise ExtractionError(f"téléchargement impossible : {url} ({exc})") from exc
104
+ return extract_from_html(body.decode(encoding, errors="replace"), url=final_url)
105
+
106
+
107
+ async def _refuse_non_public(url: httpx.URL) -> None:
108
+ """Refuse un hôte qui est, ou dont une des adresses DNS est, non publique."""
109
+ host = url.host
110
+ try:
111
+ addresses = {ipaddress.ip_address(host)}
112
+ except ValueError:
113
+ try:
114
+ infos = await asyncio.get_running_loop().getaddrinfo(
115
+ host, url.port, type=socket.SOCK_STREAM
116
+ )
117
+ except socket.gaierror as exc:
118
+ raise ExtractionError(f"hôte introuvable : {host}") from exc
119
+ addresses = {ipaddress.ip_address(info[4][0]) for info in infos}
120
+ for address in addresses:
121
+ if not _is_public(address):
122
+ raise ExtractionError(
123
+ f"adresse non publique refusée : {host} ({address}). "
124
+ "LOOM_NOTES_FETCH_ALLOW_PRIVATE=true pour l'autoriser."
125
+ )
126
+
127
+
128
+ def _is_public(address: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
129
+ """Ni boucle locale, ni réseau privé, ni lien local, ni réservée, ni multicast."""
130
+ if isinstance(address, ipaddress.IPv6Address) and address.ipv4_mapped is not None:
131
+ address = address.ipv4_mapped
132
+ return address.is_global and not address.is_multicast
133
+
134
+
135
+ async def _read_capped(resp: httpx.Response, max_bytes: int) -> bytes:
136
+ """Lit le corps (décompressé) sans dépasser max_bytes."""
137
+ declared = resp.headers.get("content-length", "")
138
+ if declared.isdigit() and int(declared) > max_bytes:
139
+ raise ExtractionError(f"page trop volumineuse : {declared} octets (maximum {max_bytes})")
140
+ chunks: list[bytes] = []
141
+ size = 0
142
+ async for chunk in resp.aiter_bytes():
143
+ size += len(chunk)
144
+ if size > max_bytes:
145
+ raise ExtractionError(f"page trop volumineuse : plus de {max_bytes} octets")
146
+ chunks.append(chunk)
147
+ return b"".join(chunks)
148
+
149
+
150
+ def read_markdown_file(path: Path) -> Extracted:
151
+ """Lit un fichier markdown local. Le titre est le premier H1, sinon le nom du fichier."""
152
+ if not path.is_file():
153
+ raise ExtractionError(f"fichier introuvable : {path}")
154
+ text = path.read_text(encoding="utf-8").strip()
155
+ if not text:
156
+ raise ExtractionError(f"fichier vide : {path}")
157
+ title = _first_h1(text) or path.stem
158
+ return Extracted(title=title, text=text)
159
+
160
+
161
+ def _first_h1(text: str) -> str | None:
162
+ m = _H1.search(text)
163
+ return m.group(1).strip() if m else None
164
+
165
+
166
+ def _elements(doc: HtmlElement, xpath: str) -> list[HtmlElement]:
167
+ return cast(list[HtmlElement], doc.xpath(xpath))
168
+
169
+
170
+ def _page_title(doc: HtmlElement) -> str | None:
171
+ for xpath in (
172
+ "//meta[@property='og:title']/@content",
173
+ "//head/title/text()",
174
+ "//h1//text()",
175
+ ):
176
+ values = cast(list[str], doc.xpath(xpath))
177
+ joined = " ".join(" ".join(values).split())
178
+ if joined:
179
+ return joined
180
+ return None
181
+
182
+
183
+ def _separate_glued_inlines(doc: HtmlElement) -> None:
184
+ """`<span>Label</span>Texte` ou `<span>Label</span><strong>…` sans espace entre les deux :
185
+ on insère un espace, sinon le markdown colle les mots (« Ce qui arriveL'agent »)."""
186
+ for el in doc.iter(*_INLINE_TAGS):
187
+ text = "".join(el.itertext()).rstrip()
188
+ if not text:
189
+ continue
190
+ ends_word = text[-1].isalnum()
191
+ ends_sentence = text[-1] in ".!?:;"
192
+ tail = el.tail or ""
193
+ if tail:
194
+ if (ends_word and tail[0].isupper()) or (ends_sentence and tail[0].isalpha()):
195
+ el.tail = " " + tail
196
+ continue
197
+ nxt = el.getnext()
198
+ if ends_word and nxt is not None and isinstance(nxt.tag, str) and nxt.tag in _INLINE_TAGS:
199
+ head = "".join(nxt.itertext()).lstrip()
200
+ if head and head[0].isalnum():
201
+ el.tail = " "
202
+
203
+
204
+ def _tidy(text: str) -> str:
205
+ text = re.sub(r"[ \t]+\n", "\n", text)
206
+ text = re.sub(r"\n{3,}", "\n\n", text)
207
+ return text.strip()
@@ -0,0 +1,53 @@
1
+ """Contrôle d'accès des fichiers lus par `add_file`.
2
+
3
+ Trois contrôles, dans cet ordre, et le message dit toujours lequel a mordu :
4
+ 1. le chemin résolu (symlinks compris) est inclus dans une racine autorisée ;
5
+ 2. aucun composant du chemin relatif ne correspond à un motif refusé ;
6
+ 3. l'extension est celle d'un fichier texte/markdown.
7
+
8
+ La liste de refus de base est dans le code, volontairement : une garantie qu'un fichier de
9
+ configuration peut retirer n'est plus une garantie. La configuration ne peut qu'y ajouter.
10
+ """
11
+
12
+ import fnmatch
13
+ from pathlib import Path
14
+
15
+ from loom_notes.settings import Settings
16
+
17
+ BASE_DENY: tuple[str, ...] = (
18
+ ".env", ".env.*", "*.pem", "*.key", "*.p12", "*.pfx",
19
+ "id_rsa*", "id_ecdsa*", "id_ed25519*",
20
+ ".ssh", ".aws", ".gnupg", ".netrc", ".npmrc", ".pypirc",
21
+ ".git", ".venv", "node_modules", "__pycache__",
22
+ ) # fmt: skip
23
+
24
+ ALLOWED_SUFFIXES: frozenset[str] = frozenset({".md", ".markdown", ".txt"})
25
+
26
+
27
+ class PathDeniedError(Exception):
28
+ pass
29
+
30
+
31
+ def resolve_allowed(path: str | Path, settings: Settings) -> Path:
32
+ """Renvoie le chemin réel si la lecture est autorisée, sinon lève PathDeniedError."""
33
+ if not settings.allowed_roots:
34
+ raise PathDeniedError("add_file désactivé : aucune racine dans LOOM_NOTES_ALLOWED_ROOTS")
35
+ target = Path(path).expanduser().resolve()
36
+ root = next((r for r in _resolved_roots(settings) if target.is_relative_to(r)), None)
37
+ if root is None:
38
+ raise PathDeniedError(f"chemin hors des racines autorisées : {target}")
39
+ deny = (*BASE_DENY, *settings.deny_patterns)
40
+ for part in target.relative_to(root).parts:
41
+ for pattern in deny:
42
+ if fnmatch.fnmatchcase(part, pattern):
43
+ raise PathDeniedError(f"chemin refusé par le motif '{pattern}' : {target}")
44
+ if target.suffix.lower() not in ALLOWED_SUFFIXES:
45
+ raise PathDeniedError(
46
+ f"extension non autorisée ({target.suffix or 'aucune'}) ; "
47
+ f"attendu : {', '.join(sorted(ALLOWED_SUFFIXES))}"
48
+ )
49
+ return target
50
+
51
+
52
+ def _resolved_roots(settings: Settings) -> list[Path]:
53
+ return [Path(r).expanduser().resolve() for r in settings.allowed_roots]
loom_notes/models.py ADDED
@@ -0,0 +1,90 @@
1
+ """Modèles de données partagés entre ingestion, store, CLI et serveur MCP."""
2
+
3
+ from datetime import UTC, datetime
4
+ from typing import Literal
5
+
6
+ from pydantic import BaseModel, Field
7
+
8
+ SourceKind = Literal["text", "url", "file"]
9
+
10
+
11
+ def utc_now_iso() -> str:
12
+ # Microsecondes conservées : added_at sert de clé de tri, deux ajouts dans la même seconde
13
+ # doivent rester ordonnés.
14
+ return datetime.now(UTC).isoformat()
15
+
16
+
17
+ class Document(BaseModel):
18
+ doc_id: str
19
+ title: str
20
+ project: str
21
+ tags: list[str] = Field(default_factory=list)
22
+ source_kind: SourceKind
23
+ source: str | None = None
24
+ added_at: str = Field(default_factory=utc_now_iso)
25
+ updated_at: str | None = None
26
+ content_hash: str
27
+ text: str
28
+
29
+
30
+ class Chunk(BaseModel):
31
+ doc_id: str
32
+ index: int
33
+ heading_path: str
34
+ text: str
35
+
36
+ @property
37
+ def embed_text(self) -> str:
38
+ """Texte réellement vectorisé : le chemin de titres préfixe le contenu."""
39
+ return f"{self.heading_path}\n\n{self.text}" if self.heading_path else self.text
40
+
41
+
42
+ class DocSummary(BaseModel):
43
+ doc_id: str
44
+ title: str
45
+ project: str
46
+ tags: list[str]
47
+ source_kind: SourceKind
48
+ source: str | None
49
+ added_at: str
50
+ updated_at: str | None
51
+ chars: int
52
+
53
+ @classmethod
54
+ def from_document(cls, doc: Document) -> "DocSummary":
55
+ return cls(
56
+ doc_id=doc.doc_id,
57
+ title=doc.title,
58
+ project=doc.project,
59
+ tags=doc.tags,
60
+ source_kind=doc.source_kind,
61
+ source=doc.source,
62
+ added_at=doc.added_at,
63
+ updated_at=doc.updated_at,
64
+ chars=len(doc.text),
65
+ )
66
+
67
+
68
+ class Hit(BaseModel):
69
+ doc_id: str
70
+ title: str
71
+ project: str
72
+ score: float
73
+ snippet: str = Field(
74
+ description="Le chunk entier s'il est court, sinon un extrait (voir `truncated`)."
75
+ )
76
+ truncated: bool = Field(description="True si `snippet` est un extrait ; `get` donne le reste.")
77
+ heading_path: str
78
+ source: str | None
79
+ added_at: str
80
+
81
+
82
+ class IngestResult(BaseModel):
83
+ doc_id: str
84
+ title: str
85
+ project: str
86
+ chunks: int
87
+ updated: bool = Field(default=False, description="True si un document existant a été remplacé.")
88
+ duplicate_of: str | None = Field(
89
+ default=None, description="doc_id d'un document au contenu identique ; rien n'a été écrit."
90
+ )
@@ -0,0 +1,81 @@
1
+ """Recherche : embedding de la requête → hybride Qdrant → reranking → regroupement par document."""
2
+
3
+ import asyncio
4
+ from collections.abc import Sequence
5
+
6
+ from loom_notes.embed.base import Embedder, Reranker
7
+ from loom_notes.models import Hit
8
+ from loom_notes.settings import Settings
9
+ from loom_notes.store.qdrant import MemoryStore, ScoredChunk
10
+
11
+
12
+ class Retriever:
13
+ def __init__(
14
+ self, settings: Settings, store: MemoryStore, embedder: Embedder, reranker: Reranker
15
+ ) -> None:
16
+ self._s = settings
17
+ self._store = store
18
+ self._embedder = embedder
19
+ self.reranker = reranker
20
+
21
+ async def search(
22
+ self,
23
+ query: str,
24
+ project: str | None,
25
+ tags: Sequence[str] | None,
26
+ k: int,
27
+ *,
28
+ min_score: float | None = None,
29
+ ) -> list[Hit]:
30
+ query = query.strip()
31
+ if not query:
32
+ return []
33
+ emb = await asyncio.to_thread(self._embedder.embed_query, query)
34
+ candidates = await self._store.search(
35
+ emb, project=project, tags=tags, limit=self._s.rerank_candidates
36
+ )
37
+ if not candidates:
38
+ return []
39
+ passages = [f"{c.heading_path}\n\n{c.text}" for c in candidates]
40
+ scores = await asyncio.to_thread(self.reranker.score, query, passages)
41
+ ranked = sorted(zip(candidates, scores, strict=True), key=lambda cs: cs[1], reverse=True)
42
+ threshold = self._s.min_score if min_score is None else min_score
43
+ return self._group(ranked, k, threshold)
44
+
45
+ def _group(
46
+ self, ranked: list[tuple[ScoredChunk, float]], k: int, threshold: float
47
+ ) -> list[Hit]:
48
+ per_doc: dict[str, int] = {}
49
+ hits: list[Hit] = []
50
+ for chunk, score in ranked:
51
+ if score < threshold:
52
+ break
53
+ n = per_doc.get(chunk.doc_id, 0)
54
+ if n >= self._s.max_chunks_per_doc:
55
+ continue
56
+ per_doc[chunk.doc_id] = n + 1
57
+ short = len(chunk.text) <= self._s.full_chunk_chars
58
+ hits.append(
59
+ Hit(
60
+ doc_id=chunk.doc_id,
61
+ title=chunk.title,
62
+ project=chunk.project,
63
+ score=round(score, 4),
64
+ snippet=chunk.text if short else _snippet(chunk.text, self._s.snippet_chars),
65
+ truncated=not short,
66
+ heading_path=chunk.heading_path,
67
+ source=chunk.source,
68
+ added_at=chunk.added_at,
69
+ )
70
+ )
71
+ if len(hits) >= k:
72
+ break
73
+ return hits
74
+
75
+
76
+ def _snippet(text: str, limit: int) -> str:
77
+ flat = " ".join(text.split())
78
+ if len(flat) <= limit:
79
+ return flat
80
+ cut = flat.rfind(" ", 0, limit)
81
+ return flat[: cut if cut > limit // 2 else limit].rstrip() + "…"