chunksilo 2.3.3__tar.gz → 2.4.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.
- {chunksilo-2.3.3/src/chunksilo.egg-info → chunksilo-2.4.0}/PKG-INFO +12 -10
- {chunksilo-2.3.3 → chunksilo-2.4.0}/README.md +8 -7
- {chunksilo-2.3.3 → chunksilo-2.4.0}/pyproject.toml +28 -4
- {chunksilo-2.3.3 → chunksilo-2.4.0}/requirements.txt +2 -0
- {chunksilo-2.3.3 → chunksilo-2.4.0}/src/chunksilo/__init__.py +1 -1
- {chunksilo-2.3.3 → chunksilo-2.4.0}/src/chunksilo/cfgload.py +50 -3
- {chunksilo-2.3.3 → chunksilo-2.4.0}/src/chunksilo/cli.py +2 -1
- chunksilo-2.4.0/src/chunksilo/docx_utils.py +285 -0
- {chunksilo-2.3.3 → chunksilo-2.4.0}/src/chunksilo/index.py +78 -989
- chunksilo-2.4.0/src/chunksilo/models.py +75 -0
- {chunksilo-2.3.3 → chunksilo-2.4.0}/src/chunksilo/search.py +270 -278
- chunksilo-2.4.0/src/chunksilo/ui.py +639 -0
- {chunksilo-2.3.3 → chunksilo-2.4.0/src/chunksilo.egg-info}/PKG-INFO +12 -10
- {chunksilo-2.3.3 → chunksilo-2.4.0}/src/chunksilo.egg-info/SOURCES.txt +6 -0
- {chunksilo-2.3.3 → chunksilo-2.4.0}/src/chunksilo.egg-info/requires.txt +3 -2
- chunksilo-2.4.0/test/test_cli.py +148 -0
- chunksilo-2.4.0/test/test_docx_utils.py +261 -0
- {chunksilo-2.3.3 → chunksilo-2.4.0}/test/test_indexing_ui.py +8 -8
- chunksilo-2.4.0/test/test_server.py +107 -0
- {chunksilo-2.3.3 → chunksilo-2.4.0}/LICENSE +0 -0
- {chunksilo-2.3.3 → chunksilo-2.4.0}/NOTICE +0 -0
- {chunksilo-2.3.3 → chunksilo-2.4.0}/setup.cfg +0 -0
- {chunksilo-2.3.3 → chunksilo-2.4.0}/src/chunksilo/__main__.py +0 -0
- {chunksilo-2.3.3 → chunksilo-2.4.0}/src/chunksilo/confluence_html_formatter.py +0 -0
- {chunksilo-2.3.3 → chunksilo-2.4.0}/src/chunksilo/server.py +1 -1
- {chunksilo-2.3.3 → chunksilo-2.4.0}/src/chunksilo.egg-info/dependency_links.txt +0 -0
- {chunksilo-2.3.3 → chunksilo-2.4.0}/src/chunksilo.egg-info/entry_points.txt +0 -0
- {chunksilo-2.3.3 → chunksilo-2.4.0}/src/chunksilo.egg-info/top_level.txt +0 -0
- {chunksilo-2.3.3 → chunksilo-2.4.0}/test/test_chunk_location.py +0 -0
- {chunksilo-2.3.3 → chunksilo-2.4.0}/test/test_confluence_html_formatter.py +0 -0
- {chunksilo-2.3.3 → chunksilo-2.4.0}/test/test_error_handling.py +0 -0
- {chunksilo-2.3.3 → chunksilo-2.4.0}/test/test_heading_path_integration.py +0 -0
- {chunksilo-2.3.3 → chunksilo-2.4.0}/test/test_incremental_ingest.py +0 -0
- {chunksilo-2.3.3 → chunksilo-2.4.0}/test/test_indexing_benchmark.py +0 -0
- {chunksilo-2.3.3 → chunksilo-2.4.0}/test/test_jira_integration.py +0 -0
- {chunksilo-2.3.3 → chunksilo-2.4.0}/test/test_quoted_phrases.py +0 -0
- {chunksilo-2.3.3 → chunksilo-2.4.0}/test/test_rag_metrics.py +0 -0
- {chunksilo-2.3.3 → chunksilo-2.4.0}/test/test_retrieval_only.py +0 -0
- {chunksilo-2.3.3 → chunksilo-2.4.0}/test/test_scan_timeouts.py +0 -0
- {chunksilo-2.3.3 → chunksilo-2.4.0}/test/test_system.py +0 -0
- {chunksilo-2.3.3 → chunksilo-2.4.0}/test/test_utils.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: chunksilo
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.4.0
|
|
4
4
|
Summary: Local RAG-based semantic document search with MCP server interface
|
|
5
5
|
Author: Fredrik Reveny
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -30,12 +30,13 @@ Requires-Dist: huggingface-hub<2,>=0.22.0
|
|
|
30
30
|
Requires-Dist: flashrank<1,>=0.1.0
|
|
31
31
|
Requires-Dist: fastembed<1,>=0.5.0
|
|
32
32
|
Requires-Dist: pyyaml<7,>=6.0
|
|
33
|
+
Requires-Dist: llama-index-readers-confluence<1,>=0.6.0
|
|
34
|
+
Requires-Dist: jira<4,>=3.5.0
|
|
33
35
|
Provides-Extra: confluence
|
|
34
|
-
Requires-Dist: llama-index-readers-confluence<1,>=0.6.0; extra == "confluence"
|
|
35
36
|
Provides-Extra: jira
|
|
36
|
-
Requires-Dist: jira<4,>=3.5.0; extra == "jira"
|
|
37
37
|
Provides-Extra: test
|
|
38
38
|
Requires-Dist: pytest<9,>=7.4.0; extra == "test"
|
|
39
|
+
Requires-Dist: pytest-cov>=4.0; extra == "test"
|
|
39
40
|
Requires-Dist: requests<3,>=2.31.0; extra == "test"
|
|
40
41
|
Dynamic: license-file
|
|
41
42
|
|
|
@@ -92,11 +93,12 @@ Requires Python 3.11 or later. Models are downloaded automatically on first run
|
|
|
92
93
|
|
|
93
94
|
```bash
|
|
94
95
|
pip install chunksilo
|
|
95
|
-
|
|
96
|
-
# Or with Confluence and Jira support:
|
|
97
|
-
pip install chunksilo[confluence,jira]
|
|
98
96
|
```
|
|
99
97
|
|
|
98
|
+
Confluence and Jira support is included by default — just provide a config file
|
|
99
|
+
to enable them. (`pip install chunksilo[confluence,jira]` still works as an alias
|
|
100
|
+
for backward compatibility.)
|
|
101
|
+
|
|
100
102
|
Then:
|
|
101
103
|
1. **Create** a config file at `~/.config/chunksilo/config.yaml` (see [Configuration](#configuration))
|
|
102
104
|
2. **Build** the index: `chunksilo --build-index`
|
|
@@ -225,7 +227,7 @@ All settings are optional and have sensible defaults.
|
|
|
225
227
|
|
|
226
228
|
#### Confluence Settings (optional)
|
|
227
229
|
|
|
228
|
-
> **Note:** Confluence
|
|
230
|
+
> **Note:** Confluence support is installed by default; just set the values below to enable it.
|
|
229
231
|
|
|
230
232
|
| Setting | Description |
|
|
231
233
|
| :--- | :--- |
|
|
@@ -243,7 +245,7 @@ All settings are optional and have sensible defaults.
|
|
|
243
245
|
|
|
244
246
|
#### Jira Settings (optional)
|
|
245
247
|
|
|
246
|
-
> **Note:** Jira
|
|
248
|
+
> **Note:** Jira support is installed by default; just set the values below to enable it.
|
|
247
249
|
|
|
248
250
|
| Setting | Description |
|
|
249
251
|
| :--- | :--- |
|
|
@@ -418,8 +420,8 @@ Add to `mcp_settings.json` (typically in `~/.config/Code/User/globalStorage/roov
|
|
|
418
420
|
- **Index missing**: Run `chunksilo --build-index` (PyPI install) or `./venv/bin/chunksilo --build-index` (offline bundle).
|
|
419
421
|
- **Retrieval errors**: Check paths in your MCP client configuration.
|
|
420
422
|
- **Offline mode**: PyPI installs default to `offline: false` (models auto-download). The offline bundle includes pre-downloaded models and sets `offline: true`. Set `retrieval.offline: true` in `config.yaml` to prevent network calls after initial model download.
|
|
421
|
-
- **Confluence Integration**:
|
|
422
|
-
- **Jira Integration**:
|
|
423
|
+
- **Confluence Integration**: Included by default — set `confluence.url`, `confluence.username`, and `confluence.api_token` in `config.yaml`.
|
|
424
|
+
- **Jira Integration**: Included by default — set `jira.url`, `jira.username`, and `jira.api_token` in `config.yaml`. Optionally configure `jira.projects` to restrict search to specific project keys.
|
|
423
425
|
- **Custom CA Bundle**: Set `ssl.ca_bundle_path` in `config.yaml` for custom certificates.
|
|
424
426
|
- **Network mounts**: Unavailable directories are skipped with a warning; indexing continues with available directories.
|
|
425
427
|
- **Legacy .doc files**: Requires LibreOffice to be installed for automatic conversion to .docx. If LibreOffice is not found, .doc files are skipped with a warning. Full heading extraction is supported.
|
|
@@ -51,11 +51,12 @@ Requires Python 3.11 or later. Models are downloaded automatically on first run
|
|
|
51
51
|
|
|
52
52
|
```bash
|
|
53
53
|
pip install chunksilo
|
|
54
|
-
|
|
55
|
-
# Or with Confluence and Jira support:
|
|
56
|
-
pip install chunksilo[confluence,jira]
|
|
57
54
|
```
|
|
58
55
|
|
|
56
|
+
Confluence and Jira support is included by default — just provide a config file
|
|
57
|
+
to enable them. (`pip install chunksilo[confluence,jira]` still works as an alias
|
|
58
|
+
for backward compatibility.)
|
|
59
|
+
|
|
59
60
|
Then:
|
|
60
61
|
1. **Create** a config file at `~/.config/chunksilo/config.yaml` (see [Configuration](#configuration))
|
|
61
62
|
2. **Build** the index: `chunksilo --build-index`
|
|
@@ -184,7 +185,7 @@ All settings are optional and have sensible defaults.
|
|
|
184
185
|
|
|
185
186
|
#### Confluence Settings (optional)
|
|
186
187
|
|
|
187
|
-
> **Note:** Confluence
|
|
188
|
+
> **Note:** Confluence support is installed by default; just set the values below to enable it.
|
|
188
189
|
|
|
189
190
|
| Setting | Description |
|
|
190
191
|
| :--- | :--- |
|
|
@@ -202,7 +203,7 @@ All settings are optional and have sensible defaults.
|
|
|
202
203
|
|
|
203
204
|
#### Jira Settings (optional)
|
|
204
205
|
|
|
205
|
-
> **Note:** Jira
|
|
206
|
+
> **Note:** Jira support is installed by default; just set the values below to enable it.
|
|
206
207
|
|
|
207
208
|
| Setting | Description |
|
|
208
209
|
| :--- | :--- |
|
|
@@ -377,8 +378,8 @@ Add to `mcp_settings.json` (typically in `~/.config/Code/User/globalStorage/roov
|
|
|
377
378
|
- **Index missing**: Run `chunksilo --build-index` (PyPI install) or `./venv/bin/chunksilo --build-index` (offline bundle).
|
|
378
379
|
- **Retrieval errors**: Check paths in your MCP client configuration.
|
|
379
380
|
- **Offline mode**: PyPI installs default to `offline: false` (models auto-download). The offline bundle includes pre-downloaded models and sets `offline: true`. Set `retrieval.offline: true` in `config.yaml` to prevent network calls after initial model download.
|
|
380
|
-
- **Confluence Integration**:
|
|
381
|
-
- **Jira Integration**:
|
|
381
|
+
- **Confluence Integration**: Included by default — set `confluence.url`, `confluence.username`, and `confluence.api_token` in `config.yaml`.
|
|
382
|
+
- **Jira Integration**: Included by default — set `jira.url`, `jira.username`, and `jira.api_token` in `config.yaml`. Optionally configure `jira.projects` to restrict search to specific project keys.
|
|
382
383
|
- **Custom CA Bundle**: Set `ssl.ca_bundle_path` in `config.yaml` for custom certificates.
|
|
383
384
|
- **Network mounts**: Unavailable directories are skipped with a warning; indexing continues with available directories.
|
|
384
385
|
- **Legacy .doc files**: Requires LibreOffice to be installed for automatic conversion to .docx. If LibreOffice is not found, .doc files are skipped with a warning. Full heading extraction is supported.
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "chunksilo"
|
|
7
|
-
version = "2.
|
|
7
|
+
version = "2.4.0"
|
|
8
8
|
description = "Local RAG-based semantic document search with MCP server interface"
|
|
9
9
|
license = "Apache-2.0"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -25,9 +25,12 @@ classifiers = [
|
|
|
25
25
|
dynamic = ["dependencies"]
|
|
26
26
|
|
|
27
27
|
[project.optional-dependencies]
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
28
|
+
# Confluence and Jira support is now installed by default (see requirements.txt).
|
|
29
|
+
# These extras are kept as empty aliases so existing `pip install chunksilo[confluence,jira]`
|
|
30
|
+
# commands keep working without a warning.
|
|
31
|
+
confluence = []
|
|
32
|
+
jira = []
|
|
33
|
+
test = ["pytest>=7.4.0,<9", "pytest-cov>=4.0", "requests>=2.31.0,<3"]
|
|
31
34
|
|
|
32
35
|
[project.scripts]
|
|
33
36
|
chunksilo = "chunksilo.cli:main"
|
|
@@ -42,3 +45,24 @@ dependencies = {file = ["requirements.txt"]}
|
|
|
42
45
|
|
|
43
46
|
[tool.setuptools.packages.find]
|
|
44
47
|
where = ["src"]
|
|
48
|
+
|
|
49
|
+
# Bundle the retrieval models into the wheel so air-gapped installs work with no
|
|
50
|
+
# network access. Populated at build time (see the publish job / prepare-common.sh).
|
|
51
|
+
# The HuggingFace `blobs/` copies are excluded: with HF_HUB_DISABLE_SYMLINKS=1 the
|
|
52
|
+
# `snapshots/` tree holds real files, so blobs would only duplicate the bytes.
|
|
53
|
+
[tool.setuptools.package-data]
|
|
54
|
+
chunksilo = ["_bundled_models/**/*"]
|
|
55
|
+
|
|
56
|
+
[tool.setuptools.exclude-package-data]
|
|
57
|
+
chunksilo = ["_bundled_models/**/blobs/**"]
|
|
58
|
+
|
|
59
|
+
[tool.ruff]
|
|
60
|
+
target-version = "py311"
|
|
61
|
+
line-length = 120
|
|
62
|
+
|
|
63
|
+
[tool.ruff.lint]
|
|
64
|
+
select = ["E", "F", "I", "UP"]
|
|
65
|
+
ignore = ["E501"]
|
|
66
|
+
|
|
67
|
+
[tool.ruff.lint.isort]
|
|
68
|
+
known-first-party = ["chunksilo"]
|
|
@@ -5,14 +5,20 @@ Shared configuration loading for ChunkSilo.
|
|
|
5
5
|
|
|
6
6
|
Loads configuration from config.yaml, searching in standard locations.
|
|
7
7
|
"""
|
|
8
|
+
import copy
|
|
8
9
|
import logging
|
|
9
10
|
import os
|
|
10
|
-
import yaml
|
|
11
11
|
from pathlib import Path
|
|
12
12
|
from typing import Any
|
|
13
13
|
|
|
14
|
+
import yaml
|
|
15
|
+
|
|
14
16
|
logger = logging.getLogger(__name__)
|
|
15
17
|
|
|
18
|
+
# Retrieval models bundled inside the installed package (populated at wheel-build
|
|
19
|
+
# time). Present in air-gapped wheel installs; absent in plain source checkouts.
|
|
20
|
+
_BUNDLED_MODELS_DIR = Path(__file__).resolve().parent / "_bundled_models"
|
|
21
|
+
|
|
16
22
|
|
|
17
23
|
def _find_config() -> Path:
|
|
18
24
|
"""Find config.yaml using a priority-based search.
|
|
@@ -136,6 +142,46 @@ def _deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any
|
|
|
136
142
|
return result
|
|
137
143
|
|
|
138
144
|
|
|
145
|
+
def _has_embedding_cache(cache_dir: Path) -> bool:
|
|
146
|
+
"""True if ``cache_dir`` holds a HuggingFace-style embedding model cache."""
|
|
147
|
+
try:
|
|
148
|
+
return any(cache_dir.glob("models--*"))
|
|
149
|
+
except OSError:
|
|
150
|
+
return False
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _resolve_bundled_models(config: dict[str, Any]) -> dict[str, Any]:
|
|
154
|
+
"""Transparently fall back to models bundled in the package.
|
|
155
|
+
|
|
156
|
+
If the wheel ships the retrieval models and the configured cache directory
|
|
157
|
+
does not already contain them, point the cache at the bundled copy and enable
|
|
158
|
+
offline mode. This lets a pip-installed wheel run in an air-gapped environment
|
|
159
|
+
with zero extra configuration. A user-configured cache that already holds the
|
|
160
|
+
models always wins, so explicit setups are never overridden.
|
|
161
|
+
|
|
162
|
+
Detection looks for any HuggingFace-style ``models--*`` cache directory rather
|
|
163
|
+
than matching the configured embedding name: fastembed stores the model under
|
|
164
|
+
its upstream source repo (e.g. ``models--qdrant--bge-small-en-v1.5-onnx-q``),
|
|
165
|
+
not under the configured alias ``BAAI/bge-small-en-v1.5``.
|
|
166
|
+
"""
|
|
167
|
+
if not _has_embedding_cache(_BUNDLED_MODELS_DIR):
|
|
168
|
+
return config # No models bundled in this install.
|
|
169
|
+
|
|
170
|
+
configured = Path(config["storage"]["model_cache_dir"]).expanduser()
|
|
171
|
+
if configured.resolve() != _BUNDLED_MODELS_DIR.resolve() and _has_embedding_cache(
|
|
172
|
+
configured
|
|
173
|
+
):
|
|
174
|
+
return config # Configured cache already has models; respect it.
|
|
175
|
+
|
|
176
|
+
result = copy.deepcopy(config)
|
|
177
|
+
result["storage"]["model_cache_dir"] = str(_BUNDLED_MODELS_DIR)
|
|
178
|
+
result["retrieval"]["offline"] = True
|
|
179
|
+
logger.info(
|
|
180
|
+
"Using bundled retrieval models at %s (offline mode)", _BUNDLED_MODELS_DIR
|
|
181
|
+
)
|
|
182
|
+
return result
|
|
183
|
+
|
|
184
|
+
|
|
139
185
|
def load_config(config_path: Path | None = None) -> dict[str, Any]:
|
|
140
186
|
"""Load configuration from YAML file with defaults.
|
|
141
187
|
|
|
@@ -156,14 +202,15 @@ def load_config(config_path: Path | None = None) -> dict[str, Any]:
|
|
|
156
202
|
|
|
157
203
|
if not path.exists():
|
|
158
204
|
logger.info("Config file not found at %s; using built-in defaults", path)
|
|
159
|
-
return _DEFAULTS.copy()
|
|
205
|
+
return _resolve_bundled_models(_DEFAULTS.copy())
|
|
160
206
|
|
|
161
207
|
logger.info("Using config: %s", path)
|
|
162
208
|
|
|
163
|
-
with open(path,
|
|
209
|
+
with open(path, encoding="utf-8") as f:
|
|
164
210
|
user_config = yaml.safe_load(f) or {}
|
|
165
211
|
|
|
166
212
|
result = _deep_merge(_DEFAULTS, user_config)
|
|
213
|
+
result = _resolve_bundled_models(result)
|
|
167
214
|
|
|
168
215
|
# Cache result only for default path
|
|
169
216
|
if config_path is None:
|
|
@@ -57,8 +57,9 @@ def main():
|
|
|
57
57
|
logging.basicConfig(level=log_level, format="%(message)s", stream=sys.stderr)
|
|
58
58
|
|
|
59
59
|
if args.dump_defaults:
|
|
60
|
-
from .cfgload import _DEFAULTS
|
|
61
60
|
import yaml
|
|
61
|
+
|
|
62
|
+
from .cfgload import _DEFAULTS
|
|
62
63
|
yaml.dump(_DEFAULTS, sys.stdout, default_flow_style=False, sort_keys=False)
|
|
63
64
|
return
|
|
64
65
|
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""DOCX/DOC document processing utilities.
|
|
4
|
+
|
|
5
|
+
Handles parsing DOCX files into heading-based documents, heading level
|
|
6
|
+
extraction, and .doc-to-.docx conversion via LibreOffice.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import TYPE_CHECKING, Any
|
|
14
|
+
|
|
15
|
+
from docx import Document
|
|
16
|
+
from llama_index.core import Document as LlamaIndexDocument
|
|
17
|
+
|
|
18
|
+
from . import cfgload
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from .ui import FileProcessingContext
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _parse_heading_level(style_name: str | None) -> int:
|
|
27
|
+
"""Best-effort extraction of a numeric heading level from a DOCX style name."""
|
|
28
|
+
if not style_name:
|
|
29
|
+
return 1
|
|
30
|
+
try:
|
|
31
|
+
if "Heading" in style_name:
|
|
32
|
+
level_str = style_name.replace("Heading", "").strip()
|
|
33
|
+
if level_str:
|
|
34
|
+
return int(level_str)
|
|
35
|
+
except (ValueError, AttributeError):
|
|
36
|
+
pass
|
|
37
|
+
return 1
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _is_heading_style(style_name: str) -> bool:
|
|
41
|
+
"""Check whether a DOCX style name represents a heading."""
|
|
42
|
+
return (
|
|
43
|
+
style_name.startswith("Heading")
|
|
44
|
+
or style_name.startswith("heading")
|
|
45
|
+
or "Heading" in style_name
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _get_doc_temp_dir() -> Path:
|
|
50
|
+
"""Get the temporary directory for .doc conversion, creating it if needed."""
|
|
51
|
+
storage_dir = Path(cfgload.get("storage.storage_dir", "./storage"))
|
|
52
|
+
temp_dir = storage_dir / "doc_temp"
|
|
53
|
+
temp_dir.mkdir(parents=True, exist_ok=True)
|
|
54
|
+
return temp_dir
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _convert_doc_to_docx(doc_path: Path, timeout: float = 60) -> Path | None:
|
|
58
|
+
"""Convert a .doc file to .docx using LibreOffice.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
doc_path: Path to .doc file
|
|
62
|
+
timeout: Timeout in seconds for conversion process
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
Path to temporary .docx file, or None if conversion fails.
|
|
66
|
+
Caller is responsible for cleaning up the temp file.
|
|
67
|
+
"""
|
|
68
|
+
import shutil
|
|
69
|
+
import subprocess
|
|
70
|
+
|
|
71
|
+
# Find LibreOffice executable
|
|
72
|
+
soffice_paths = [
|
|
73
|
+
"/Applications/LibreOffice.app/Contents/MacOS/soffice", # macOS
|
|
74
|
+
"/usr/bin/soffice", # Linux
|
|
75
|
+
"/usr/bin/libreoffice", # Linux alternative
|
|
76
|
+
"soffice", # Windows (in PATH)
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
soffice = None
|
|
80
|
+
for path in soffice_paths:
|
|
81
|
+
if shutil.which(path):
|
|
82
|
+
soffice = path
|
|
83
|
+
break
|
|
84
|
+
|
|
85
|
+
if not soffice:
|
|
86
|
+
logger.warning(f"LibreOffice not found. Cannot convert {doc_path}")
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
# Use storage directory for temp files (more reliable space than /tmp)
|
|
90
|
+
temp_dir = _get_doc_temp_dir()
|
|
91
|
+
|
|
92
|
+
try:
|
|
93
|
+
result = subprocess.run(
|
|
94
|
+
[soffice, "--headless", "--convert-to", "docx",
|
|
95
|
+
"--outdir", str(temp_dir), str(doc_path)],
|
|
96
|
+
capture_output=True,
|
|
97
|
+
timeout=timeout,
|
|
98
|
+
)
|
|
99
|
+
if result.returncode != 0:
|
|
100
|
+
logger.warning(f"LibreOffice conversion failed for {doc_path}: {result.stderr}")
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
# Find the converted file
|
|
104
|
+
docx_name = doc_path.stem + ".docx"
|
|
105
|
+
docx_path = temp_dir / docx_name
|
|
106
|
+
if docx_path.exists():
|
|
107
|
+
return docx_path
|
|
108
|
+
|
|
109
|
+
logger.warning(f"Converted file not found: {docx_path}")
|
|
110
|
+
except subprocess.TimeoutExpired:
|
|
111
|
+
logger.warning(f"LibreOffice conversion timed out for {doc_path}")
|
|
112
|
+
except Exception as e:
|
|
113
|
+
logger.warning(f"Error converting {doc_path}: {e}")
|
|
114
|
+
|
|
115
|
+
return None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def split_docx_into_heading_documents(
|
|
119
|
+
docx_path: Path,
|
|
120
|
+
ctx: FileProcessingContext | None = None,
|
|
121
|
+
*,
|
|
122
|
+
heading_store: Any = None,
|
|
123
|
+
excluded_embed_metadata_keys: list[str] | None = None,
|
|
124
|
+
excluded_llm_metadata_keys: list[str] | None = None,
|
|
125
|
+
) -> list[LlamaIndexDocument]:
|
|
126
|
+
"""Split DOCX into documents by heading with progress updates.
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
docx_path: Path to DOCX file
|
|
130
|
+
ctx: Optional processing context for progress updates and timeout
|
|
131
|
+
heading_store: HeadingStore instance for persisting heading metadata
|
|
132
|
+
excluded_embed_metadata_keys: Keys to exclude from embedding text
|
|
133
|
+
excluded_llm_metadata_keys: Keys to exclude from LLM context
|
|
134
|
+
"""
|
|
135
|
+
_excluded_embed = excluded_embed_metadata_keys or []
|
|
136
|
+
_excluded_llm = excluded_llm_metadata_keys or []
|
|
137
|
+
|
|
138
|
+
docs: list[LlamaIndexDocument] = []
|
|
139
|
+
|
|
140
|
+
if ctx:
|
|
141
|
+
ctx.set_phase("Opening DOCX")
|
|
142
|
+
|
|
143
|
+
try:
|
|
144
|
+
doc = Document(docx_path)
|
|
145
|
+
except Exception as e:
|
|
146
|
+
logger.warning(f"Failed to open DOCX {docx_path}: {e}")
|
|
147
|
+
return docs
|
|
148
|
+
|
|
149
|
+
# Extract file dates from filesystem
|
|
150
|
+
stat = docx_path.stat()
|
|
151
|
+
creation_date = datetime.fromtimestamp(stat.st_ctime).strftime("%Y-%m-%d")
|
|
152
|
+
last_modified_date = datetime.fromtimestamp(stat.st_mtime).strftime("%Y-%m-%d")
|
|
153
|
+
|
|
154
|
+
# Try to extract dates from DOCX core properties (more accurate than filesystem)
|
|
155
|
+
try:
|
|
156
|
+
core_props = doc.core_properties
|
|
157
|
+
if core_props.created:
|
|
158
|
+
creation_date = core_props.created.strftime("%Y-%m-%d")
|
|
159
|
+
if core_props.modified:
|
|
160
|
+
last_modified_date = core_props.modified.strftime("%Y-%m-%d")
|
|
161
|
+
except Exception:
|
|
162
|
+
logger.debug("Could not read DOCX core properties for %s, using filesystem dates", docx_path, exc_info=True)
|
|
163
|
+
|
|
164
|
+
# First pass: Extract all headings with positions for hierarchy metadata
|
|
165
|
+
if ctx:
|
|
166
|
+
ctx.set_phase("Extracting headings")
|
|
167
|
+
|
|
168
|
+
all_headings = []
|
|
169
|
+
char_position = 0
|
|
170
|
+
for para in doc.paragraphs:
|
|
171
|
+
# Periodically check for timeout during long operations
|
|
172
|
+
if ctx and len(all_headings) % 100 == 0:
|
|
173
|
+
ctx.set_phase(f"Extracting headings ({len(all_headings)} found)")
|
|
174
|
+
|
|
175
|
+
style_name = getattr(para.style, "name", "") or ""
|
|
176
|
+
|
|
177
|
+
if _is_heading_style(style_name) and para.text.strip():
|
|
178
|
+
heading_level = _parse_heading_level(style_name)
|
|
179
|
+
all_headings.append({
|
|
180
|
+
"text": para.text.strip(),
|
|
181
|
+
"position": char_position,
|
|
182
|
+
"level": heading_level
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
char_position += len(para.text) + 1 # +1 for newline
|
|
186
|
+
|
|
187
|
+
# Store headings separately to avoid metadata size issues during chunking
|
|
188
|
+
if heading_store is not None:
|
|
189
|
+
if ctx:
|
|
190
|
+
ctx.set_phase("Storing heading metadata")
|
|
191
|
+
heading_store.set_headings(str(docx_path), all_headings)
|
|
192
|
+
|
|
193
|
+
# Second pass: Split by heading (existing logic)
|
|
194
|
+
if ctx:
|
|
195
|
+
ctx.set_phase("Splitting into sections")
|
|
196
|
+
current_heading: str | None = None
|
|
197
|
+
current_level: int | None = None
|
|
198
|
+
current_body: list[str] = []
|
|
199
|
+
|
|
200
|
+
def flush_current():
|
|
201
|
+
if not current_heading:
|
|
202
|
+
return
|
|
203
|
+
text = "\n".join(line for line in current_body if line is not None).strip()
|
|
204
|
+
if not text:
|
|
205
|
+
return
|
|
206
|
+
|
|
207
|
+
# Build hierarchical heading_path by finding parent headings based on level
|
|
208
|
+
heading_path = []
|
|
209
|
+
if all_headings:
|
|
210
|
+
# Find the index of the current heading in all_headings
|
|
211
|
+
current_idx = None
|
|
212
|
+
for idx, h in enumerate(all_headings):
|
|
213
|
+
if h["text"] == current_heading and h["level"] == current_level:
|
|
214
|
+
current_idx = idx
|
|
215
|
+
break
|
|
216
|
+
|
|
217
|
+
if current_idx is not None:
|
|
218
|
+
# Build path by including all parent headings (those with lower level numbers)
|
|
219
|
+
# Walk backwards from current heading and include headings with level < current_level
|
|
220
|
+
path_headings = [all_headings[current_idx]] # Start with current
|
|
221
|
+
for idx in range(current_idx - 1, -1, -1):
|
|
222
|
+
h = all_headings[idx]
|
|
223
|
+
if h["level"] < path_headings[0]["level"]:
|
|
224
|
+
path_headings.insert(0, h)
|
|
225
|
+
heading_path = [h["text"] for h in path_headings]
|
|
226
|
+
|
|
227
|
+
metadata = {
|
|
228
|
+
"file_path": str(docx_path),
|
|
229
|
+
"file_name": docx_path.name,
|
|
230
|
+
"source": str(docx_path),
|
|
231
|
+
"heading": current_heading,
|
|
232
|
+
"heading_level": current_level,
|
|
233
|
+
"creation_date": creation_date,
|
|
234
|
+
"last_modified_date": last_modified_date,
|
|
235
|
+
"heading_path": heading_path, # Pre-computed hierarchical path
|
|
236
|
+
}
|
|
237
|
+
docs.append(LlamaIndexDocument(
|
|
238
|
+
text=text,
|
|
239
|
+
metadata=metadata,
|
|
240
|
+
excluded_embed_metadata_keys=_excluded_embed,
|
|
241
|
+
excluded_llm_metadata_keys=_excluded_llm,
|
|
242
|
+
))
|
|
243
|
+
|
|
244
|
+
for para in doc.paragraphs:
|
|
245
|
+
style_name = getattr(para.style, "name", "") or ""
|
|
246
|
+
|
|
247
|
+
if _is_heading_style(style_name) and para.text.strip():
|
|
248
|
+
flush_current()
|
|
249
|
+
current_heading = para.text.strip()
|
|
250
|
+
current_level = _parse_heading_level(style_name)
|
|
251
|
+
current_body = []
|
|
252
|
+
else:
|
|
253
|
+
if current_heading is not None:
|
|
254
|
+
current_body.append(para.text)
|
|
255
|
+
|
|
256
|
+
flush_current()
|
|
257
|
+
|
|
258
|
+
if not docs:
|
|
259
|
+
try:
|
|
260
|
+
full_text = "\n".join(p.text for p in doc.paragraphs).strip()
|
|
261
|
+
except Exception:
|
|
262
|
+
logger.debug("Failed to extract paragraph text from %s", docx_path, exc_info=True)
|
|
263
|
+
full_text = ""
|
|
264
|
+
|
|
265
|
+
if full_text:
|
|
266
|
+
metadata = {
|
|
267
|
+
"file_path": str(docx_path),
|
|
268
|
+
"file_name": docx_path.name,
|
|
269
|
+
"source": str(docx_path),
|
|
270
|
+
"heading": None,
|
|
271
|
+
"heading_level": None,
|
|
272
|
+
"creation_date": creation_date,
|
|
273
|
+
"last_modified_date": last_modified_date,
|
|
274
|
+
}
|
|
275
|
+
docs.append(LlamaIndexDocument(
|
|
276
|
+
text=full_text,
|
|
277
|
+
metadata=metadata,
|
|
278
|
+
excluded_embed_metadata_keys=_excluded_embed,
|
|
279
|
+
excluded_llm_metadata_keys=_excluded_llm,
|
|
280
|
+
))
|
|
281
|
+
|
|
282
|
+
logger.info(
|
|
283
|
+
f"Split DOCX {docx_path} into {len(docs)} heading-based document(s)"
|
|
284
|
+
)
|
|
285
|
+
return docs
|