docuhand 0.1.0.dev1__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.
- docuhand/__init__.py +3 -0
- docuhand/__main__.py +4 -0
- docuhand/_compat.py +28 -0
- docuhand/engine/__init__.py +38 -0
- docuhand/engine/com_thread.py +88 -0
- docuhand/engine/com_utils.py +56 -0
- docuhand/engine/container_guard.py +166 -0
- docuhand/engine/convert_plan.py +43 -0
- docuhand/engine/edit_plan.py +71 -0
- docuhand/engine/extraction.py +175 -0
- docuhand/engine/live_edit.py +202 -0
- docuhand/engine/merge_plan.py +58 -0
- docuhand/engine/office_app.py +828 -0
- docuhand/engine/pdf_plan.py +49 -0
- docuhand/engine/template_plan.py +107 -0
- docuhand/engine/templating.py +316 -0
- docuhand/engine/wd_constants.py +23 -0
- docuhand/errors.py +202 -0
- docuhand/safety/__init__.py +10 -0
- docuhand/safety/allowlist.py +41 -0
- docuhand/safety/audit.py +52 -0
- docuhand/safety/policy.py +29 -0
- docuhand/server.py +221 -0
- docuhand/tools/__init__.py +7 -0
- docuhand/tools/convert.py +228 -0
- docuhand/tools/edit_open.py +92 -0
- docuhand/tools/export_pdf.py +92 -0
- docuhand/tools/extract.py +63 -0
- docuhand/tools/fill_template.py +115 -0
- docuhand/tools/inspect.py +57 -0
- docuhand/tools/merge.py +143 -0
- docuhand-0.1.0.dev1.dist-info/METADATA +14 -0
- docuhand-0.1.0.dev1.dist-info/RECORD +36 -0
- docuhand-0.1.0.dev1.dist-info/WHEEL +4 -0
- docuhand-0.1.0.dev1.dist-info/entry_points.txt +2 -0
- docuhand-0.1.0.dev1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Safety layer: allowlist enforcement + JSONL audit log.
|
|
2
|
+
|
|
3
|
+
Per 开工包 decision #4 this is structural (registered around every tool),
|
|
4
|
+
not a "remember to call it" convention.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .allowlist import check_allowed, get_allowlist
|
|
8
|
+
from .audit import record
|
|
9
|
+
|
|
10
|
+
__all__ = ["check_allowed", "get_allowlist", "record"]
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Path allowlist — env-driven, enforced before any tool runs.
|
|
2
|
+
|
|
3
|
+
``DOCUHAND_ALLOWLIST``: os.pathsep-separated absolute directories.
|
|
4
|
+
Empty/absent (the v0.1 default) = allow everything: the user launches the
|
|
5
|
+
server themselves, which per spec IS the explicit consent ("默认:用户显式
|
|
6
|
+
传入的目录"). Setting the env var locks the server to those subtrees.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from ..errors import PathNotAllowedError
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def get_allowlist() -> list[Path]:
|
|
18
|
+
raw = os.environ.get("DOCUHAND_ALLOWLIST", "")
|
|
19
|
+
entries: list[Path] = []
|
|
20
|
+
for part in raw.split(os.pathsep):
|
|
21
|
+
if part.strip():
|
|
22
|
+
entries.append(Path(part.strip()).expanduser().resolve())
|
|
23
|
+
return entries
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def check_allowed(path: str) -> None:
|
|
27
|
+
entries = get_allowlist()
|
|
28
|
+
if not entries:
|
|
29
|
+
return
|
|
30
|
+
p = Path(path).expanduser()
|
|
31
|
+
try:
|
|
32
|
+
resolved = p.resolve()
|
|
33
|
+
except OSError:
|
|
34
|
+
resolved = p.absolute()
|
|
35
|
+
for root in entries:
|
|
36
|
+
try:
|
|
37
|
+
resolved.relative_to(root)
|
|
38
|
+
return
|
|
39
|
+
except ValueError:
|
|
40
|
+
continue
|
|
41
|
+
raise PathNotAllowedError(str(p), [str(e) for e in entries])
|
docuhand/safety/audit.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""JSONL audit log — every operation lands here, user can disable it.
|
|
2
|
+
|
|
3
|
+
Set ``DOCUHAND_AUDIT=off`` to disable, ``DOCUHAND_AUDIT_PATH`` to relocate
|
|
4
|
+
(default: %LOCALAPPDATA%/docuhand/audit.jsonl). Audit failures are swallowed:
|
|
5
|
+
logging must never break a document operation.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import time
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
_ENABLED = os.environ.get("DOCUHAND_AUDIT", "on").strip().lower() not in ("off", "0", "false", "no")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _log_path() -> Path:
|
|
19
|
+
env = os.environ.get("DOCUHAND_AUDIT_PATH")
|
|
20
|
+
if env:
|
|
21
|
+
return Path(env)
|
|
22
|
+
base = os.environ.get("LOCALAPPDATA") or str(Path.home())
|
|
23
|
+
return Path(base) / "docuhand" / "audit.jsonl"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def record(
|
|
27
|
+
tool: str,
|
|
28
|
+
path: str,
|
|
29
|
+
*,
|
|
30
|
+
ok: bool,
|
|
31
|
+
duration_ms: float,
|
|
32
|
+
engine: str | None = None,
|
|
33
|
+
error_code: str | None = None,
|
|
34
|
+
) -> None:
|
|
35
|
+
if not _ENABLED:
|
|
36
|
+
return
|
|
37
|
+
entry = {
|
|
38
|
+
"ts": time.strftime("%Y-%m-%dT%H:%M:%S"),
|
|
39
|
+
"tool": tool,
|
|
40
|
+
"path": path,
|
|
41
|
+
"ok": ok,
|
|
42
|
+
"duration_ms": round(duration_ms),
|
|
43
|
+
"engine": engine,
|
|
44
|
+
"error_code": error_code,
|
|
45
|
+
}
|
|
46
|
+
try:
|
|
47
|
+
target = _log_path()
|
|
48
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
49
|
+
with target.open("a", encoding="utf-8") as f:
|
|
50
|
+
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
|
|
51
|
+
except Exception:
|
|
52
|
+
pass
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Global write policy — the ``--read-only`` mode as data, not discipline.
|
|
2
|
+
|
|
3
|
+
Set ``DOCUHAND_READONLY=1`` to lock the server into read-only operation.
|
|
4
|
+
Blocked: document-mutating tools (edit_open_document, fill_template,
|
|
5
|
+
merge_documents, convert_documents). Allowed: pure readers (inspect_document,
|
|
6
|
+
extract_content) and new-artifact exporters (export_pdf never touches the
|
|
7
|
+
source document).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from ..errors import ReadOnlyError
|
|
13
|
+
|
|
14
|
+
_TRUTHY = {"1", "true", "yes", "on"}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def is_readonly() -> bool:
|
|
18
|
+
raw = ""
|
|
19
|
+
try:
|
|
20
|
+
raw = __import__("os").environ.get("DOCUHAND_READONLY", "")
|
|
21
|
+
except Exception:
|
|
22
|
+
return False
|
|
23
|
+
return raw.strip().lower() in _TRUTHY
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def ensure_writable(tool: str) -> None:
|
|
27
|
+
"""Raise ReadOnlyError when the server runs in read-only mode."""
|
|
28
|
+
if is_readonly():
|
|
29
|
+
raise ReadOnlyError(tool)
|
docuhand/server.py
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""MCP stdio entry point — deliberately a thin layer.
|
|
2
|
+
|
|
3
|
+
Protocol concerns live here; all real logic lives in engine/ and tools/.
|
|
4
|
+
If MCP is ever replaced (see 开工包 附录 decision #6), only this file and
|
|
5
|
+
the tool registration move — the engine carries zero MCP dependencies.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
|
|
12
|
+
from mcp.server.fastmcp import FastMCP
|
|
13
|
+
|
|
14
|
+
from ._compat import fix_stdio
|
|
15
|
+
from .tools.convert import run as convert_documents_run
|
|
16
|
+
from .tools.edit_open import run as edit_open_document_run
|
|
17
|
+
from .tools.export_pdf import run as export_pdf_run
|
|
18
|
+
from .tools.extract import run as extract_content_run
|
|
19
|
+
from .tools.fill_template import run as fill_template_run
|
|
20
|
+
from .tools.inspect import run as inspect_document_run
|
|
21
|
+
from .tools.merge import run as merge_documents_run
|
|
22
|
+
|
|
23
|
+
fix_stdio()
|
|
24
|
+
|
|
25
|
+
mcp = FastMCP(
|
|
26
|
+
"docuhand",
|
|
27
|
+
instructions=(
|
|
28
|
+
"DocuHand drives REAL Microsoft Word / WPS Office via COM on Windows. "
|
|
29
|
+
"Use inspect_document as the pre-flight health check for any Word "
|
|
30
|
+
"document: it reports the true format (legacy .doc vs .docx vs a "
|
|
31
|
+
"renamed file), lock state (open in Word right now?), page/word/paragraph "
|
|
32
|
+
"counts, corruption suspicion, password protection, editor restriction "
|
|
33
|
+
"mode, bookmarks and field codes. Use convert_documents to batch-convert "
|
|
34
|
+
"a whole directory of legacy .doc files to modern .docx (or the reverse) "
|
|
35
|
+
"in one call — Microsoft Word is used when present and WPS Office is the "
|
|
36
|
+
"automatic fallback. Use fill_template to produce a filled copy of a "
|
|
37
|
+
"template via bookmarks, {{placeholders}} or content controls with CJK "
|
|
38
|
+
"font protection; export_pdf for print-fidelity PDFs (optionally a page "
|
|
39
|
+
"range); extract_content to pull text, tables (as JSON grids) or the "
|
|
40
|
+
"heading outline out of legacy .doc files; merge_documents for "
|
|
41
|
+
"mail-merge-style batch generation from data rows; and "
|
|
42
|
+
"edit_open_document to edit a document the user has open RIGHT NOW "
|
|
43
|
+
"(changes stay visible in their window; it never saves unless asked). "
|
|
44
|
+
"Results are structured JSON; every error carries an llm_hint "
|
|
45
|
+
"describing the next corrective step."
|
|
46
|
+
),
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@mcp.tool()
|
|
51
|
+
def convert_documents(
|
|
52
|
+
directory: str,
|
|
53
|
+
to_format: str = "docx",
|
|
54
|
+
recursive: bool = False,
|
|
55
|
+
overwrite: str = "skip",
|
|
56
|
+
output_dir: str | None = None,
|
|
57
|
+
) -> dict:
|
|
58
|
+
"""Batch-convert every .doc/.docx file in a directory in ONE call.
|
|
59
|
+
|
|
60
|
+
Drives the real Word/WPS engine per file (Word first, WPS fallback when
|
|
61
|
+
Word is absent or crashes). Files already in the target format are
|
|
62
|
+
reported as already_target and left untouched; other extensions are
|
|
63
|
+
ignored. Sources are NEVER deleted. One unreadable file fails alone
|
|
64
|
+
with a structured error instead of killing the batch; only a total
|
|
65
|
+
engine-launch failure aborts the run.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
directory: Absolute Windows path to the folder holding the documents.
|
|
69
|
+
to_format: Target format — "docx" (recommended) or "doc".
|
|
70
|
+
recursive: Also descend into subdirectories.
|
|
71
|
+
overwrite: "skip" (default) never touches an existing target file;
|
|
72
|
+
"overwrite" replaces existing targets deliberately.
|
|
73
|
+
output_dir: Optional target folder. Omit to convert in place (the
|
|
74
|
+
converted file lands next to its source).
|
|
75
|
+
"""
|
|
76
|
+
return convert_documents_run(
|
|
77
|
+
directory,
|
|
78
|
+
to_format=to_format,
|
|
79
|
+
recursive=recursive,
|
|
80
|
+
overwrite=overwrite,
|
|
81
|
+
output_dir=output_dir,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@mcp.tool()
|
|
86
|
+
def inspect_document(path: str) -> dict:
|
|
87
|
+
"""Health-check a Word document before touching it.
|
|
88
|
+
|
|
89
|
+
Opens the document READ-ONLY in a hidden Word/WPS instance and reports:
|
|
90
|
+
true format vs extension (catches renamed or pseudo-.doc files), page /
|
|
91
|
+
word / paragraph counts, lock state (file currently open elsewhere),
|
|
92
|
+
corruption suspicion, password protection, protection mode, bookmarks
|
|
93
|
+
and field codes. Works on legacy .doc binaries that XML libraries cannot
|
|
94
|
+
read, and on files currently open in Word/WPS.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
path: Absolute Windows path to the document (e.g. D:\\\\Reports\\\\a.doc).
|
|
98
|
+
"""
|
|
99
|
+
return inspect_document_run(path)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@mcp.tool()
|
|
103
|
+
def fill_template(
|
|
104
|
+
template_path: str,
|
|
105
|
+
data: dict,
|
|
106
|
+
output_path: str | None = None,
|
|
107
|
+
mode: str = "auto",
|
|
108
|
+
bookmarks: list[str] | None = None,
|
|
109
|
+
) -> dict:
|
|
110
|
+
"""Fill a Word template with data and save a NEW document.
|
|
111
|
+
|
|
112
|
+
Produces template_filled.<ext> next to the template by default (the
|
|
113
|
+
template itself is never modified). Fill mechanisms, tried in 'auto':
|
|
114
|
+
content controls (Tag/Title = key), bookmarks (name = key), and literal
|
|
115
|
+
{{key}} placeholder tokens. Layout and East-Asian fonts are protected
|
|
116
|
+
through the fill (NameFarEast re-assertion before AND after save).
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
template_path: Absolute Windows path to the .doc/.docx template.
|
|
120
|
+
data: JSON object mapping keys to replacement text, e.g.
|
|
121
|
+
{"name": "张三", "amount": "1234.56"} fills {{name}}/{{amount}},
|
|
122
|
+
a bookmark named 'name', or a content control tagged 'name'.
|
|
123
|
+
output_path: Where to write the filled document (.doc/.docx).
|
|
124
|
+
mode: "auto" (default), "placeholders", "bookmarks" or "content_controls".
|
|
125
|
+
bookmarks: Optional explicit bookmark name list (mode auto/bookmarks).
|
|
126
|
+
"""
|
|
127
|
+
return fill_template_run(template_path, data, output_path, mode, bookmarks)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@mcp.tool()
|
|
131
|
+
def export_pdf(path: str, output_path: str | None = None, page_range: str | None = None) -> dict:
|
|
132
|
+
"""Export a print-fidelity PDF using the real Word/WPS layout engine.
|
|
133
|
+
|
|
134
|
+
The PDF matches what would come out of the printer — not a re-layout by
|
|
135
|
+
a converter library. Works on legacy .doc files and preserves CJK
|
|
136
|
+
pagination. The source document is opened read-only and never modified.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
path: Absolute Windows path to the document.
|
|
140
|
+
output_path: Target .pdf path; defaults to the source name with .pdf.
|
|
141
|
+
page_range: "all" (default), a single page "7", or a contiguous
|
|
142
|
+
range "3-9" (1-based). Split comma lists into separate calls.
|
|
143
|
+
"""
|
|
144
|
+
return export_pdf_run(path, output_path, page_range)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@mcp.tool()
|
|
148
|
+
def extract_content(path: str, what: str = "text") -> dict:
|
|
149
|
+
"""Extract text, tables or the heading outline from a Word document.
|
|
150
|
+
|
|
151
|
+
Reads legacy .doc binaries that XML libraries cannot open, in a hidden
|
|
152
|
+
READ-ONLY Word/WPS instance.
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
path: Absolute Windows path to the document.
|
|
156
|
+
what: "text" — full plain text; "tables" — every table as a
|
|
157
|
+
row-major JSON grid (merged cells repeat their origin text so
|
|
158
|
+
the grid stays rectangular); "outline" — headings as
|
|
159
|
+
[{level, text}] via locale-independent outline levels.
|
|
160
|
+
"""
|
|
161
|
+
return extract_content_run(path, what)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@mcp.tool()
|
|
165
|
+
def edit_open_document(path_or_title: str, operations: list, save: str = "ask") -> dict:
|
|
166
|
+
"""Edit a document the user has OPEN in Word/WPS right now.
|
|
167
|
+
|
|
168
|
+
Attaches to the user's running instance (not a hidden copy), finds the
|
|
169
|
+
document by absolute path or window title, and applies small operations
|
|
170
|
+
in order. The user SEES the changes happen in their window — this is
|
|
171
|
+
the only Office MCP capability that works on live, locked files.
|
|
172
|
+
|
|
173
|
+
Args:
|
|
174
|
+
path_or_title: Absolute path, or the document's window title / file name.
|
|
175
|
+
operations: Ordered list, each one of:
|
|
176
|
+
{"op": "replace_all", "find": "2023", "replace": "2024"}
|
|
177
|
+
{"op": "insert_text", "text": "Appendix", "where": "end"|"start"}
|
|
178
|
+
{"op": "save"}
|
|
179
|
+
save: "ask" (default) — do NOT save, report the dirty state and let
|
|
180
|
+
the user decide; "save" — write changes to disk when done.
|
|
181
|
+
"""
|
|
182
|
+
return edit_open_document_run(path_or_title, operations, save)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@mcp.tool()
|
|
186
|
+
def merge_documents(
|
|
187
|
+
template_path: str,
|
|
188
|
+
data_rows: list,
|
|
189
|
+
output_dir: str | None = None,
|
|
190
|
+
name_pattern: str = "{i}_{name}",
|
|
191
|
+
) -> dict:
|
|
192
|
+
"""Mail-merge style batch generation: one template × N data rows.
|
|
193
|
+
|
|
194
|
+
Generates one filled document per row into output_dir (default:
|
|
195
|
+
merged_<template-stem>/ next to the template). Rows fail in isolation —
|
|
196
|
+
one bad row never stops the batch. Name outputs with {i} (1-based row,
|
|
197
|
+
zero-padded) and any {field} tokens; names are sanitized.
|
|
198
|
+
|
|
199
|
+
Args:
|
|
200
|
+
template_path: Absolute Windows path to the .doc/.docx template with
|
|
201
|
+
{{field}} placeholders and/or bookmarks.
|
|
202
|
+
data_rows: Array of row objects, e.g. [{"name": "张三", "dept": "财务"},
|
|
203
|
+
{"name": "李四", "dept": "法务"}] → one document each.
|
|
204
|
+
output_dir: Target folder; created if missing.
|
|
205
|
+
name_pattern: Output file name pattern (default "{i}_{name}").
|
|
206
|
+
"""
|
|
207
|
+
return merge_documents_run(template_path, data_rows, output_dir, name_pattern)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def main() -> None:
|
|
211
|
+
parser = argparse.ArgumentParser(prog="docuhand")
|
|
212
|
+
sub = parser.add_subparsers(dest="command")
|
|
213
|
+
sub.add_parser("serve", help="run the MCP stdio server (default)")
|
|
214
|
+
args = parser.parse_args()
|
|
215
|
+
if args.command not in (None, "serve"):
|
|
216
|
+
parser.error(f"unknown command: {args.command}")
|
|
217
|
+
mcp.run(transport="stdio")
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
if __name__ == "__main__":
|
|
221
|
+
main()
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""MCP-facing tools: pydantic-checked args + LLM-facing behaviour.
|
|
2
|
+
|
|
3
|
+
v0.1 scope: inspect_document only. Each tool module pairs the safety
|
|
4
|
+
wrappers (allowlist/audit) with the engine call and returns structured
|
|
5
|
+
dicts — errors included (architecture decision #3: errors are data the
|
|
6
|
+
agent reads to self-correct, not tracebacks it drowns in).
|
|
7
|
+
"""
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""convert_documents — batch .doc ↔ .docx through the real engine.
|
|
2
|
+
|
|
3
|
+
Tool-layer responsibilities (the engine stays MCP-free):
|
|
4
|
+
- validate + normalize arguments into structured errors
|
|
5
|
+
- plan the batch with pure helpers (engine/convert_plan.py)
|
|
6
|
+
- run each conversion on the COM STA worker (threading contract)
|
|
7
|
+
- enforce the overwrite policy BEFORE any engine call
|
|
8
|
+
- keep every source file (never delete) and report that explicitly
|
|
9
|
+
- wrap every failure into a per-file structured entry — one bad file in a
|
|
10
|
+
500-file batch must not kill the batch. Only a total engine launch
|
|
11
|
+
failure aborts the whole run (nothing else can succeed either).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import time
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from ..engine import convert_plan, get_engine, get_worker
|
|
21
|
+
from ..engine.office_app import engine_order_names, sniff_format
|
|
22
|
+
from ..errors import (
|
|
23
|
+
DocuhandError,
|
|
24
|
+
EngineUnavailableError,
|
|
25
|
+
FileNotFoundError,
|
|
26
|
+
InternalError,
|
|
27
|
+
InvalidParamError,
|
|
28
|
+
)
|
|
29
|
+
from ..safety import allowlist, audit
|
|
30
|
+
|
|
31
|
+
TOOL_NAME = "convert_documents"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def run(
|
|
35
|
+
directory: str,
|
|
36
|
+
to_format: str = "docx",
|
|
37
|
+
recursive: bool = False,
|
|
38
|
+
overwrite: str = "skip",
|
|
39
|
+
output_dir: str | None = None,
|
|
40
|
+
) -> dict[str, Any]:
|
|
41
|
+
t0 = time.perf_counter()
|
|
42
|
+
|
|
43
|
+
# -- argument validation -------------------------------------------------
|
|
44
|
+
try:
|
|
45
|
+
fmt = convert_plan.normalize_to_format(to_format)
|
|
46
|
+
except ValueError as exc:
|
|
47
|
+
return InvalidParamError(
|
|
48
|
+
str(exc),
|
|
49
|
+
llm_hint="Use to_format='docx' (recommended, modern format) or 'doc'.",
|
|
50
|
+
).to_dict()
|
|
51
|
+
|
|
52
|
+
if overwrite not in ("skip", "overwrite"):
|
|
53
|
+
return InvalidParamError(
|
|
54
|
+
f"overwrite must be 'skip' or 'overwrite', got {overwrite!r}",
|
|
55
|
+
llm_hint="Default 'skip' never destroys an existing file; "
|
|
56
|
+
"'overwrite' replaces existing target files deliberately.",
|
|
57
|
+
).to_dict()
|
|
58
|
+
|
|
59
|
+
root = Path(directory).expanduser()
|
|
60
|
+
if not root.exists():
|
|
61
|
+
return FileNotFoundError(str(root)).to_dict()
|
|
62
|
+
if not root.is_dir():
|
|
63
|
+
return InvalidParamError(
|
|
64
|
+
f"Not a directory: {root}",
|
|
65
|
+
llm_hint="Pass the directory that contains the documents. "
|
|
66
|
+
"For a single file, put it in a directory or use inspect_document first.",
|
|
67
|
+
).to_dict()
|
|
68
|
+
root = root.resolve() # canonical form in payloads; the engine needs absolutes
|
|
69
|
+
|
|
70
|
+
out_root: Path | None = None
|
|
71
|
+
if output_dir:
|
|
72
|
+
out_root = Path(output_dir).expanduser()
|
|
73
|
+
try:
|
|
74
|
+
out_root.mkdir(parents=True, exist_ok=True)
|
|
75
|
+
except OSError as exc:
|
|
76
|
+
return DocuhandError(
|
|
77
|
+
f"Cannot create output directory {out_root}: {exc}",
|
|
78
|
+
llm_hint="Check the path (drive letter, permissions). "
|
|
79
|
+
"Omit output_dir to convert in place, next to each source.",
|
|
80
|
+
details={"output_dir": str(out_root)},
|
|
81
|
+
).to_dict()
|
|
82
|
+
|
|
83
|
+
# -- safety gates ----------------------------------------------------------
|
|
84
|
+
try:
|
|
85
|
+
allowlist.check_allowed(str(root))
|
|
86
|
+
if out_root is not None:
|
|
87
|
+
allowlist.check_allowed(str(out_root))
|
|
88
|
+
except DocuhandError as exc:
|
|
89
|
+
audit.record(TOOL_NAME, str(root), ok=False, duration_ms=0, error_code=exc.error_code)
|
|
90
|
+
return exc.to_dict()
|
|
91
|
+
|
|
92
|
+
wd_format = convert_plan.TO_FORMATS[fmt]
|
|
93
|
+
|
|
94
|
+
# -- plan ------------------------------------------------------------------
|
|
95
|
+
pattern = root.rglob("*") if recursive else root.glob("*")
|
|
96
|
+
try:
|
|
97
|
+
candidates = sorted(p for p in pattern if p.is_file())
|
|
98
|
+
except OSError as exc:
|
|
99
|
+
return InternalError(
|
|
100
|
+
f"Cannot list directory: {exc}",
|
|
101
|
+
details={"directory": str(root)},
|
|
102
|
+
).to_dict()
|
|
103
|
+
|
|
104
|
+
results: list[dict[str, Any]] = []
|
|
105
|
+
counts = {
|
|
106
|
+
"to_convert": 0,
|
|
107
|
+
"already_target": 0,
|
|
108
|
+
"skipped_existing": 0,
|
|
109
|
+
"converted": 0,
|
|
110
|
+
"failed": 0,
|
|
111
|
+
}
|
|
112
|
+
ignored = 0
|
|
113
|
+
|
|
114
|
+
planned: list[tuple[Path, Path]] = []
|
|
115
|
+
for src in candidates:
|
|
116
|
+
kind = convert_plan.classify_source(src.suffix, fmt)
|
|
117
|
+
if kind == "unsupported_source":
|
|
118
|
+
ignored += 1
|
|
119
|
+
continue
|
|
120
|
+
if kind == "already_target":
|
|
121
|
+
counts["already_target"] += 1
|
|
122
|
+
results.append({"source": str(src), "target": None, "status": "already_target"})
|
|
123
|
+
continue
|
|
124
|
+
counts["to_convert"] += 1
|
|
125
|
+
planned.append((src, convert_plan.resolve_target(src, fmt, out_root)))
|
|
126
|
+
|
|
127
|
+
# -- execute -----------------------------------------------------------------
|
|
128
|
+
worker = get_worker()
|
|
129
|
+
engine = get_engine()
|
|
130
|
+
aborted: dict[str, Any] | None = None
|
|
131
|
+
|
|
132
|
+
for src, target in planned:
|
|
133
|
+
if target.exists() and overwrite == "skip":
|
|
134
|
+
counts["skipped_existing"] += 1
|
|
135
|
+
results.append(
|
|
136
|
+
{
|
|
137
|
+
"source": str(src),
|
|
138
|
+
"target": str(target),
|
|
139
|
+
"status": "skipped_existing",
|
|
140
|
+
"reason": "target exists (overwrite='skip')",
|
|
141
|
+
}
|
|
142
|
+
)
|
|
143
|
+
continue
|
|
144
|
+
|
|
145
|
+
try:
|
|
146
|
+
info = worker.submit(engine.convert_document, str(src), str(target), wd_format)
|
|
147
|
+
except EngineUnavailableError as exc:
|
|
148
|
+
counts["failed"] += 1
|
|
149
|
+
results.append(
|
|
150
|
+
{"source": str(src), "target": str(target), "status": "failed", "error": exc.to_dict()["error"]}
|
|
151
|
+
)
|
|
152
|
+
audit.record(TOOL_NAME, str(src), ok=False, duration_ms=0, error_code=exc.error_code)
|
|
153
|
+
aborted = exc.to_dict()
|
|
154
|
+
break
|
|
155
|
+
except DocuhandError as exc:
|
|
156
|
+
counts["failed"] += 1
|
|
157
|
+
results.append(
|
|
158
|
+
{"source": str(src), "target": str(target), "status": "failed", "error": exc.to_dict()["error"]}
|
|
159
|
+
)
|
|
160
|
+
audit.record(TOOL_NAME, str(src), ok=False, duration_ms=0, error_code=exc.error_code)
|
|
161
|
+
continue
|
|
162
|
+
except Exception as exc: # unknown engine crash — still structured
|
|
163
|
+
err = InternalError(
|
|
164
|
+
f"Unexpected error converting {src.name}: {type(exc).__name__}: {exc}",
|
|
165
|
+
llm_hint="Retry once. If it persists, report error_code and details to the DocuHand repository.",
|
|
166
|
+
details={"source": str(src)},
|
|
167
|
+
)
|
|
168
|
+
counts["failed"] += 1
|
|
169
|
+
results.append(
|
|
170
|
+
{"source": str(src), "target": str(target), "status": "failed", "error": err.to_dict()["error"]}
|
|
171
|
+
)
|
|
172
|
+
audit.record(TOOL_NAME, str(src), ok=False, duration_ms=0, error_code=err.error_code)
|
|
173
|
+
continue
|
|
174
|
+
|
|
175
|
+
# Verify the artifact really is the claimed container format.
|
|
176
|
+
try:
|
|
177
|
+
with open(target, "rb") as f:
|
|
178
|
+
verified = sniff_format(f.read(8))
|
|
179
|
+
except OSError:
|
|
180
|
+
verified = "unreadable"
|
|
181
|
+
|
|
182
|
+
counts["converted"] += 1
|
|
183
|
+
results.append(
|
|
184
|
+
{
|
|
185
|
+
"source": str(src),
|
|
186
|
+
"target": str(target),
|
|
187
|
+
"status": "converted",
|
|
188
|
+
"engine": info.get("engine"),
|
|
189
|
+
"target_verified_format": verified,
|
|
190
|
+
"target_size_bytes": target.stat().st_size if target.exists() else None,
|
|
191
|
+
"duration_ms": info.get("duration_ms"),
|
|
192
|
+
}
|
|
193
|
+
)
|
|
194
|
+
audit.record(
|
|
195
|
+
TOOL_NAME,
|
|
196
|
+
str(src),
|
|
197
|
+
ok=True,
|
|
198
|
+
duration_ms=info.get("duration_ms") or 0,
|
|
199
|
+
engine=info.get("engine"),
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
total_found = counts["already_target"] + counts["to_convert"]
|
|
203
|
+
payload: dict[str, Any] = {
|
|
204
|
+
"ok": aborted is None,
|
|
205
|
+
"tool": TOOL_NAME,
|
|
206
|
+
"directory": str(root),
|
|
207
|
+
"output_dir": str(out_root) if out_root else None,
|
|
208
|
+
"to_format": fmt,
|
|
209
|
+
"recursive": recursive,
|
|
210
|
+
"overwrite": overwrite,
|
|
211
|
+
"engine_order": engine_order_names(),
|
|
212
|
+
"sources_deleted": False,
|
|
213
|
+
"summary": {
|
|
214
|
+
**counts,
|
|
215
|
+
"doc_files_found": total_found,
|
|
216
|
+
"ignored_other_extensions": ignored,
|
|
217
|
+
"duration_ms": round((time.perf_counter() - t0) * 1000),
|
|
218
|
+
},
|
|
219
|
+
"results": results,
|
|
220
|
+
}
|
|
221
|
+
if aborted is not None:
|
|
222
|
+
payload["error"] = aborted["error"]
|
|
223
|
+
payload["llm_hint"] = (
|
|
224
|
+
"No Office engine could be launched, so the batch aborted before "
|
|
225
|
+
"touching the remaining files. Install/repair Microsoft Word or "
|
|
226
|
+
"WPS Office (or close a stuck first-run dialog), then retry."
|
|
227
|
+
)
|
|
228
|
+
return payload
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""edit_open_document — edit a document the user has open RIGHT NOW.
|
|
2
|
+
|
|
3
|
+
Attaches to the live Word/WPS instance on the STA worker, resolves the
|
|
4
|
+
document by path or window title, validates the whole operation batch up
|
|
5
|
+
front (edit_plan.py), applies it, and reports the saved/dirty state.
|
|
6
|
+
Default is NOT to save ('ask') — the agent tells the user what changed.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import time
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from ..engine import edit_plan, get_worker
|
|
15
|
+
from ..engine.live_edit import edit_open_document as engine_edit
|
|
16
|
+
from ..errors import DocuhandError, InvalidParamError, InternalError
|
|
17
|
+
from ..safety import allowlist, audit
|
|
18
|
+
from ..safety.policy import ensure_writable
|
|
19
|
+
|
|
20
|
+
TOOL_NAME = "edit_open_document"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def run(
|
|
24
|
+
path_or_title: str,
|
|
25
|
+
operations: list[dict[str, Any]],
|
|
26
|
+
save: str = "ask",
|
|
27
|
+
) -> dict[str, Any]:
|
|
28
|
+
t0 = time.perf_counter()
|
|
29
|
+
|
|
30
|
+
# -- validate the whole batch BEFORE any COM call --------------------------
|
|
31
|
+
try:
|
|
32
|
+
clean_ops = edit_plan.validate_operations(operations)
|
|
33
|
+
except ValueError as exc:
|
|
34
|
+
return InvalidParamError(str(exc), llm_hint="Fix the operations list and retry.").to_dict()
|
|
35
|
+
if save not in ("ask", "save"):
|
|
36
|
+
return InvalidParamError(
|
|
37
|
+
f"save must be 'ask' or 'save' (got {save!r})",
|
|
38
|
+
llm_hint="Default 'ask' never writes without the user knowing; 'save' persists the changes.",
|
|
39
|
+
).to_dict()
|
|
40
|
+
|
|
41
|
+
# -- safety gates ------------------------------------------------------------
|
|
42
|
+
# allowlist applies to the file path when resolvable; title matches are
|
|
43
|
+
# allowed only because the user opened that document themselves.
|
|
44
|
+
p = None
|
|
45
|
+
from pathlib import Path
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
p = Path(path_or_title).expanduser()
|
|
49
|
+
if p.exists():
|
|
50
|
+
allowlist.check_allowed(str(p.resolve()))
|
|
51
|
+
except DocuhandError as exc:
|
|
52
|
+
audit.record(TOOL_NAME, str(path_or_title), ok=False, duration_ms=0, error_code=exc.error_code)
|
|
53
|
+
return exc.to_dict()
|
|
54
|
+
except OSError:
|
|
55
|
+
pass # a window title, not a path — the user opened it themselves
|
|
56
|
+
|
|
57
|
+
try:
|
|
58
|
+
ensure_writable(TOOL_NAME)
|
|
59
|
+
except DocuhandError as exc:
|
|
60
|
+
audit.record(TOOL_NAME, str(path_or_title), ok=False, duration_ms=0, error_code=exc.error_code)
|
|
61
|
+
return exc.to_dict()
|
|
62
|
+
|
|
63
|
+
# -- execute on the STA worker -------------------------------------------------
|
|
64
|
+
worker = get_worker()
|
|
65
|
+
try:
|
|
66
|
+
result = worker.submit(engine_edit, path_or_title, clean_ops, save)
|
|
67
|
+
except DocuhandError as exc:
|
|
68
|
+
audit.record(
|
|
69
|
+
TOOL_NAME,
|
|
70
|
+
str(path_or_title),
|
|
71
|
+
ok=False,
|
|
72
|
+
duration_ms=(time.perf_counter() - t0) * 1000,
|
|
73
|
+
error_code=exc.error_code,
|
|
74
|
+
)
|
|
75
|
+
return exc.to_dict()
|
|
76
|
+
except Exception as exc:
|
|
77
|
+
err = InternalError(
|
|
78
|
+
f"Unexpected error editing open document: {type(exc).__name__}: {exc}",
|
|
79
|
+
llm_hint="Retry once. If it persists, report error_code and details to the DocuHand repository.",
|
|
80
|
+
details={"path_or_title": path_or_title},
|
|
81
|
+
)
|
|
82
|
+
audit.record(TOOL_NAME, str(path_or_title), ok=False, duration_ms=(time.perf_counter() - t0) * 1000, error_code=err.error_code)
|
|
83
|
+
return err.to_dict()
|
|
84
|
+
|
|
85
|
+
audit.record(
|
|
86
|
+
TOOL_NAME,
|
|
87
|
+
result.get("document", str(path_or_title)),
|
|
88
|
+
ok=True,
|
|
89
|
+
duration_ms=result.get("duration_ms") or 0,
|
|
90
|
+
engine=result.get("engine"),
|
|
91
|
+
)
|
|
92
|
+
return result
|