forked from balisujohn/localwriter
-
Notifications
You must be signed in to change notification settings - Fork 14
Expand file tree
/
Copy pathembeddings_fs.py
More file actions
320 lines (264 loc) · 11.4 KB
/
Copy pathembeddings_fs.py
File metadata and controls
320 lines (264 loc) · 11.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
# WriterAgent - AI Writing Assistant for LibreOffice
# Copyright (c) 2026 KeithCu (modifications and relicensing)
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
"""Writer/Calc/Impress/Draw ODF and Microsoft Office extract for embeddings / FTS (no UNO)."""
from __future__ import annotations
import dataclasses
import hashlib
import logging
import os
from pathlib import Path
from typing import Any
log = logging.getLogger(__name__)
TEXT_NS = "{urn:oasis:names:tc:opendocument:xmlns:text:1.0}"
WRITER_EXTENSIONS = frozenset({".odt", ".ott", ".fodt"})
CALC_EXTENSIONS = frozenset({".ods", ".ots", ".fods"})
DRAW_EXTENSIONS = frozenset({".odp", ".otp", ".fodp", ".odg"})
INDEXABLE_EXTENSIONS = WRITER_EXTENSIONS | CALC_EXTENSIONS | DRAW_EXTENSIONS
FOREIGN_WRITER_EXTENSIONS = frozenset({".docx", ".doc", ".rtf", ".txt", ".md"})
FOREIGN_CALC_EXTENSIONS = frozenset({".xlsx", ".xls", ".csv"})
FOREIGN_DRAW_EXTENSIONS = frozenset({".pptx", ".ppt"})
FOREIGN_EXTENSIONS = FOREIGN_WRITER_EXTENSIONS | FOREIGN_CALC_EXTENSIONS | FOREIGN_DRAW_EXTENSIONS
ALL_INDEXABLE_EXTENSIONS = INDEXABLE_EXTENSIONS | FOREIGN_EXTENSIONS
PROSE_CHUNK_EXTENSIONS = WRITER_EXTENSIONS | frozenset({".docx", ".doc", ".rtf", ".txt", ".md"})
@dataclasses.dataclass(frozen=True)
class LocaleTextRun:
"""One contiguous text span within a passage with an optional BCP-47 locale."""
char_start: int
char_end: int
locale_bcp47: str | None
@dataclasses.dataclass(frozen=True)
class ParagraphChunk:
doc_url: str
para_index: int
char_start: int
char_end: int
text: str
content_hash: str
file_mtime: float
doc_path: str = ""
@dataclasses.dataclass(frozen=True)
class WriterFileEntry:
path: str
url: str
modified: float
name: str
def content_hash(text: str) -> str:
"""SHA-256 of normalized passage/chunk text (stable for incremental invalidation)."""
normalized = str(text or "").strip()
return hashlib.sha256(normalized.encode("utf-8")).hexdigest()
def _normalize_path(path: str) -> str:
return os.path.normpath(os.path.abspath(path))
def path_to_file_url(path: str) -> str:
"""Build a LO-compatible file URL (file:/// on Unix)."""
norm = _normalize_path(path)
return Path(norm).as_uri()
def extract_writer_paragraph_runs(path: str) -> list[tuple[str, list[LocaleTextRun]]]:
"""Read locale-tagged runs per paragraph from Writer ODF on disk."""
from plugin.embeddings.embeddings_locale import extract_odf_paragraph_runs
ext = os.path.splitext(path)[1].lower()
if ext not in WRITER_EXTENSIONS:
return []
return extract_odf_paragraph_runs(path)
def extract_writer_paragraphs(path: str) -> list[str]:
"""Read body paragraph text from a Writer .odt/.ott (zip) or .fodt (flat XML)."""
return [passage for passage, _runs in extract_writer_paragraph_runs(path) if passage.strip()]
def _extract_foreign_passages(path: str, ext: str) -> list[str]:
from plugin.embeddings.venv import embeddings_ooxml_extract as ooxml
if ext == ".docx":
return ooxml.extract_docx_paragraphs(path)
if ext in {".xlsx", ".xls"}:
return ooxml.extract_spreadsheet_rows(path)
if ext == ".csv":
return ooxml.extract_csv_rows(path)
if ext in {".txt", ".md"}:
return ooxml.extract_plaintext_paragraphs(path)
if ext == ".rtf":
return ooxml.extract_rtf_paragraphs(path)
if ext == ".pptx":
return ooxml.extract_pptx_passages(path)
return []
def _extract_legacy_via_soffice(path: str, ext: str) -> list[str]:
from plugin.embeddings.embeddings_soffice_convert import LEGACY_BINARY_EXTENSIONS, temporary_converted_odf
if ext not in LEGACY_BINARY_EXTENSIONS:
return []
with temporary_converted_odf(path) as converted:
if converted is None:
return []
return extract_indexable_passages(str(converted))
def extract_indexable_passage_runs(path: str) -> list[tuple[str, list[LocaleTextRun]]]:
"""Extract indexable passages with locale runs for prose chunking."""
from plugin.embeddings.embeddings_locale import extract_docx_paragraph_runs, locale_runs_for_plain_passage, resolve_document_locale_bcp47
ext = os.path.splitext(path)[1].lower()
if ext in WRITER_EXTENSIONS:
return extract_writer_paragraph_runs(path)
if ext == ".docx":
return extract_docx_paragraph_runs(path)
if ext in {".txt", ".rtf", ".md"}:
passages = extract_indexable_passages(path)
doc_default = resolve_document_locale_bcp47(path, body_sample="\n".join(passages[:20]))
return [(passage, locale_runs_for_plain_passage(passage, doc_default)) for passage in passages if passage.strip()]
if ext == ".doc":
from plugin.embeddings.embeddings_locale import extract_odf_paragraph_runs
from plugin.embeddings.embeddings_soffice_convert import temporary_converted_odf
with temporary_converted_odf(path) as converted:
if converted is None:
return []
return extract_odf_paragraph_runs(str(converted))
return []
def extract_indexable_passages(path: str) -> list[str]:
"""Extract indexable passage text from ODF, Microsoft Office, or plain-text files on disk."""
ext = os.path.splitext(path)[1].lower()
if ext in WRITER_EXTENSIONS:
return extract_writer_paragraphs(path)
if ext in CALC_EXTENSIONS:
from plugin.embeddings.venv.embeddings_odf_extract import extract_calc_rows
return extract_calc_rows(path)
if ext in DRAW_EXTENSIONS:
from plugin.embeddings.venv.embeddings_odf_extract import extract_draw_pages
return extract_draw_pages(path)
if ext in FOREIGN_EXTENSIONS:
passages = _extract_foreign_passages(path, ext)
if passages:
return passages
return _extract_legacy_via_soffice(path, ext)
return []
def guess_indexable_paths(directory: str) -> list[WriterFileEntry]:
"""List indexable document siblings in *directory* (stdlib scan, no UNO)."""
listing_root = _normalize_path(directory)
entries: list[WriterFileEntry] = []
try:
names = sorted(os.listdir(listing_root))
except OSError:
log.debug("guess_indexable_paths listdir failed for %s", listing_root, exc_info=True)
return []
for name in names:
full = os.path.join(listing_root, name)
if not os.path.isfile(full):
continue
ext = os.path.splitext(name)[1].lower()
if ext not in ALL_INDEXABLE_EXTENSIONS:
continue
try:
mtime = float(os.path.getmtime(full))
except OSError:
mtime = 0.0
norm = _normalize_path(full)
entries.append(
WriterFileEntry(
path=norm,
url=path_to_file_url(norm),
modified=mtime,
name=name,
)
)
return entries
def guess_writer_paths(directory: str) -> list[WriterFileEntry]:
"""Alias for :func:`guess_indexable_paths`."""
return guess_indexable_paths(directory)
def path_uses_prose_chunking(path: str) -> bool:
"""Return True when index chunks should use sentence splitting (Writer-style prose)."""
ext = os.path.splitext(path)[1].lower()
return ext in PROSE_CHUNK_EXTENSIONS
def indexable_chunks_from_path(
path: str,
*,
doc_url: str | None = None,
file_mtime: float | None = None,
) -> tuple[int, list[ParagraphChunk]]:
"""Extract native passages, split to embed chunks; return (passage_count, chunk_rows)."""
from plugin.embeddings.embeddings_locale import resolve_document_locale_bcp47
from plugin.embeddings.embeddings_split import split_passage_locale_runs_to_chunk_meta, split_passage_to_chunk_meta
norm = _normalize_path(path)
url = doc_url if doc_url else path_to_file_url(norm)
try:
mtime = float(file_mtime if file_mtime is not None else os.path.getmtime(norm))
except OSError:
mtime = 0.0
prose = path_uses_prose_chunking(norm)
doc_default: str | None = None
if prose:
sample_passages = extract_indexable_passages(norm)
doc_default = resolve_document_locale_bcp47(norm, body_sample="\n".join(sample_passages[:20]))
chunks: list[ParagraphChunk] = []
if prose:
passage_runs = extract_indexable_passage_runs(norm)
for para_index, (passage, runs) in enumerate(passage_runs):
if not passage.strip():
continue
base_meta = {
"doc_url": url,
"para_index": para_index,
"file_mtime": mtime,
}
split_rows = split_passage_locale_runs_to_chunk_meta(
passage,
runs,
base_meta,
prose=True,
doc_default_locale=doc_default,
)
for piece in split_rows:
piece_text = str(piece.get("text") or "").strip()
if not piece_text:
continue
chunks.append(
ParagraphChunk(
doc_url=url,
para_index=para_index,
char_start=int(piece.get("char_start") or 0),
char_end=int(piece.get("char_end") or len(piece_text)),
text=piece_text,
content_hash=content_hash(piece_text),
file_mtime=mtime,
doc_path=norm,
)
)
return len(passage_runs), chunks
passages = [text.strip() for text in extract_indexable_passages(norm) if text.strip()]
for para_index, passage in enumerate(passages):
base_meta = {
"doc_url": url,
"para_index": para_index,
"file_mtime": mtime,
}
for piece in split_passage_to_chunk_meta(passage, base_meta, prose=False):
piece_text = str(piece.get("text") or "").strip()
if not piece_text:
continue
chunks.append(
ParagraphChunk(
doc_url=url,
para_index=para_index,
char_start=int(piece.get("char_start") or 0),
char_end=int(piece.get("char_end") or len(piece_text)),
text=piece_text,
content_hash=content_hash(piece_text),
file_mtime=mtime,
doc_path=norm,
)
)
return len(passages), chunks
def paragraph_chunks_from_path(path: str, *, doc_url: str | None = None, file_mtime: float | None = None) -> list[ParagraphChunk]:
"""Build embed-sized chunk rows from one supported document on disk."""
_passage_count, chunks = indexable_chunks_from_path(path, doc_url=doc_url, file_mtime=file_mtime)
del _passage_count
return chunks
def chunk_to_index_row(chunk: ParagraphChunk, *, chunk_id: int | None = None) -> dict[str, Any]:
"""Dict shape for venv index_paragraphs / ingest."""
row: dict[str, Any] = {
"doc_url": chunk.doc_url,
"para_index": chunk.para_index,
"char_start": chunk.char_start,
"char_end": chunk.char_end,
"content_hash": chunk.content_hash,
"text": chunk.text,
"file_mtime": chunk.file_mtime,
}
if chunk_id is not None:
row["chunk_id"] = chunk_id
return row