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.
- loom_notes/__init__.py +8 -0
- loom_notes/cli.py +229 -0
- loom_notes/embed/__init__.py +3 -0
- loom_notes/embed/base.py +52 -0
- loom_notes/embed/bge_m3.py +66 -0
- loom_notes/embed/fake.py +69 -0
- loom_notes/embed/reranker.py +44 -0
- loom_notes/evals.py +108 -0
- loom_notes/ids.py +13 -0
- loom_notes/ingest/__init__.py +12 -0
- loom_notes/ingest/chunk.py +142 -0
- loom_notes/ingest/dedup.py +16 -0
- loom_notes/ingest/extract.py +207 -0
- loom_notes/ingest/paths.py +53 -0
- loom_notes/models.py +90 -0
- loom_notes/retrieval.py +81 -0
- loom_notes/server.py +216 -0
- loom_notes/service.py +253 -0
- loom_notes/settings.py +88 -0
- loom_notes/store/__init__.py +8 -0
- loom_notes/store/qdrant.py +300 -0
- loom_notes-1.1.0.dist-info/METADATA +339 -0
- loom_notes-1.1.0.dist-info/RECORD +26 -0
- loom_notes-1.1.0.dist-info/WHEEL +4 -0
- loom_notes-1.1.0.dist-info/entry_points.txt +3 -0
- loom_notes-1.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -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
|
+
)
|
loom_notes/retrieval.py
ADDED
|
@@ -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() + "…"
|