chunksilo 2.3.2__tar.gz → 2.3.4__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.2/src/chunksilo.egg-info → chunksilo-2.3.4}/PKG-INFO +2 -1
- {chunksilo-2.3.2 → chunksilo-2.3.4}/pyproject.toml +23 -2
- {chunksilo-2.3.2 → chunksilo-2.3.4}/src/chunksilo/__init__.py +1 -1
- {chunksilo-2.3.2 → chunksilo-2.3.4}/src/chunksilo/cfgload.py +39 -3
- {chunksilo-2.3.2 → chunksilo-2.3.4}/src/chunksilo/cli.py +2 -1
- chunksilo-2.3.4/src/chunksilo/docx_utils.py +285 -0
- {chunksilo-2.3.2 → chunksilo-2.3.4}/src/chunksilo/index.py +218 -1041
- chunksilo-2.3.4/src/chunksilo/models.py +75 -0
- {chunksilo-2.3.2 → chunksilo-2.3.4}/src/chunksilo/search.py +270 -278
- chunksilo-2.3.4/src/chunksilo/ui.py +639 -0
- {chunksilo-2.3.2 → chunksilo-2.3.4/src/chunksilo.egg-info}/PKG-INFO +2 -1
- {chunksilo-2.3.2 → chunksilo-2.3.4}/src/chunksilo.egg-info/SOURCES.txt +7 -0
- {chunksilo-2.3.2 → chunksilo-2.3.4}/src/chunksilo.egg-info/requires.txt +1 -0
- chunksilo-2.3.4/test/test_cli.py +148 -0
- chunksilo-2.3.4/test/test_docx_utils.py +261 -0
- {chunksilo-2.3.2 → chunksilo-2.3.4}/test/test_indexing_ui.py +35 -32
- chunksilo-2.3.4/test/test_scan_timeouts.py +369 -0
- chunksilo-2.3.4/test/test_server.py +107 -0
- {chunksilo-2.3.2 → chunksilo-2.3.4}/LICENSE +0 -0
- {chunksilo-2.3.2 → chunksilo-2.3.4}/NOTICE +0 -0
- {chunksilo-2.3.2 → chunksilo-2.3.4}/README.md +0 -0
- {chunksilo-2.3.2 → chunksilo-2.3.4}/requirements.txt +0 -0
- {chunksilo-2.3.2 → chunksilo-2.3.4}/setup.cfg +0 -0
- {chunksilo-2.3.2 → chunksilo-2.3.4}/src/chunksilo/__main__.py +0 -0
- {chunksilo-2.3.2 → chunksilo-2.3.4}/src/chunksilo/confluence_html_formatter.py +0 -0
- {chunksilo-2.3.2 → chunksilo-2.3.4}/src/chunksilo/server.py +1 -1
- {chunksilo-2.3.2 → chunksilo-2.3.4}/src/chunksilo.egg-info/dependency_links.txt +0 -0
- {chunksilo-2.3.2 → chunksilo-2.3.4}/src/chunksilo.egg-info/entry_points.txt +0 -0
- {chunksilo-2.3.2 → chunksilo-2.3.4}/src/chunksilo.egg-info/top_level.txt +0 -0
- {chunksilo-2.3.2 → chunksilo-2.3.4}/test/test_chunk_location.py +0 -0
- {chunksilo-2.3.2 → chunksilo-2.3.4}/test/test_confluence_html_formatter.py +0 -0
- {chunksilo-2.3.2 → chunksilo-2.3.4}/test/test_error_handling.py +0 -0
- {chunksilo-2.3.2 → chunksilo-2.3.4}/test/test_heading_path_integration.py +0 -0
- {chunksilo-2.3.2 → chunksilo-2.3.4}/test/test_incremental_ingest.py +0 -0
- {chunksilo-2.3.2 → chunksilo-2.3.4}/test/test_indexing_benchmark.py +0 -0
- {chunksilo-2.3.2 → chunksilo-2.3.4}/test/test_jira_integration.py +0 -0
- {chunksilo-2.3.2 → chunksilo-2.3.4}/test/test_quoted_phrases.py +0 -0
- {chunksilo-2.3.2 → chunksilo-2.3.4}/test/test_rag_metrics.py +0 -0
- {chunksilo-2.3.2 → chunksilo-2.3.4}/test/test_retrieval_only.py +0 -0
- {chunksilo-2.3.2 → chunksilo-2.3.4}/test/test_system.py +0 -0
- {chunksilo-2.3.2 → chunksilo-2.3.4}/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
|
+
Version: 2.3.4
|
|
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
|
|
@@ -36,6 +36,7 @@ Provides-Extra: jira
|
|
|
36
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
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "chunksilo"
|
|
7
|
-
version = "2.3.
|
|
7
|
+
version = "2.3.4"
|
|
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"
|
|
@@ -27,7 +27,7 @@ dynamic = ["dependencies"]
|
|
|
27
27
|
[project.optional-dependencies]
|
|
28
28
|
confluence = ["llama-index-readers-confluence>=0.6.0,<1"]
|
|
29
29
|
jira = ["jira>=3.5.0,<4"]
|
|
30
|
-
test = ["pytest>=7.4.0,<9", "requests>=2.31.0,<3"]
|
|
30
|
+
test = ["pytest>=7.4.0,<9", "pytest-cov>=4.0", "requests>=2.31.0,<3"]
|
|
31
31
|
|
|
32
32
|
[project.scripts]
|
|
33
33
|
chunksilo = "chunksilo.cli:main"
|
|
@@ -42,3 +42,24 @@ dependencies = {file = ["requirements.txt"]}
|
|
|
42
42
|
|
|
43
43
|
[tool.setuptools.packages.find]
|
|
44
44
|
where = ["src"]
|
|
45
|
+
|
|
46
|
+
# Bundle the retrieval models into the wheel so air-gapped installs work with no
|
|
47
|
+
# network access. Populated at build time (see the publish job / prepare-common.sh).
|
|
48
|
+
# The HuggingFace `blobs/` copies are excluded: with HF_HUB_DISABLE_SYMLINKS=1 the
|
|
49
|
+
# `snapshots/` tree holds real files, so blobs would only duplicate the bytes.
|
|
50
|
+
[tool.setuptools.package-data]
|
|
51
|
+
chunksilo = ["_bundled_models/**/*"]
|
|
52
|
+
|
|
53
|
+
[tool.setuptools.exclude-package-data]
|
|
54
|
+
chunksilo = ["_bundled_models/**/blobs/**"]
|
|
55
|
+
|
|
56
|
+
[tool.ruff]
|
|
57
|
+
target-version = "py311"
|
|
58
|
+
line-length = 120
|
|
59
|
+
|
|
60
|
+
[tool.ruff.lint]
|
|
61
|
+
select = ["E", "F", "I", "UP"]
|
|
62
|
+
ignore = ["E501"]
|
|
63
|
+
|
|
64
|
+
[tool.ruff.lint.isort]
|
|
65
|
+
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.
|
|
@@ -75,6 +81,7 @@ _DEFAULTS: dict[str, Any] = {
|
|
|
75
81
|
"per_file_seconds": 300, # 5 minutes per file
|
|
76
82
|
"doc_conversion_seconds": 90, # 90 seconds for .doc conversion
|
|
77
83
|
"heartbeat_interval_seconds": 2,
|
|
84
|
+
"scan_item_seconds": 30, # timeout for stat/hash/walk during scanning
|
|
78
85
|
},
|
|
79
86
|
"logging": {
|
|
80
87
|
"log_slow_files": True,
|
|
@@ -135,6 +142,34 @@ def _deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any
|
|
|
135
142
|
return result
|
|
136
143
|
|
|
137
144
|
|
|
145
|
+
def _resolve_bundled_models(config: dict[str, Any]) -> dict[str, Any]:
|
|
146
|
+
"""Transparently fall back to models bundled in the package.
|
|
147
|
+
|
|
148
|
+
If the wheel ships the retrieval models and the configured cache directory
|
|
149
|
+
does not already contain them, point the cache at the bundled copy and enable
|
|
150
|
+
offline mode. This lets a pip-installed wheel run in an air-gapped environment
|
|
151
|
+
with zero extra configuration. A user-configured cache that already holds the
|
|
152
|
+
models always wins, so explicit setups are never overridden.
|
|
153
|
+
"""
|
|
154
|
+
embed_name = config["retrieval"]["embed_model_name"]
|
|
155
|
+
hf_dirname = f"models--{embed_name.replace('/', '--')}"
|
|
156
|
+
|
|
157
|
+
if not (_BUNDLED_MODELS_DIR / hf_dirname).exists():
|
|
158
|
+
return config # No bundled models for this embedding model.
|
|
159
|
+
|
|
160
|
+
configured = Path(config["storage"]["model_cache_dir"]).expanduser()
|
|
161
|
+
if (configured / hf_dirname).exists():
|
|
162
|
+
return config # Configured cache already has the models; respect it.
|
|
163
|
+
|
|
164
|
+
result = copy.deepcopy(config)
|
|
165
|
+
result["storage"]["model_cache_dir"] = str(_BUNDLED_MODELS_DIR)
|
|
166
|
+
result["retrieval"]["offline"] = True
|
|
167
|
+
logger.info(
|
|
168
|
+
"Using bundled retrieval models at %s (offline mode)", _BUNDLED_MODELS_DIR
|
|
169
|
+
)
|
|
170
|
+
return result
|
|
171
|
+
|
|
172
|
+
|
|
138
173
|
def load_config(config_path: Path | None = None) -> dict[str, Any]:
|
|
139
174
|
"""Load configuration from YAML file with defaults.
|
|
140
175
|
|
|
@@ -155,14 +190,15 @@ def load_config(config_path: Path | None = None) -> dict[str, Any]:
|
|
|
155
190
|
|
|
156
191
|
if not path.exists():
|
|
157
192
|
logger.info("Config file not found at %s; using built-in defaults", path)
|
|
158
|
-
return _DEFAULTS.copy()
|
|
193
|
+
return _resolve_bundled_models(_DEFAULTS.copy())
|
|
159
194
|
|
|
160
195
|
logger.info("Using config: %s", path)
|
|
161
196
|
|
|
162
|
-
with open(path,
|
|
197
|
+
with open(path, encoding="utf-8") as f:
|
|
163
198
|
user_config = yaml.safe_load(f) or {}
|
|
164
199
|
|
|
165
200
|
result = _deep_merge(_DEFAULTS, user_config)
|
|
201
|
+
result = _resolve_bundled_models(result)
|
|
166
202
|
|
|
167
203
|
# Cache result only for default path
|
|
168
204
|
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
|