doclex-cli 0.1.1__py3-none-any.whl
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.
- doclex/__init__.py +0 -0
- doclex/cache/__init__.py +0 -0
- doclex/cache/store.py +196 -0
- doclex/cli.py +430 -0
- doclex/config.py +43 -0
- doclex/extraction/__init__.py +0 -0
- doclex/extraction/extractor.py +72 -0
- doclex/index/__init__.py +0 -0
- doclex/index/bm25_index.py +120 -0
- doclex/ingestion/__init__.py +0 -0
- doclex/ingestion/scanner.py +87 -0
- doclex/ingestion/watcher.py +94 -0
- doclex/metadata/__init__.py +0 -0
- doclex/metadata/db.py +250 -0
- doclex/metadata/models.py +55 -0
- doclex/pipeline.py +257 -0
- doclex/registry.py +103 -0
- doclex/segmentation/__init__.py +0 -0
- doclex/segmentation/segmenter.py +398 -0
- doclex_cli-0.1.1.dist-info/METADATA +221 -0
- doclex_cli-0.1.1.dist-info/RECORD +24 -0
- doclex_cli-0.1.1.dist-info/WHEEL +4 -0
- doclex_cli-0.1.1.dist-info/entry_points.txt +2 -0
- doclex_cli-0.1.1.dist-info/licenses/LICENSE +21 -0
doclex/__init__.py
ADDED
|
File without changes
|
doclex/cache/__init__.py
ADDED
|
File without changes
|
doclex/cache/store.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from doclex.metadata.models import ExtractedDoc, Segment
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class CacheStore:
|
|
12
|
+
"""JSON file cache: one file per document, keyed by sha256 of the path."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, cache_dir: Path) -> None:
|
|
15
|
+
self._dir = cache_dir
|
|
16
|
+
self._dir.mkdir(parents=True, exist_ok=True)
|
|
17
|
+
|
|
18
|
+
def _cache_path(self, doc_path: str) -> Path:
|
|
19
|
+
key = hashlib.sha256(doc_path.encode()).hexdigest()
|
|
20
|
+
return self._dir / f"{key}.json"
|
|
21
|
+
|
|
22
|
+
def get(self, doc_path: str) -> tuple[ExtractedDoc, list[Segment]] | None:
|
|
23
|
+
"""Return cached (doc, segments) or None if not present."""
|
|
24
|
+
cache_file = self._cache_path(doc_path)
|
|
25
|
+
if not cache_file.exists():
|
|
26
|
+
return None
|
|
27
|
+
try:
|
|
28
|
+
data = json.loads(cache_file.read_text(encoding="utf-8"))
|
|
29
|
+
doc = ExtractedDoc(
|
|
30
|
+
path=data["path"],
|
|
31
|
+
text="", # content lives in index nodes
|
|
32
|
+
mime_type=data["mime_type"],
|
|
33
|
+
metadata=data.get("metadata", {}),
|
|
34
|
+
)
|
|
35
|
+
segments = _flatten_index(data.get("index", {}), data["path"])
|
|
36
|
+
return doc, segments
|
|
37
|
+
except (KeyError, json.JSONDecodeError):
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
def put(self, doc: ExtractedDoc, segments: list[Segment]) -> None:
|
|
41
|
+
"""Write extracted doc and its segments as a unified index tree."""
|
|
42
|
+
cache_file = self._cache_path(doc.path)
|
|
43
|
+
data = {
|
|
44
|
+
"path": doc.path,
|
|
45
|
+
"mime_type": doc.mime_type,
|
|
46
|
+
"metadata": doc.metadata,
|
|
47
|
+
"index": _build_index(Path(doc.path).stem, segments),
|
|
48
|
+
}
|
|
49
|
+
cache_file.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
50
|
+
|
|
51
|
+
def delete(self, doc_path: str) -> None:
|
|
52
|
+
cache_file = self._cache_path(doc_path)
|
|
53
|
+
if cache_file.exists():
|
|
54
|
+
cache_file.unlink()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# ------------------------------------------------------------------
|
|
58
|
+
# Index tree builder
|
|
59
|
+
# ------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
def _build_index(title: str, segments: list[Segment]) -> dict:
|
|
62
|
+
"""Build a unified PageIndex-style tree from flat segments."""
|
|
63
|
+
root: dict = {"title": title, "nodes": []}
|
|
64
|
+
|
|
65
|
+
has_hierarchy = any(s.level > 0 for s in segments)
|
|
66
|
+
if not has_hierarchy:
|
|
67
|
+
root["nodes"] = [_seg_to_node(s) for s in segments]
|
|
68
|
+
return root
|
|
69
|
+
|
|
70
|
+
stack: list[tuple[int, dict]] = [(0, root)]
|
|
71
|
+
for seg in segments:
|
|
72
|
+
node = _seg_to_node(seg)
|
|
73
|
+
if seg.level > 0:
|
|
74
|
+
# heading node: pop continuations (level=0) and same/deeper headings
|
|
75
|
+
while len(stack) > 1 and (stack[-1][0] == 0 or stack[-1][0] >= seg.level):
|
|
76
|
+
stack.pop()
|
|
77
|
+
stack[-1][1]["nodes"].append(node)
|
|
78
|
+
stack.append((seg.level, node))
|
|
79
|
+
else:
|
|
80
|
+
if stack[-1][0] == 0:
|
|
81
|
+
# previous was also a continuation — nest inside it
|
|
82
|
+
stack[-1][1]["nodes"].append(node)
|
|
83
|
+
else:
|
|
84
|
+
# first continuation after a heading — create a parent node from heading
|
|
85
|
+
title, _ = _extract_heading(node.get("content", ""))
|
|
86
|
+
if title:
|
|
87
|
+
_, body = _extract_heading(node.get("content", ""))
|
|
88
|
+
group_id = node["id"]
|
|
89
|
+
node["id"] = hashlib.sha256((group_id + "|body").encode()).hexdigest()[:32]
|
|
90
|
+
node["content"] = body
|
|
91
|
+
parent_node: dict = {
|
|
92
|
+
"id": group_id,
|
|
93
|
+
"title": title,
|
|
94
|
+
"location_type": node.get("location_type", "section"),
|
|
95
|
+
"content": "",
|
|
96
|
+
"bm25_doc_id": None,
|
|
97
|
+
"nodes": [node],
|
|
98
|
+
}
|
|
99
|
+
stack[-1][1]["nodes"].append(parent_node)
|
|
100
|
+
stack.append((0, parent_node))
|
|
101
|
+
else:
|
|
102
|
+
stack[-1][1]["nodes"].append(node)
|
|
103
|
+
stack.append((0, node))
|
|
104
|
+
|
|
105
|
+
_assign_navigation_links(root)
|
|
106
|
+
_clean_nodes(root)
|
|
107
|
+
return root
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _extract_heading(content: str) -> tuple[str, str]:
|
|
111
|
+
"""Extract the first markdown heading from content, return (title, remaining_body)."""
|
|
112
|
+
lines = content.split("\n", 1)
|
|
113
|
+
first_line = lines[0].strip()
|
|
114
|
+
m = re.match(r"^#{1,3} (.+)", first_line)
|
|
115
|
+
if not m:
|
|
116
|
+
return "", content
|
|
117
|
+
title = m.group(1).strip()
|
|
118
|
+
body = lines[1].strip() if len(lines) > 1 else ""
|
|
119
|
+
return title, body
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _assign_navigation_links(root: dict) -> None:
|
|
123
|
+
"""Assign prev_id, next_id, parent_id to every node in pre-order (reading order)."""
|
|
124
|
+
flat: list[tuple[dict, str | None]] = []
|
|
125
|
+
|
|
126
|
+
def collect(nodes: list, parent_id: str | None) -> None:
|
|
127
|
+
for node in nodes:
|
|
128
|
+
flat.append((node, parent_id))
|
|
129
|
+
collect(node.get("nodes", []), node["id"])
|
|
130
|
+
|
|
131
|
+
collect(root.get("nodes", []), None)
|
|
132
|
+
|
|
133
|
+
for i, (node, parent_id) in enumerate(flat):
|
|
134
|
+
node["parent_id"] = parent_id
|
|
135
|
+
node["prev_id"] = flat[i - 1][0]["id"] if i > 0 else None
|
|
136
|
+
node["next_id"] = flat[i + 1][0]["id"] if i < len(flat) - 1 else None
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _clean_nodes(node: dict) -> None:
|
|
140
|
+
"""Clean node titles and content to remove redundancy:
|
|
141
|
+
- Leaf nodes: drop title (content is sufficient)
|
|
142
|
+
- Non-leaf heading nodes: keep title, strip heading line from content
|
|
143
|
+
"""
|
|
144
|
+
for child in node.get("nodes", []):
|
|
145
|
+
_clean_nodes(child)
|
|
146
|
+
if not node.get("nodes"):
|
|
147
|
+
node.pop("title", None)
|
|
148
|
+
elif node.get("level", 0) > 0 and node.get("title"):
|
|
149
|
+
_, body = _extract_heading(node.get("content", ""))
|
|
150
|
+
node["content"] = body
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _seg_to_node(seg: Segment) -> dict:
|
|
154
|
+
node: dict = {
|
|
155
|
+
"id": seg.id,
|
|
156
|
+
"title": seg.location_label,
|
|
157
|
+
"location_type": seg.location_type,
|
|
158
|
+
"content": seg.content,
|
|
159
|
+
"bm25_doc_id": seg.bm25_doc_id,
|
|
160
|
+
"nodes": [],
|
|
161
|
+
}
|
|
162
|
+
if seg.level > 0:
|
|
163
|
+
node["level"] = seg.level
|
|
164
|
+
return node
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
# ------------------------------------------------------------------
|
|
168
|
+
# Index tree flattener (get)
|
|
169
|
+
# ------------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
def _flatten_index(index: dict, file_path: str) -> list[Segment]:
|
|
172
|
+
"""Flatten a unified index tree back to a list of Segment objects."""
|
|
173
|
+
segments: list[Segment] = []
|
|
174
|
+
|
|
175
|
+
def walk(nodes: list, section_id: str | None, index: int) -> int:
|
|
176
|
+
for node in nodes:
|
|
177
|
+
node_id = node["id"]
|
|
178
|
+
level = node.get("level", 0)
|
|
179
|
+
seg = Segment(
|
|
180
|
+
id=node_id,
|
|
181
|
+
file_path=file_path,
|
|
182
|
+
content=node["content"],
|
|
183
|
+
location_type=node.get("location_type", "block"),
|
|
184
|
+
location_label=node.get("title") or node["content"].splitlines()[0].lstrip("#").strip()[:80],
|
|
185
|
+
location_index=index,
|
|
186
|
+
char_count=len(node["content"]),
|
|
187
|
+
bm25_doc_id=node.get("bm25_doc_id"),
|
|
188
|
+
section_id=node_id if level > 0 else section_id,
|
|
189
|
+
level=level,
|
|
190
|
+
)
|
|
191
|
+
segments.append(seg)
|
|
192
|
+
index = walk(node.get("nodes", []), seg.section_id, index + 1)
|
|
193
|
+
return index
|
|
194
|
+
|
|
195
|
+
walk(index.get("nodes", []), None, 0)
|
|
196
|
+
return segments
|
doclex/cli.py
ADDED
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import os
|
|
5
|
+
import shutil
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.progress import BarColumn, MofNCompleteColumn, Progress, SpinnerColumn, TextColumn, TimeElapsedColumn
|
|
11
|
+
from rich.table import Table
|
|
12
|
+
|
|
13
|
+
from doclex.config import Config
|
|
14
|
+
from doclex.pipeline import Pipeline
|
|
15
|
+
from doclex.registry import (
|
|
16
|
+
Project,
|
|
17
|
+
ProjectAlreadyExistsError,
|
|
18
|
+
ProjectFolderNotFoundError,
|
|
19
|
+
ProjectNotFoundError,
|
|
20
|
+
ProjectOverlapError,
|
|
21
|
+
ProjectRegistry,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
app = typer.Typer(
|
|
25
|
+
name="doclex",
|
|
26
|
+
add_completion=False,
|
|
27
|
+
help="Local document search — BM25-based, no embeddings, no cloud.",
|
|
28
|
+
)
|
|
29
|
+
console = Console(width=220)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _resolve_project(name: str) -> Project:
|
|
33
|
+
try:
|
|
34
|
+
return ProjectRegistry().get(name)
|
|
35
|
+
except ProjectNotFoundError as exc:
|
|
36
|
+
console.print(
|
|
37
|
+
f"[red]Error:[/red] {exc}\n\n"
|
|
38
|
+
"Run [bold]doclex list[/bold] to see registered projects."
|
|
39
|
+
)
|
|
40
|
+
raise typer.Exit(1)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _build_pipeline(project: Project, *, create: bool = False) -> Pipeline:
|
|
44
|
+
os.environ["DOCLEX_FOLDER"] = str(project.folder)
|
|
45
|
+
config = Config()
|
|
46
|
+
if create:
|
|
47
|
+
config.ensure_dirs()
|
|
48
|
+
elif not config.cache_dir.exists():
|
|
49
|
+
console.print(
|
|
50
|
+
f"[yellow]No index found for project[/yellow] [bold]{project.name}[/bold] "
|
|
51
|
+
f"({project.folder}). Run [bold]doclex index --project {project.name}[/bold] first."
|
|
52
|
+
)
|
|
53
|
+
raise typer.Exit(0)
|
|
54
|
+
return Pipeline(config)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# ------------------------------------------------------------------
|
|
58
|
+
# add / remove / projects
|
|
59
|
+
# ------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
@app.command()
|
|
62
|
+
def add(
|
|
63
|
+
name: str = typer.Argument(..., help="Unique name for the project."),
|
|
64
|
+
folder: Path = typer.Argument(..., help="Folder to register."),
|
|
65
|
+
) -> None:
|
|
66
|
+
"""Register a folder under a project name.
|
|
67
|
+
|
|
68
|
+
\b
|
|
69
|
+
Examples:
|
|
70
|
+
doclex add docs ./docs
|
|
71
|
+
"""
|
|
72
|
+
try:
|
|
73
|
+
project = ProjectRegistry().add(name, folder)
|
|
74
|
+
except (ProjectAlreadyExistsError, ProjectFolderNotFoundError, ProjectOverlapError) as exc:
|
|
75
|
+
console.print(f"[red]Error:[/red] {exc}")
|
|
76
|
+
raise typer.Exit(1)
|
|
77
|
+
console.print(f"[green]✓[/green] Registered project '{project.name}' → {project.folder}")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@app.command("list")
|
|
81
|
+
def list_projects() -> None:
|
|
82
|
+
"""List registered projects."""
|
|
83
|
+
all_projects = ProjectRegistry().list()
|
|
84
|
+
if not all_projects:
|
|
85
|
+
console.print(
|
|
86
|
+
"[yellow]No projects registered.[/yellow] "
|
|
87
|
+
"Use [bold]doclex add <name> <folder>[/bold]."
|
|
88
|
+
)
|
|
89
|
+
return
|
|
90
|
+
|
|
91
|
+
table = Table(show_header=True, header_style="bold")
|
|
92
|
+
table.add_column("Name")
|
|
93
|
+
table.add_column("Folder")
|
|
94
|
+
for p in all_projects:
|
|
95
|
+
table.add_row(p.name, str(p.folder))
|
|
96
|
+
console.print(table)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@app.command()
|
|
100
|
+
def remove(
|
|
101
|
+
name: str = typer.Argument(..., help="Project name to remove."),
|
|
102
|
+
) -> None:
|
|
103
|
+
"""Remove a project and purge its cache/index data."""
|
|
104
|
+
try:
|
|
105
|
+
project = ProjectRegistry().remove(name)
|
|
106
|
+
except ProjectNotFoundError as exc:
|
|
107
|
+
console.print(f"[red]Error:[/red] {exc}")
|
|
108
|
+
raise typer.Exit(1)
|
|
109
|
+
|
|
110
|
+
cache_dir = project.folder / ".doclex"
|
|
111
|
+
shutil.rmtree(cache_dir, ignore_errors=True)
|
|
112
|
+
console.print(f"[green]✓[/green] Removed project '{name}' and its cache ({cache_dir}).")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# ------------------------------------------------------------------
|
|
116
|
+
# index
|
|
117
|
+
# ------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
@app.command()
|
|
120
|
+
def index(
|
|
121
|
+
project: str = typer.Option(
|
|
122
|
+
..., "--project", "-p",
|
|
123
|
+
help="Registered project name.",
|
|
124
|
+
),
|
|
125
|
+
files: list[Path] | None = typer.Option(
|
|
126
|
+
None, "--file",
|
|
127
|
+
help="One or more specific files to (re)index. Must be inside the project's folder.",
|
|
128
|
+
),
|
|
129
|
+
force: bool = typer.Option(
|
|
130
|
+
False, "--force",
|
|
131
|
+
help="Force re-extraction of every file, ignoring cached state.",
|
|
132
|
+
),
|
|
133
|
+
) -> None:
|
|
134
|
+
"""Index a project or re-index specific files.
|
|
135
|
+
|
|
136
|
+
\b
|
|
137
|
+
Examples:
|
|
138
|
+
doclex index --project docs # incremental scan
|
|
139
|
+
doclex index --project docs --force # reindex everything
|
|
140
|
+
doclex index --project docs --file report.pdf # reindex one file
|
|
141
|
+
doclex index --project docs --file a.pdf --file b.docx
|
|
142
|
+
"""
|
|
143
|
+
if files and force:
|
|
144
|
+
console.print("[red]Error:[/red] --file and --force are mutually exclusive.")
|
|
145
|
+
raise typer.Exit(1)
|
|
146
|
+
|
|
147
|
+
proj = _resolve_project(project)
|
|
148
|
+
|
|
149
|
+
if files:
|
|
150
|
+
for f in files:
|
|
151
|
+
if not f.exists():
|
|
152
|
+
console.print(f"[red]Error:[/red] File not found: {f}")
|
|
153
|
+
raise typer.Exit(1)
|
|
154
|
+
if not f.resolve().is_relative_to(proj.folder):
|
|
155
|
+
console.print(
|
|
156
|
+
f"[red]Error:[/red] {f} is not inside project '{proj.name}' ({proj.folder})"
|
|
157
|
+
)
|
|
158
|
+
raise typer.Exit(1)
|
|
159
|
+
|
|
160
|
+
pipeline = _build_pipeline(proj, create=True)
|
|
161
|
+
|
|
162
|
+
async def run() -> None:
|
|
163
|
+
await pipeline.startup()
|
|
164
|
+
|
|
165
|
+
# ── Specific files reindex ───────────────────────────────────
|
|
166
|
+
if files:
|
|
167
|
+
with Progress(
|
|
168
|
+
SpinnerColumn(), BarColumn(), MofNCompleteColumn(),
|
|
169
|
+
TimeElapsedColumn(), TextColumn("[progress.description]{task.description}"),
|
|
170
|
+
console=console,
|
|
171
|
+
) as progress:
|
|
172
|
+
task = progress.add_task("", total=len(files))
|
|
173
|
+
for f in files:
|
|
174
|
+
progress.update(task, advance=1, description=f"[cyan]reindexing[/cyan] {f.name}")
|
|
175
|
+
await pipeline.reindex_file(f)
|
|
176
|
+
console.print(f"[green]✓[/green] Reindexed {len(files)} file(s).")
|
|
177
|
+
await pipeline.shutdown()
|
|
178
|
+
return
|
|
179
|
+
|
|
180
|
+
# ── Force full reindex ───────────────────────────────────────
|
|
181
|
+
if force:
|
|
182
|
+
all_files = await pipeline.list_files()
|
|
183
|
+
total = len(all_files)
|
|
184
|
+
if total == 0:
|
|
185
|
+
console.print("[yellow]No files in index to reindex.[/yellow]")
|
|
186
|
+
await pipeline.shutdown()
|
|
187
|
+
return
|
|
188
|
+
|
|
189
|
+
console.print(f"[bold]Force reindexing[/bold] {total} files…")
|
|
190
|
+
with Progress(
|
|
191
|
+
SpinnerColumn(), BarColumn(), MofNCompleteColumn(),
|
|
192
|
+
TimeElapsedColumn(), TextColumn("[progress.description]{task.description}"),
|
|
193
|
+
console=console,
|
|
194
|
+
) as progress:
|
|
195
|
+
task = progress.add_task("", total=total)
|
|
196
|
+
|
|
197
|
+
def on_progress_force(path: Path, action: str) -> None:
|
|
198
|
+
progress.update(task, advance=1, description=f"[cyan]{action}[/cyan] {path.name}")
|
|
199
|
+
|
|
200
|
+
await pipeline.reindex_all(on_progress=on_progress_force)
|
|
201
|
+
console.print("[green]✓[/green] Done.")
|
|
202
|
+
await pipeline.shutdown()
|
|
203
|
+
return
|
|
204
|
+
|
|
205
|
+
# ── Incremental reconciliation ───────────────────────────────
|
|
206
|
+
with Progress(SpinnerColumn(), TextColumn("{task.description}"), console=console, transient=True) as spin:
|
|
207
|
+
spin.add_task("Scanning folder…")
|
|
208
|
+
changes = await pipeline.reconcile()
|
|
209
|
+
|
|
210
|
+
total = len(changes.new) + len(changes.modified) + len(changes.deleted)
|
|
211
|
+
|
|
212
|
+
if total == 0:
|
|
213
|
+
console.print("[green]✓[/green] Index is up to date. No changes detected.")
|
|
214
|
+
else:
|
|
215
|
+
console.print(
|
|
216
|
+
f"[bold]Processing:[/bold] {len(changes.new)} new, "
|
|
217
|
+
f"{len(changes.modified)} modified, "
|
|
218
|
+
f"{len(changes.deleted)} deleted"
|
|
219
|
+
)
|
|
220
|
+
with Progress(
|
|
221
|
+
SpinnerColumn(), BarColumn(), MofNCompleteColumn(),
|
|
222
|
+
TimeElapsedColumn(), TextColumn("[progress.description]{task.description}"),
|
|
223
|
+
console=console,
|
|
224
|
+
) as progress:
|
|
225
|
+
task = progress.add_task("", total=total)
|
|
226
|
+
|
|
227
|
+
def on_progress(path: Path, action: str) -> None:
|
|
228
|
+
progress.update(task, advance=1, description=f"[cyan]{action}[/cyan] {path.name}")
|
|
229
|
+
|
|
230
|
+
await pipeline.apply_changes(changes, on_progress=on_progress)
|
|
231
|
+
|
|
232
|
+
console.print("[green]✓[/green] Done.")
|
|
233
|
+
|
|
234
|
+
await pipeline.shutdown()
|
|
235
|
+
|
|
236
|
+
asyncio.run(run())
|
|
237
|
+
|
|
238
|
+
# ONNX Runtime (layout detection) crashes during Python interpreter shutdown
|
|
239
|
+
# when the GC tries to free native session objects. os._exit bypasses GC
|
|
240
|
+
# teardown; all work is already saved to disk.
|
|
241
|
+
os._exit(0)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
# ------------------------------------------------------------------
|
|
245
|
+
# search
|
|
246
|
+
# ------------------------------------------------------------------
|
|
247
|
+
|
|
248
|
+
@app.command()
|
|
249
|
+
def search(
|
|
250
|
+
query: str = typer.Argument(..., help="Search query (natural language or keywords)."),
|
|
251
|
+
project: str = typer.Option(
|
|
252
|
+
..., "--project", "-p",
|
|
253
|
+
help="Registered project name.",
|
|
254
|
+
),
|
|
255
|
+
top_k: int = typer.Option(10, "--top", "-k", help="Maximum number of results to return."),
|
|
256
|
+
full: bool = typer.Option(
|
|
257
|
+
False, "--full", "-F",
|
|
258
|
+
help="Show the full segment text instead of a short snippet.",
|
|
259
|
+
),
|
|
260
|
+
) -> None:
|
|
261
|
+
"""Search indexed documents and show results with citations.
|
|
262
|
+
|
|
263
|
+
\b
|
|
264
|
+
Examples:
|
|
265
|
+
doclex search "GT/DPM" --project docs
|
|
266
|
+
doclex search "presupuesto 2024" --project docs --top 5 --full
|
|
267
|
+
"""
|
|
268
|
+
proj = _resolve_project(project)
|
|
269
|
+
pipeline = _build_pipeline(proj)
|
|
270
|
+
|
|
271
|
+
async def run() -> None:
|
|
272
|
+
with Progress(SpinnerColumn(), TextColumn("{task.description}"), console=console, transient=True) as spin:
|
|
273
|
+
spin.add_task("Searching…")
|
|
274
|
+
await pipeline.startup()
|
|
275
|
+
results = await pipeline.search(query, top_k)
|
|
276
|
+
await pipeline.shutdown()
|
|
277
|
+
|
|
278
|
+
if not results:
|
|
279
|
+
console.print("[yellow]No results found.[/yellow]")
|
|
280
|
+
return
|
|
281
|
+
|
|
282
|
+
console.print()
|
|
283
|
+
for i, r in enumerate(results, 1):
|
|
284
|
+
file_name = Path(r.file_path).name
|
|
285
|
+
console.print(
|
|
286
|
+
f"[bold cyan][{i}][/bold cyan] [bold]{file_name}[/bold] — "
|
|
287
|
+
f"[dim]{r.location_label}[/dim] "
|
|
288
|
+
f"[yellow](score: {r.score:.3f})[/yellow]"
|
|
289
|
+
)
|
|
290
|
+
text = r.content if full else r.snippet
|
|
291
|
+
console.print(f' [italic]"{text}"[/italic]')
|
|
292
|
+
console.print()
|
|
293
|
+
|
|
294
|
+
asyncio.run(run())
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
# ------------------------------------------------------------------
|
|
298
|
+
# status
|
|
299
|
+
# ------------------------------------------------------------------
|
|
300
|
+
|
|
301
|
+
@app.command()
|
|
302
|
+
def status(
|
|
303
|
+
project: str = typer.Option(
|
|
304
|
+
..., "--project", "-p",
|
|
305
|
+
help="Registered project name.",
|
|
306
|
+
),
|
|
307
|
+
files: list[Path] | None = typer.Option(
|
|
308
|
+
None, "--file",
|
|
309
|
+
help="Inspect one or more specific files.",
|
|
310
|
+
),
|
|
311
|
+
all_files: bool = typer.Option(
|
|
312
|
+
False, "--all",
|
|
313
|
+
help="List all indexed files with their status.",
|
|
314
|
+
),
|
|
315
|
+
filter_status: str | None = typer.Option(
|
|
316
|
+
None, "--filter",
|
|
317
|
+
help="Filter file list by status: indexed | error | pending.",
|
|
318
|
+
),
|
|
319
|
+
) -> None:
|
|
320
|
+
"""Show corpus status, optionally listing files or inspecting specific files.
|
|
321
|
+
|
|
322
|
+
\b
|
|
323
|
+
Examples:
|
|
324
|
+
doclex status --project docs # corpus summary
|
|
325
|
+
doclex status --project docs --all # list all files
|
|
326
|
+
doclex status --project docs --all --filter error # filtered list
|
|
327
|
+
doclex status --project docs --file report.pdf --file r3.pdf # inspect specific files
|
|
328
|
+
"""
|
|
329
|
+
proj = _resolve_project(project)
|
|
330
|
+
pipeline = _build_pipeline(proj)
|
|
331
|
+
|
|
332
|
+
async def run() -> None:
|
|
333
|
+
await pipeline.startup()
|
|
334
|
+
|
|
335
|
+
# ── Specific-file inspect ────────────────────────────────────
|
|
336
|
+
if files:
|
|
337
|
+
for f in files:
|
|
338
|
+
info = await pipeline.get_file_status(f)
|
|
339
|
+
rec = info["record"]
|
|
340
|
+
console.print()
|
|
341
|
+
|
|
342
|
+
t = Table(show_header=False, box=None, padding=(0, 2))
|
|
343
|
+
t.add_column(style="bold dim")
|
|
344
|
+
t.add_column()
|
|
345
|
+
t.add_row("File", str(f))
|
|
346
|
+
t.add_row("On disk", "[green]yes[/green]" if info["on_disk"] else "[red]no[/red]")
|
|
347
|
+
t.add_row("In index", "[green]yes[/green]" if info["in_index"] else "[yellow]no[/yellow]")
|
|
348
|
+
|
|
349
|
+
if rec:
|
|
350
|
+
modified_label = "[yellow]yes — run index to update[/yellow]" if info["is_modified"] else "[green]no[/green]"
|
|
351
|
+
t.add_row("Modified on disk", modified_label)
|
|
352
|
+
t.add_row("Status", rec.status)
|
|
353
|
+
t.add_row("MIME type", rec.mime_type)
|
|
354
|
+
t.add_row("Segments", str(len(info["segments"])))
|
|
355
|
+
if rec.error_msg:
|
|
356
|
+
t.add_row("Error", f"[red]{rec.error_msg}[/red]")
|
|
357
|
+
|
|
358
|
+
console.print(t)
|
|
359
|
+
|
|
360
|
+
await pipeline.shutdown()
|
|
361
|
+
return
|
|
362
|
+
|
|
363
|
+
# ── File list ────────────────────────────────────────────────
|
|
364
|
+
if all_files:
|
|
365
|
+
file_list = await pipeline.list_files(filter_status)
|
|
366
|
+
await pipeline.shutdown()
|
|
367
|
+
|
|
368
|
+
if not file_list:
|
|
369
|
+
console.print("[yellow]No files found.[/yellow]")
|
|
370
|
+
return
|
|
371
|
+
|
|
372
|
+
table = Table(show_header=True, header_style="bold")
|
|
373
|
+
table.add_column("Status", width=10)
|
|
374
|
+
table.add_column("File")
|
|
375
|
+
table.add_column("MIME type", style="dim")
|
|
376
|
+
|
|
377
|
+
style_map = {"indexed": "green", "error": "red", "pending": "yellow"}
|
|
378
|
+
icon_map = {"indexed": "✓", "error": "✗", "pending": "…"}
|
|
379
|
+
|
|
380
|
+
for f in file_list:
|
|
381
|
+
color = style_map.get(f.status, "white")
|
|
382
|
+
icon = icon_map.get(f.status, "?")
|
|
383
|
+
table.add_row(
|
|
384
|
+
f"[{color}]{icon} {f.status}[/{color}]",
|
|
385
|
+
f.path,
|
|
386
|
+
f.mime_type,
|
|
387
|
+
)
|
|
388
|
+
console.print(table)
|
|
389
|
+
return
|
|
390
|
+
|
|
391
|
+
# ── Corpus summary ───────────────────────────────────────────
|
|
392
|
+
info = await pipeline.corpus_status()
|
|
393
|
+
await pipeline.shutdown()
|
|
394
|
+
|
|
395
|
+
t = Table(show_header=False, box=None, padding=(0, 2))
|
|
396
|
+
t.add_column(style="bold dim")
|
|
397
|
+
t.add_column()
|
|
398
|
+
|
|
399
|
+
t.add_row("Folder", str(info["folder"]))
|
|
400
|
+
t.add_row(
|
|
401
|
+
"Indexed",
|
|
402
|
+
f"[green]{info['files_indexed']} files[/green]"
|
|
403
|
+
+ (f" [red]{info['files_error']} errors[/red]" if info["files_error"] else "")
|
|
404
|
+
+ (f" [yellow]{info['files_pending']} pending[/yellow]" if info["files_pending"] else ""),
|
|
405
|
+
)
|
|
406
|
+
t.add_row("Segments", str(info["segments_total"]))
|
|
407
|
+
t.add_row("BM25 index", str(info["index_size"]) + " docs")
|
|
408
|
+
|
|
409
|
+
if not info["has_index"]:
|
|
410
|
+
t.add_row("Disk state", "[yellow]not indexed — run doclex index --project <name>[/yellow]")
|
|
411
|
+
else:
|
|
412
|
+
pending_total = info["pending_new"] + info["pending_modified"] + info["pending_deleted"]
|
|
413
|
+
if pending_total:
|
|
414
|
+
t.add_row(
|
|
415
|
+
"Needs reindex",
|
|
416
|
+
f"[yellow]{info['pending_new']} new "
|
|
417
|
+
f"{info['pending_modified']} modified "
|
|
418
|
+
f"{info['pending_deleted']} deleted[/yellow]"
|
|
419
|
+
" → run [bold]doclex index[/bold]",
|
|
420
|
+
)
|
|
421
|
+
else:
|
|
422
|
+
t.add_row("Disk state", "[green]up to date[/green]")
|
|
423
|
+
|
|
424
|
+
console.print(t)
|
|
425
|
+
|
|
426
|
+
asyncio.run(run())
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
if __name__ == "__main__":
|
|
430
|
+
app()
|
doclex/config.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Config:
|
|
8
|
+
def __init__(self) -> None:
|
|
9
|
+
folder_env = os.environ.get("DOCLEX_FOLDER")
|
|
10
|
+
if not folder_env:
|
|
11
|
+
raise ValueError("DOCLEX_FOLDER environment variable is required")
|
|
12
|
+
|
|
13
|
+
self.folder = Path(folder_env).expanduser().resolve()
|
|
14
|
+
if not self.folder.is_dir():
|
|
15
|
+
raise ValueError(f"DOCLEX_FOLDER is not a directory: {self.folder}")
|
|
16
|
+
|
|
17
|
+
cache_env = os.environ.get("DOCLEX_CACHE_DIR")
|
|
18
|
+
self.cache_dir = (
|
|
19
|
+
Path(cache_env).expanduser().resolve()
|
|
20
|
+
if cache_env
|
|
21
|
+
else self.folder / ".doclex"
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
db_env = os.environ.get("DOCLEX_DB_PATH")
|
|
25
|
+
self.db_path = (
|
|
26
|
+
Path(db_env).expanduser().resolve()
|
|
27
|
+
if db_env
|
|
28
|
+
else self.cache_dir / "doclex.db"
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
self.bm25_index_dir = self.cache_dir / "bm25_index"
|
|
32
|
+
|
|
33
|
+
self.top_k = int(os.environ.get("DOCLEX_TOP_K", "10"))
|
|
34
|
+
self.debounce_secs = float(os.environ.get("DOCLEX_DEBOUNCE", "1.0"))
|
|
35
|
+
|
|
36
|
+
def ensure_dirs(self) -> None:
|
|
37
|
+
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
|
38
|
+
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
SUPPORTED_EXTENSIONS: frozenset[str] = frozenset(
|
|
42
|
+
{".pdf", ".txt", ".md", ".docx", ".xlsx", ".csv"}
|
|
43
|
+
)
|
|
File without changes
|