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.
Files changed (41) hide show
  1. {chunksilo-2.3.3/src/chunksilo.egg-info → chunksilo-2.4.0}/PKG-INFO +12 -10
  2. {chunksilo-2.3.3 → chunksilo-2.4.0}/README.md +8 -7
  3. {chunksilo-2.3.3 → chunksilo-2.4.0}/pyproject.toml +28 -4
  4. {chunksilo-2.3.3 → chunksilo-2.4.0}/requirements.txt +2 -0
  5. {chunksilo-2.3.3 → chunksilo-2.4.0}/src/chunksilo/__init__.py +1 -1
  6. {chunksilo-2.3.3 → chunksilo-2.4.0}/src/chunksilo/cfgload.py +50 -3
  7. {chunksilo-2.3.3 → chunksilo-2.4.0}/src/chunksilo/cli.py +2 -1
  8. chunksilo-2.4.0/src/chunksilo/docx_utils.py +285 -0
  9. {chunksilo-2.3.3 → chunksilo-2.4.0}/src/chunksilo/index.py +78 -989
  10. chunksilo-2.4.0/src/chunksilo/models.py +75 -0
  11. {chunksilo-2.3.3 → chunksilo-2.4.0}/src/chunksilo/search.py +270 -278
  12. chunksilo-2.4.0/src/chunksilo/ui.py +639 -0
  13. {chunksilo-2.3.3 → chunksilo-2.4.0/src/chunksilo.egg-info}/PKG-INFO +12 -10
  14. {chunksilo-2.3.3 → chunksilo-2.4.0}/src/chunksilo.egg-info/SOURCES.txt +6 -0
  15. {chunksilo-2.3.3 → chunksilo-2.4.0}/src/chunksilo.egg-info/requires.txt +3 -2
  16. chunksilo-2.4.0/test/test_cli.py +148 -0
  17. chunksilo-2.4.0/test/test_docx_utils.py +261 -0
  18. {chunksilo-2.3.3 → chunksilo-2.4.0}/test/test_indexing_ui.py +8 -8
  19. chunksilo-2.4.0/test/test_server.py +107 -0
  20. {chunksilo-2.3.3 → chunksilo-2.4.0}/LICENSE +0 -0
  21. {chunksilo-2.3.3 → chunksilo-2.4.0}/NOTICE +0 -0
  22. {chunksilo-2.3.3 → chunksilo-2.4.0}/setup.cfg +0 -0
  23. {chunksilo-2.3.3 → chunksilo-2.4.0}/src/chunksilo/__main__.py +0 -0
  24. {chunksilo-2.3.3 → chunksilo-2.4.0}/src/chunksilo/confluence_html_formatter.py +0 -0
  25. {chunksilo-2.3.3 → chunksilo-2.4.0}/src/chunksilo/server.py +1 -1
  26. {chunksilo-2.3.3 → chunksilo-2.4.0}/src/chunksilo.egg-info/dependency_links.txt +0 -0
  27. {chunksilo-2.3.3 → chunksilo-2.4.0}/src/chunksilo.egg-info/entry_points.txt +0 -0
  28. {chunksilo-2.3.3 → chunksilo-2.4.0}/src/chunksilo.egg-info/top_level.txt +0 -0
  29. {chunksilo-2.3.3 → chunksilo-2.4.0}/test/test_chunk_location.py +0 -0
  30. {chunksilo-2.3.3 → chunksilo-2.4.0}/test/test_confluence_html_formatter.py +0 -0
  31. {chunksilo-2.3.3 → chunksilo-2.4.0}/test/test_error_handling.py +0 -0
  32. {chunksilo-2.3.3 → chunksilo-2.4.0}/test/test_heading_path_integration.py +0 -0
  33. {chunksilo-2.3.3 → chunksilo-2.4.0}/test/test_incremental_ingest.py +0 -0
  34. {chunksilo-2.3.3 → chunksilo-2.4.0}/test/test_indexing_benchmark.py +0 -0
  35. {chunksilo-2.3.3 → chunksilo-2.4.0}/test/test_jira_integration.py +0 -0
  36. {chunksilo-2.3.3 → chunksilo-2.4.0}/test/test_quoted_phrases.py +0 -0
  37. {chunksilo-2.3.3 → chunksilo-2.4.0}/test/test_rag_metrics.py +0 -0
  38. {chunksilo-2.3.3 → chunksilo-2.4.0}/test/test_retrieval_only.py +0 -0
  39. {chunksilo-2.3.3 → chunksilo-2.4.0}/test/test_scan_timeouts.py +0 -0
  40. {chunksilo-2.3.3 → chunksilo-2.4.0}/test/test_system.py +0 -0
  41. {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.3
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 integration requires the optional dependency. Install with: `pip install chunksilo[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 integration requires the optional dependency. Install with: `pip install chunksilo[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**: Install with `pip install chunksilo[confluence]`, then set `confluence.url`, `confluence.username`, and `confluence.api_token` in `config.yaml`.
422
- - **Jira Integration**: Install with `pip install chunksilo[jira]`, then set `jira.url`, `jira.username`, and `jira.api_token` in `config.yaml`. Optionally configure `jira.projects` to restrict search to specific project keys.
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 integration requires the optional dependency. Install with: `pip install chunksilo[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 integration requires the optional dependency. Install with: `pip install chunksilo[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**: Install with `pip install chunksilo[confluence]`, then set `confluence.url`, `confluence.username`, and `confluence.api_token` in `config.yaml`.
381
- - **Jira Integration**: Install with `pip install chunksilo[jira]`, then set `jira.url`, `jira.username`, and `jira.api_token` in `config.yaml`. Optionally configure `jira.projects` to restrict search to specific project keys.
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.3.3"
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
- confluence = ["llama-index-readers-confluence>=0.6.0,<1"]
29
- jira = ["jira>=3.5.0,<4"]
30
- test = ["pytest>=7.4.0,<9", "requests>=2.31.0,<3"]
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"]
@@ -10,3 +10,5 @@ huggingface-hub>=0.22.0,<2
10
10
  flashrank>=0.1.0,<1
11
11
  fastembed>=0.5.0,<1
12
12
  pyyaml>=6.0,<7
13
+ llama-index-readers-confluence>=0.6.0,<1
14
+ jira>=3.5.0,<4
@@ -1,4 +1,4 @@
1
1
  # SPDX-License-Identifier: Apache-2.0
2
2
  """ChunkSilo - Local RAG-based semantic document search."""
3
3
 
4
- __version__ = "2.3.3"
4
+ __version__ = "2.4.0"
@@ -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, "r", encoding="utf-8") as f:
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