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.
Files changed (41) hide show
  1. {chunksilo-2.3.2/src/chunksilo.egg-info → chunksilo-2.3.4}/PKG-INFO +2 -1
  2. {chunksilo-2.3.2 → chunksilo-2.3.4}/pyproject.toml +23 -2
  3. {chunksilo-2.3.2 → chunksilo-2.3.4}/src/chunksilo/__init__.py +1 -1
  4. {chunksilo-2.3.2 → chunksilo-2.3.4}/src/chunksilo/cfgload.py +39 -3
  5. {chunksilo-2.3.2 → chunksilo-2.3.4}/src/chunksilo/cli.py +2 -1
  6. chunksilo-2.3.4/src/chunksilo/docx_utils.py +285 -0
  7. {chunksilo-2.3.2 → chunksilo-2.3.4}/src/chunksilo/index.py +218 -1041
  8. chunksilo-2.3.4/src/chunksilo/models.py +75 -0
  9. {chunksilo-2.3.2 → chunksilo-2.3.4}/src/chunksilo/search.py +270 -278
  10. chunksilo-2.3.4/src/chunksilo/ui.py +639 -0
  11. {chunksilo-2.3.2 → chunksilo-2.3.4/src/chunksilo.egg-info}/PKG-INFO +2 -1
  12. {chunksilo-2.3.2 → chunksilo-2.3.4}/src/chunksilo.egg-info/SOURCES.txt +7 -0
  13. {chunksilo-2.3.2 → chunksilo-2.3.4}/src/chunksilo.egg-info/requires.txt +1 -0
  14. chunksilo-2.3.4/test/test_cli.py +148 -0
  15. chunksilo-2.3.4/test/test_docx_utils.py +261 -0
  16. {chunksilo-2.3.2 → chunksilo-2.3.4}/test/test_indexing_ui.py +35 -32
  17. chunksilo-2.3.4/test/test_scan_timeouts.py +369 -0
  18. chunksilo-2.3.4/test/test_server.py +107 -0
  19. {chunksilo-2.3.2 → chunksilo-2.3.4}/LICENSE +0 -0
  20. {chunksilo-2.3.2 → chunksilo-2.3.4}/NOTICE +0 -0
  21. {chunksilo-2.3.2 → chunksilo-2.3.4}/README.md +0 -0
  22. {chunksilo-2.3.2 → chunksilo-2.3.4}/requirements.txt +0 -0
  23. {chunksilo-2.3.2 → chunksilo-2.3.4}/setup.cfg +0 -0
  24. {chunksilo-2.3.2 → chunksilo-2.3.4}/src/chunksilo/__main__.py +0 -0
  25. {chunksilo-2.3.2 → chunksilo-2.3.4}/src/chunksilo/confluence_html_formatter.py +0 -0
  26. {chunksilo-2.3.2 → chunksilo-2.3.4}/src/chunksilo/server.py +1 -1
  27. {chunksilo-2.3.2 → chunksilo-2.3.4}/src/chunksilo.egg-info/dependency_links.txt +0 -0
  28. {chunksilo-2.3.2 → chunksilo-2.3.4}/src/chunksilo.egg-info/entry_points.txt +0 -0
  29. {chunksilo-2.3.2 → chunksilo-2.3.4}/src/chunksilo.egg-info/top_level.txt +0 -0
  30. {chunksilo-2.3.2 → chunksilo-2.3.4}/test/test_chunk_location.py +0 -0
  31. {chunksilo-2.3.2 → chunksilo-2.3.4}/test/test_confluence_html_formatter.py +0 -0
  32. {chunksilo-2.3.2 → chunksilo-2.3.4}/test/test_error_handling.py +0 -0
  33. {chunksilo-2.3.2 → chunksilo-2.3.4}/test/test_heading_path_integration.py +0 -0
  34. {chunksilo-2.3.2 → chunksilo-2.3.4}/test/test_incremental_ingest.py +0 -0
  35. {chunksilo-2.3.2 → chunksilo-2.3.4}/test/test_indexing_benchmark.py +0 -0
  36. {chunksilo-2.3.2 → chunksilo-2.3.4}/test/test_jira_integration.py +0 -0
  37. {chunksilo-2.3.2 → chunksilo-2.3.4}/test/test_quoted_phrases.py +0 -0
  38. {chunksilo-2.3.2 → chunksilo-2.3.4}/test/test_rag_metrics.py +0 -0
  39. {chunksilo-2.3.2 → chunksilo-2.3.4}/test/test_retrieval_only.py +0 -0
  40. {chunksilo-2.3.2 → chunksilo-2.3.4}/test/test_system.py +0 -0
  41. {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.2
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.2"
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"]
@@ -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.2"
4
+ __version__ = "2.3.4"
@@ -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, "r", encoding="utf-8") as f:
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