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,92 @@
|
|
|
1
|
+
"""export_pdf — print-fidelity PDF via the real Word/WPS layout engine.
|
|
2
|
+
|
|
3
|
+
Tool layer: parse the page range with pure helpers (engine/pdf_plan.py),
|
|
4
|
+
gate through allowlist + audit (export creates a NEW file but never
|
|
5
|
+
touches the source, so it stays available in read-only mode), then run
|
|
6
|
+
one engine export on the COM STA worker.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import time
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from ..engine import get_engine, get_worker
|
|
16
|
+
from ..engine.pdf_plan import parse_page_range
|
|
17
|
+
from ..errors import DocuhandError, FileNotFoundError, InternalError, InvalidParamError
|
|
18
|
+
from ..safety import allowlist, audit
|
|
19
|
+
|
|
20
|
+
TOOL_NAME = "export_pdf"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def run(
|
|
24
|
+
path: str,
|
|
25
|
+
output_path: str | None = None,
|
|
26
|
+
page_range: str | None = None,
|
|
27
|
+
) -> dict[str, Any]:
|
|
28
|
+
t0 = time.perf_counter()
|
|
29
|
+
|
|
30
|
+
# -- argument validation ---------------------------------------------------
|
|
31
|
+
src = Path(path).expanduser()
|
|
32
|
+
if not src.exists():
|
|
33
|
+
return FileNotFoundError(str(src)).to_dict()
|
|
34
|
+
|
|
35
|
+
try:
|
|
36
|
+
pages = parse_page_range(page_range)
|
|
37
|
+
except ValueError as exc:
|
|
38
|
+
return InvalidParamError(str(exc), llm_hint="Use page_range='all', '7' or '3-9', or omit it.").to_dict()
|
|
39
|
+
|
|
40
|
+
if output_path:
|
|
41
|
+
dst = Path(output_path).expanduser()
|
|
42
|
+
if dst.suffix.lower() != ".pdf":
|
|
43
|
+
return InvalidParamError(
|
|
44
|
+
f"output_path must end in .pdf (got {dst.suffix or 'none'})",
|
|
45
|
+
llm_hint="PDF export writes a .pdf file; pick a name like 'report.pdf'.",
|
|
46
|
+
).to_dict()
|
|
47
|
+
else:
|
|
48
|
+
dst = src.with_suffix(".pdf")
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
if dst.exists() and dst.samefile(src):
|
|
52
|
+
return InvalidParamError(
|
|
53
|
+
"output_path must differ from the source document",
|
|
54
|
+
llm_hint="Pick another name or omit output_path to write next to the source.",
|
|
55
|
+
).to_dict()
|
|
56
|
+
except OSError:
|
|
57
|
+
pass
|
|
58
|
+
|
|
59
|
+
# -- safety gates ------------------------------------------------------------
|
|
60
|
+
try:
|
|
61
|
+
allowlist.check_allowed(str(src))
|
|
62
|
+
allowlist.check_allowed(str(dst))
|
|
63
|
+
except DocuhandError as exc:
|
|
64
|
+
audit.record(TOOL_NAME, str(src), ok=False, duration_ms=0, error_code=exc.error_code)
|
|
65
|
+
return exc.to_dict()
|
|
66
|
+
|
|
67
|
+
# -- execute -------------------------------------------------------------------
|
|
68
|
+
worker = get_worker()
|
|
69
|
+
engine = get_engine()
|
|
70
|
+
page_from, page_to = pages if pages else (None, None)
|
|
71
|
+
try:
|
|
72
|
+
result = worker.submit(engine.export_pdf, str(src), str(dst), page_from, page_to)
|
|
73
|
+
except DocuhandError as exc:
|
|
74
|
+
audit.record(
|
|
75
|
+
TOOL_NAME,
|
|
76
|
+
str(src),
|
|
77
|
+
ok=False,
|
|
78
|
+
duration_ms=(time.perf_counter() - t0) * 1000,
|
|
79
|
+
error_code=exc.error_code,
|
|
80
|
+
)
|
|
81
|
+
return exc.to_dict()
|
|
82
|
+
except Exception as exc: # unknown engine crash — still structured
|
|
83
|
+
err = InternalError(
|
|
84
|
+
f"Unexpected error exporting {src.name}: {type(exc).__name__}: {exc}",
|
|
85
|
+
llm_hint="Retry once. If it persists, report error_code and details to the DocuHand repository.",
|
|
86
|
+
details={"source": str(src)},
|
|
87
|
+
)
|
|
88
|
+
audit.record(TOOL_NAME, str(src), ok=False, duration_ms=(time.perf_counter() - t0) * 1000, error_code=err.error_code)
|
|
89
|
+
return err.to_dict()
|
|
90
|
+
|
|
91
|
+
audit.record(TOOL_NAME, str(src), ok=True, duration_ms=result.get("duration_ms") or 0, engine=result.get("engine"))
|
|
92
|
+
return result
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""extract_content — text / tables / outline out of legacy .doc as JSON."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from ..engine import get_engine, get_worker
|
|
9
|
+
from ..engine.extraction import extract_content as engine_extract
|
|
10
|
+
from ..errors import DocuhandError, InvalidParamError, InternalError
|
|
11
|
+
from ..safety import allowlist, audit
|
|
12
|
+
|
|
13
|
+
TOOL_NAME = "extract_content"
|
|
14
|
+
|
|
15
|
+
_WHAT = ("text", "tables", "outline")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def run(path: str, what: str = "text") -> dict[str, Any]:
|
|
19
|
+
t0 = time.perf_counter()
|
|
20
|
+
|
|
21
|
+
what_norm = (what or "text").strip().lower()
|
|
22
|
+
if what_norm not in _WHAT:
|
|
23
|
+
return InvalidParamError(
|
|
24
|
+
f"what must be one of {list(_WHAT)} (got {what!r})",
|
|
25
|
+
llm_hint="'tables' pulls every table as a JSON grid; 'outline' returns the heading tree; 'text' is the full plain text.",
|
|
26
|
+
).to_dict()
|
|
27
|
+
|
|
28
|
+
try:
|
|
29
|
+
allowlist.check_allowed(path)
|
|
30
|
+
except DocuhandError as exc:
|
|
31
|
+
audit.record(TOOL_NAME, path, ok=False, duration_ms=0, error_code=exc.error_code)
|
|
32
|
+
return exc.to_dict()
|
|
33
|
+
|
|
34
|
+
worker = get_worker()
|
|
35
|
+
engine = get_engine()
|
|
36
|
+
try:
|
|
37
|
+
result = worker.submit(engine_extract, engine, worker, path, what_norm)
|
|
38
|
+
except DocuhandError as exc:
|
|
39
|
+
audit.record(
|
|
40
|
+
TOOL_NAME,
|
|
41
|
+
path,
|
|
42
|
+
ok=False,
|
|
43
|
+
duration_ms=(time.perf_counter() - t0) * 1000,
|
|
44
|
+
error_code=exc.error_code,
|
|
45
|
+
)
|
|
46
|
+
return exc.to_dict()
|
|
47
|
+
except Exception as exc:
|
|
48
|
+
err = InternalError(
|
|
49
|
+
f"Unexpected error extracting content: {type(exc).__name__}: {exc}",
|
|
50
|
+
llm_hint="Retry once. If it persists, report error_code and details to the DocuHand repository.",
|
|
51
|
+
details={"path": path},
|
|
52
|
+
)
|
|
53
|
+
audit.record(TOOL_NAME, path, ok=False, duration_ms=(time.perf_counter() - t0) * 1000, error_code=err.error_code)
|
|
54
|
+
return err.to_dict()
|
|
55
|
+
|
|
56
|
+
audit.record(
|
|
57
|
+
TOOL_NAME,
|
|
58
|
+
path,
|
|
59
|
+
ok=True,
|
|
60
|
+
duration_ms=result.get("duration_ms") or 0,
|
|
61
|
+
engine=result.get("engine"),
|
|
62
|
+
)
|
|
63
|
+
return result
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""fill_template — fill bookmarks / {{placeholders}} / content controls.
|
|
2
|
+
|
|
3
|
+
Tool layer: validate + plan with pure helpers (engine/template_plan.py),
|
|
4
|
+
gate through the safety stack (read-only policy, allowlist, audit), then
|
|
5
|
+
run one engine fill on the COM STA worker. The template file itself is
|
|
6
|
+
never modified; failures come back as structured errors.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import time
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from ..engine import get_engine, get_worker
|
|
16
|
+
from ..engine.template_plan import (
|
|
17
|
+
normalize_mode,
|
|
18
|
+
parse_bookmark_names,
|
|
19
|
+
resolve_output,
|
|
20
|
+
validate_data,
|
|
21
|
+
)
|
|
22
|
+
from ..errors import DocuhandError, FileNotFoundError, InternalError, InvalidParamError, TemplateFillError
|
|
23
|
+
from ..safety import allowlist, audit
|
|
24
|
+
from ..safety.policy import ensure_writable
|
|
25
|
+
|
|
26
|
+
TOOL_NAME = "fill_template"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def run(
|
|
30
|
+
template_path: str,
|
|
31
|
+
data: dict[str, Any],
|
|
32
|
+
output_path: str | None = None,
|
|
33
|
+
mode: str = "auto",
|
|
34
|
+
bookmarks: list[str] | None = None,
|
|
35
|
+
) -> dict[str, Any]:
|
|
36
|
+
t0 = time.perf_counter()
|
|
37
|
+
|
|
38
|
+
# -- argument validation ---------------------------------------------------
|
|
39
|
+
try:
|
|
40
|
+
norm_mode = normalize_mode(mode)
|
|
41
|
+
clean_data = validate_data(data)
|
|
42
|
+
names = parse_bookmark_names(bookmarks)
|
|
43
|
+
except ValueError as exc:
|
|
44
|
+
return InvalidParamError(str(exc), llm_hint="Fix the argument and retry.").to_dict()
|
|
45
|
+
|
|
46
|
+
template = Path(template_path).expanduser()
|
|
47
|
+
if not template.exists():
|
|
48
|
+
return FileNotFoundError(str(template)).to_dict()
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
output = resolve_output(template, output_path)
|
|
52
|
+
except ValueError as exc:
|
|
53
|
+
return InvalidParamError(
|
|
54
|
+
str(exc),
|
|
55
|
+
llm_hint="Choose a different output_path (extension .doc/.docx); the template is never rewritten.",
|
|
56
|
+
).to_dict()
|
|
57
|
+
|
|
58
|
+
# -- safety gates ------------------------------------------------------------
|
|
59
|
+
try:
|
|
60
|
+
ensure_writable(TOOL_NAME)
|
|
61
|
+
allowlist.check_allowed(str(template))
|
|
62
|
+
allowlist.check_allowed(str(output))
|
|
63
|
+
except DocuhandError as exc:
|
|
64
|
+
audit.record(TOOL_NAME, str(template), ok=False, duration_ms=0, error_code=exc.error_code)
|
|
65
|
+
return exc.to_dict()
|
|
66
|
+
|
|
67
|
+
# -- execute -------------------------------------------------------------------
|
|
68
|
+
worker = get_worker()
|
|
69
|
+
engine = get_engine()
|
|
70
|
+
try:
|
|
71
|
+
result = worker.submit(
|
|
72
|
+
engine.fill_document,
|
|
73
|
+
str(template),
|
|
74
|
+
str(output),
|
|
75
|
+
clean_data,
|
|
76
|
+
norm_mode,
|
|
77
|
+
names,
|
|
78
|
+
)
|
|
79
|
+
except DocuhandError as exc:
|
|
80
|
+
audit.record(
|
|
81
|
+
TOOL_NAME,
|
|
82
|
+
str(template),
|
|
83
|
+
ok=False,
|
|
84
|
+
duration_ms=(time.perf_counter() - t0) * 1000,
|
|
85
|
+
error_code=exc.error_code,
|
|
86
|
+
)
|
|
87
|
+
return exc.to_dict()
|
|
88
|
+
except Exception as exc: # unknown engine crash — still structured
|
|
89
|
+
err = InternalError(
|
|
90
|
+
f"Unexpected error filling {template.name}: {type(exc).__name__}: {exc}",
|
|
91
|
+
llm_hint="Retry once. If it persists, report error_code and details to the DocuHand repository.",
|
|
92
|
+
details={"template": str(template)},
|
|
93
|
+
)
|
|
94
|
+
audit.record(TOOL_NAME, str(template), ok=False, duration_ms=(time.perf_counter() - t0) * 1000, error_code=err.error_code)
|
|
95
|
+
return err.to_dict()
|
|
96
|
+
|
|
97
|
+
audit.record(TOOL_NAME, str(template), ok=True, duration_ms=result.get("duration_ms") or 0, engine=result.get("engine"))
|
|
98
|
+
|
|
99
|
+
# A fill that silently skipped keys is a warning, not a crash — surface it.
|
|
100
|
+
problems: list[str] = []
|
|
101
|
+
if result.get("missing_keys"):
|
|
102
|
+
problems.append(f"{len(result['missing_keys'])} data key(s) found no target in the template")
|
|
103
|
+
if result.get("leftover_placeholders"):
|
|
104
|
+
problems.append(f"{len(result['leftover_placeholders'])} {{{{...}}}} placeholder(s) remain unfilled")
|
|
105
|
+
if problems and norm_mode == "auto":
|
|
106
|
+
incomplete = TemplateFillError(
|
|
107
|
+
str(output),
|
|
108
|
+
"; ".join(problems),
|
|
109
|
+
missing=result.get("missing_keys") or None,
|
|
110
|
+
)
|
|
111
|
+
# success-shaped payload plus the structured warning — the file IS
|
|
112
|
+
# written; the agent decides whether it is good enough to keep.
|
|
113
|
+
result["ok"] = True
|
|
114
|
+
result["warning"] = incomplete.to_dict()["error"]
|
|
115
|
+
return result
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""inspect_document — pre-flight health check for Word documents."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from ..engine import get_engine
|
|
9
|
+
from ..errors import DocuhandError, InternalError
|
|
10
|
+
from ..safety import allowlist, audit
|
|
11
|
+
|
|
12
|
+
TOOL_NAME = "inspect_document"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def run(path: str) -> dict[str, Any]:
|
|
16
|
+
t0 = time.perf_counter()
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
allowlist.check_allowed(path)
|
|
20
|
+
except DocuhandError as exc:
|
|
21
|
+
audit.record(TOOL_NAME, path, ok=False, duration_ms=0, error_code=exc.error_code)
|
|
22
|
+
return exc.to_dict()
|
|
23
|
+
|
|
24
|
+
try:
|
|
25
|
+
result = get_engine().inspect_document(path)
|
|
26
|
+
except DocuhandError as exc:
|
|
27
|
+
audit.record(
|
|
28
|
+
TOOL_NAME,
|
|
29
|
+
path,
|
|
30
|
+
ok=False,
|
|
31
|
+
duration_ms=(time.perf_counter() - t0) * 1000,
|
|
32
|
+
error_code=exc.error_code,
|
|
33
|
+
)
|
|
34
|
+
return exc.to_dict()
|
|
35
|
+
except Exception as exc: # unknown engine crash — still structured
|
|
36
|
+
err = InternalError(
|
|
37
|
+
f"Unexpected error: {type(exc).__name__}: {exc}",
|
|
38
|
+
llm_hint="Retry once. If it persists, report error_code and details to the DocuHand repository.",
|
|
39
|
+
details={"path": path},
|
|
40
|
+
)
|
|
41
|
+
audit.record(
|
|
42
|
+
TOOL_NAME,
|
|
43
|
+
path,
|
|
44
|
+
ok=False,
|
|
45
|
+
duration_ms=(time.perf_counter() - t0) * 1000,
|
|
46
|
+
error_code=err.error_code,
|
|
47
|
+
)
|
|
48
|
+
return err.to_dict()
|
|
49
|
+
|
|
50
|
+
audit.record(
|
|
51
|
+
TOOL_NAME,
|
|
52
|
+
path,
|
|
53
|
+
ok=True,
|
|
54
|
+
duration_ms=(time.perf_counter() - t0) * 1000,
|
|
55
|
+
engine=result.get("engine"),
|
|
56
|
+
)
|
|
57
|
+
return result
|
docuhand/tools/merge.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""merge_documents — one template × N data rows → N filled files.
|
|
2
|
+
|
|
3
|
+
Mail-merge style batch generation (the 干部任免审批表 production scar,
|
|
4
|
+
rebuilt as a product tool). Per-row fault isolation: one bad row fails
|
|
5
|
+
alone and the batch continues — the convert_documents survival guarantee,
|
|
6
|
+
applied to generation.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import time
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from ..engine import get_engine, get_worker
|
|
16
|
+
from ..engine.merge_plan import build_name, default_output_dir, resolve_collision
|
|
17
|
+
from ..engine.template_plan import validate_data
|
|
18
|
+
from ..errors import DocuhandError, InternalError, InvalidParamError
|
|
19
|
+
from ..safety import allowlist, audit
|
|
20
|
+
from ..safety.policy import ensure_writable
|
|
21
|
+
|
|
22
|
+
TOOL_NAME = "merge_documents"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def run(
|
|
26
|
+
template_path: str,
|
|
27
|
+
data_rows: list[dict[str, Any]],
|
|
28
|
+
output_dir: str | None = None,
|
|
29
|
+
name_pattern: str = "{i}_{name}",
|
|
30
|
+
) -> dict[str, Any]:
|
|
31
|
+
t0 = time.perf_counter()
|
|
32
|
+
|
|
33
|
+
# -- argument validation ---------------------------------------------------
|
|
34
|
+
template = Path(template_path).expanduser()
|
|
35
|
+
if not template.exists():
|
|
36
|
+
from ..errors import FileNotFoundError
|
|
37
|
+
|
|
38
|
+
return FileNotFoundError(str(template)).to_dict()
|
|
39
|
+
|
|
40
|
+
if not isinstance(data_rows, list) or not data_rows:
|
|
41
|
+
return InvalidParamError(
|
|
42
|
+
"data_rows must be a non-empty array of row objects",
|
|
43
|
+
llm_hint="Each row is one output document: [{\"name\": \"张三\", ...}, ...].",
|
|
44
|
+
).to_dict()
|
|
45
|
+
if len(data_rows) > 500:
|
|
46
|
+
return InvalidParamError(
|
|
47
|
+
f"at most 500 rows per call (got {len(data_rows)})",
|
|
48
|
+
llm_hint="Split very large merges into several calls; the COM session stays healthier.",
|
|
49
|
+
).to_dict()
|
|
50
|
+
|
|
51
|
+
cleaned_rows: list[dict[str, str]] = []
|
|
52
|
+
for i, row in enumerate(data_rows):
|
|
53
|
+
try:
|
|
54
|
+
cleaned_rows.append(validate_data(row))
|
|
55
|
+
except ValueError as exc:
|
|
56
|
+
return InvalidParamError(
|
|
57
|
+
f"data_rows[{i}]: {exc}",
|
|
58
|
+
llm_hint="Fix this row and retry.",
|
|
59
|
+
).to_dict()
|
|
60
|
+
|
|
61
|
+
out_dir = Path(output_dir).expanduser() if output_dir else default_output_dir(template)
|
|
62
|
+
try:
|
|
63
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
64
|
+
except OSError as exc:
|
|
65
|
+
return DocuhandError(
|
|
66
|
+
f"Cannot create output directory {out_dir}: {exc}",
|
|
67
|
+
llm_hint="Check the path (drive letter, permissions) or pass a different output_dir.",
|
|
68
|
+
details={"output_dir": str(out_dir)},
|
|
69
|
+
).to_dict()
|
|
70
|
+
|
|
71
|
+
ext = template.suffix.lower()
|
|
72
|
+
if ext not in (".doc", ".docx"):
|
|
73
|
+
return InvalidParamError(
|
|
74
|
+
f"template must be .doc or .docx (got {ext or 'none'})",
|
|
75
|
+
llm_hint="Pick a Word template file; PDF templates are not supported.",
|
|
76
|
+
).to_dict()
|
|
77
|
+
|
|
78
|
+
# -- safety gates ------------------------------------------------------------
|
|
79
|
+
try:
|
|
80
|
+
ensure_writable(TOOL_NAME)
|
|
81
|
+
allowlist.check_allowed(str(template))
|
|
82
|
+
allowlist.check_allowed(str(out_dir))
|
|
83
|
+
except DocuhandError as exc:
|
|
84
|
+
audit.record(TOOL_NAME, str(template), ok=False, duration_ms=0, error_code=exc.error_code)
|
|
85
|
+
return exc.to_dict()
|
|
86
|
+
|
|
87
|
+
# -- plan outputs (collisions resolved before any engine work) -----------------
|
|
88
|
+
planned: list[tuple[dict[str, str], Path]] = []
|
|
89
|
+
for i, row in enumerate(cleaned_rows, start=1):
|
|
90
|
+
stem = build_name(name_pattern, i, row)
|
|
91
|
+
target = resolve_collision(out_dir, stem, ext)
|
|
92
|
+
planned.append((row, target))
|
|
93
|
+
|
|
94
|
+
# -- execute per row with fault isolation ----------------------------------------
|
|
95
|
+
worker = get_worker()
|
|
96
|
+
engine = get_engine()
|
|
97
|
+
results: list[dict[str, Any]] = []
|
|
98
|
+
counts = {"merged": 0, "failed": 0, "with_warnings": 0}
|
|
99
|
+
|
|
100
|
+
for i, (row, target) in enumerate(planned, start=1):
|
|
101
|
+
try:
|
|
102
|
+
r = worker.submit(engine.merge_one, str(template), str(target), row)
|
|
103
|
+
except DocuhandError as exc:
|
|
104
|
+
counts["failed"] += 1
|
|
105
|
+
results.append({"row": i, "output": str(target), "status": "failed", "error": exc.to_dict()["error"]})
|
|
106
|
+
audit.record(TOOL_NAME, str(template), ok=False, duration_ms=0, error_code=exc.error_code)
|
|
107
|
+
continue
|
|
108
|
+
except Exception as exc:
|
|
109
|
+
err = InternalError(
|
|
110
|
+
f"row {i}: {type(exc).__name__}: {exc}",
|
|
111
|
+
llm_hint="Retry once; if it persists report error_code to the DocuHand repository.",
|
|
112
|
+
)
|
|
113
|
+
counts["failed"] += 1
|
|
114
|
+
results.append({"row": i, "output": str(target), "status": "failed", "error": err.to_dict()["error"]})
|
|
115
|
+
audit.record(TOOL_NAME, str(template), ok=False, duration_ms=0, error_code=err.error_code)
|
|
116
|
+
continue
|
|
117
|
+
|
|
118
|
+
counts["merged"] += 1
|
|
119
|
+
entry: dict[str, Any] = {
|
|
120
|
+
"row": i,
|
|
121
|
+
"output": str(target),
|
|
122
|
+
"status": "merged",
|
|
123
|
+
"engine": r.get("engine"),
|
|
124
|
+
"output_size_bytes": r.get("output_size_bytes"),
|
|
125
|
+
}
|
|
126
|
+
if r.get("row_warning"):
|
|
127
|
+
counts["with_warnings"] += 1
|
|
128
|
+
entry["warning"] = r["row_warning"]
|
|
129
|
+
results.append(entry)
|
|
130
|
+
audit.record(TOOL_NAME, str(template), ok=True, duration_ms=r.get("duration_ms") or 0, engine=r.get("engine"))
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
"ok": counts["failed"] < len(planned), # total engine failure only if every row failed
|
|
134
|
+
"tool": TOOL_NAME,
|
|
135
|
+
"template": str(template),
|
|
136
|
+
"output_dir": str(out_dir),
|
|
137
|
+
"summary": {
|
|
138
|
+
**counts,
|
|
139
|
+
"rows": len(planned),
|
|
140
|
+
"duration_ms": round((time.perf_counter() - t0) * 1000),
|
|
141
|
+
},
|
|
142
|
+
"results": results,
|
|
143
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: docuhand
|
|
3
|
+
Version: 0.1.0.dev1
|
|
4
|
+
Summary: Give your AI agent hands to operate real Word & WPS documents on Windows — an MCP server driving real Office apps via COM.
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Requires-Python: >=3.12
|
|
8
|
+
Requires-Dist: mcp<2,>=1.2
|
|
9
|
+
Requires-Dist: olefile>=0.47
|
|
10
|
+
Requires-Dist: pywin32<312,>=306
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# docuhand
|
|
14
|
+
Give your AI agent hands to operate real Word & WPS documents on Windows
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
docuhand/__init__.py,sha256=xSZFzhFrXp33G8dvHJ4wvsUPHP7JBr2Rm1sljA_puJU,110
|
|
2
|
+
docuhand/__main__.py,sha256=9it1my_mM7xBpDh79QSi7FAvZD3Vki3GQFPJIDZw_zs,64
|
|
3
|
+
docuhand/_compat.py,sha256=Ue5Uo8_nKDpfkmI5lGBIeLY_JG31I-jTstlMrOhoET0,984
|
|
4
|
+
docuhand/errors.py,sha256=ia1ikuJjZHged-7HB_V1wjMm0AcItBJzRKnzTea9cSc,7199
|
|
5
|
+
docuhand/server.py,sha256=tlJjWLeOqouPvOsR1OLuIOOyXDz-z6cAf28iY22-ZdY,9346
|
|
6
|
+
docuhand/engine/__init__.py,sha256=SMAJZMblCqWR39XWPlcb6_fMHhDk2xMIwxTOOxoT4nA,1047
|
|
7
|
+
docuhand/engine/com_thread.py,sha256=srRaHYjbv99vOzWLtKBeeD8yaKKJwnpzWitwEVBviOo,3139
|
|
8
|
+
docuhand/engine/com_utils.py,sha256=cNmIDXHuxSUSwvthNGfCCGvLEN4JKaAddHG2V6_TGKA,1707
|
|
9
|
+
docuhand/engine/container_guard.py,sha256=BTE2v-LkDL9TCj5cgNLp3OM7ubrM77VkfE-5aicyRHw,7111
|
|
10
|
+
docuhand/engine/convert_plan.py,sha256=ysajq0Ikv2IwQ7WLXm6PI6cwFJb2wixym55c_QAUNO4,1632
|
|
11
|
+
docuhand/engine/edit_plan.py,sha256=qSBR3f9W7gXcR7tL4R4Y2V5SgNKVMS_8zs1iInBjVQc,3098
|
|
12
|
+
docuhand/engine/extraction.py,sha256=bmDi9HN4CTrgfMNsrNpL9Lr6PX-2C40RXovNgYUho7Q,6379
|
|
13
|
+
docuhand/engine/live_edit.py,sha256=hjYMU72HoiOFNUEF4JXFqfb62CgodkjQ3mdgdV2zApY,7477
|
|
14
|
+
docuhand/engine/merge_plan.py,sha256=t1hAoQ4msDmTZqq_snfQpIy1gWOZGe8Ki0f6b4n8tzk,1972
|
|
15
|
+
docuhand/engine/office_app.py,sha256=zWoyUDO5cYu88KrxO0vmxcTmQDQu57VIN-noXKa8ZKU,32036
|
|
16
|
+
docuhand/engine/pdf_plan.py,sha256=x9kxHWE4HH8dquzC5TxjhzwYXajx8oB3WWmiWYSHNEc,1898
|
|
17
|
+
docuhand/engine/template_plan.py,sha256=bPORNXxe4WpURTrY54jwGeWF4SrOt98Ee-BvGB3zi4c,3910
|
|
18
|
+
docuhand/engine/templating.py,sha256=PkQT3inty0PDFMJfIlLj_H2554hD9u0t2aClF4qKMLc,11137
|
|
19
|
+
docuhand/engine/wd_constants.py,sha256=MT0YLnaiDo5opf86C9rl_4I9ukPnTff7tnH8DazsNLg,547
|
|
20
|
+
docuhand/safety/__init__.py,sha256=HbcyxKcUAaotJ5-sXh6clm1oca9KepfGKzzgTTnSuI8,315
|
|
21
|
+
docuhand/safety/allowlist.py,sha256=a7ooM9aBjnni2EY_YSnq4dIYN0bPIeWw61VFRx9fLks,1190
|
|
22
|
+
docuhand/safety/audit.py,sha256=_jQNEDrrUqaykdFuLTNTziqACGfRGpf-Z4ZZlrwYxAw,1387
|
|
23
|
+
docuhand/safety/policy.py,sha256=J9uiSCW3Maszeg6-qS9BNj0Qav8ZIISQu7y4WLKJcIQ,877
|
|
24
|
+
docuhand/tools/__init__.py,sha256=GadG7o7O1tWO4jjTPrWx6R_4u7xiJPW9MXqSf-fMsZs,344
|
|
25
|
+
docuhand/tools/convert.py,sha256=4hCMRGmIGSPiza2ujcRJc4kejkcMV0ZJjsw0t7vVZ5k,8527
|
|
26
|
+
docuhand/tools/edit_open.py,sha256=dwlClXEWTidHlOEWC7i3swV0ALIhE1sy7QQvNldE52c,3458
|
|
27
|
+
docuhand/tools/export_pdf.py,sha256=sQjtJ0q1-JAb2kRzj9Hui3ojIXUOKXL_S4Rj3IYBvIY,3441
|
|
28
|
+
docuhand/tools/extract.py,sha256=ToJwVk3_U6ZEKxJaJRbGSMRuPlb7VB_8z6mdb_nV464,2086
|
|
29
|
+
docuhand/tools/fill_template.py,sha256=7G5CNDdVoUEDfeER8u1ldyC8B8hWe6kiERqGuQPRi0o,4304
|
|
30
|
+
docuhand/tools/inspect.py,sha256=O8dayfhlyrFpCZdTfO2rU_f1-4nLvByMurP9wENCiYs,1611
|
|
31
|
+
docuhand/tools/merge.py,sha256=tDC4hQEiBPfu2FSvV0TZHkIzxu27OImVvrbP_u0IyVQ,5681
|
|
32
|
+
docuhand-0.1.0.dev1.dist-info/METADATA,sha256=z3nNmRe3Q96wZ4gGyn9fwCfgHGQu9LGQho285eO81gg,479
|
|
33
|
+
docuhand-0.1.0.dev1.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
34
|
+
docuhand-0.1.0.dev1.dist-info/entry_points.txt,sha256=c0HU90ex3jSZhvCHIelsFweolWagjDOm1UUCcmoDqDQ,50
|
|
35
|
+
docuhand-0.1.0.dev1.dist-info/licenses/LICENSE,sha256=hPw9rU1TQ7AAqcXcmBq2qtrQyq1-Yo-e6u-BFy_ndJ4,1088
|
|
36
|
+
docuhand-0.1.0.dev1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Frank Wang
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|