smart-docgen 0.1.0__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.
- docpilot/__init__.py +1499 -0
- docpilot/builder/__init__.py +12 -0
- docpilot/builder/base.py +50 -0
- docpilot/builder/docx_analyzer.py +208 -0
- docpilot/builder/docx_builder.py +266 -0
- docpilot/builder/html_blocks.py +174 -0
- docpilot/builder/html_table.py +269 -0
- docpilot/builder/hwp_convert.py +104 -0
- docpilot/builder/hwpx_analyzer.py +190 -0
- docpilot/builder/hwpx_auto_builder.py +314 -0
- docpilot/builder/hwpx_builder.py +301 -0
- docpilot/builder/hwpx_bullets.py +164 -0
- docpilot/builder/hwpx_dynamic_builder.py +361 -0
- docpilot/builder/hwpx_section_generator.py +130 -0
- docpilot/builder/hwpx_style_extractor.py +128 -0
- docpilot/builder/pdf_builder.py +100 -0
- docpilot/db/__init__.py +4 -0
- docpilot/db/client.py +155 -0
- docpilot/db/indexer.py +355 -0
- docpilot/db/schema.py +94 -0
- docpilot/db/template_store.py +412 -0
- docpilot/exceptions.py +34 -0
- docpilot/ingestion/__init__.py +48 -0
- docpilot/ingestion/docx.py +78 -0
- docpilot/ingestion/hwp.py +77 -0
- docpilot/ingestion/hwpx.py +91 -0
- docpilot/ingestion/models.py +12 -0
- docpilot/ingestion/pdf.py +125 -0
- docpilot/ingestion/text.py +35 -0
- docpilot/mapping/__init__.py +24 -0
- docpilot/mapping/base.py +184 -0
- docpilot/mapping/benchmark.py +103 -0
- docpilot/mapping/claude.py +93 -0
- docpilot/mapping/gemini.py +79 -0
- docpilot/mapping/openai.py +78 -0
- docpilot/mapping/openai_compat.py +106 -0
- docpilot/mapping/rag.py +157 -0
- docpilot/mapping/sidecar.py +107 -0
- docpilot/mcp_server.py +282 -0
- docpilot/search/__init__.py +64 -0
- docpilot/search/_filter.py +77 -0
- docpilot/search/embedding.py +268 -0
- docpilot/search/eval.py +86 -0
- docpilot/search/exact.py +50 -0
- docpilot/search/highlight.py +73 -0
- docpilot/search/hybrid.py +92 -0
- docpilot/search/models.py +44 -0
- docpilot/search/morpheme.py +132 -0
- docpilot/search/reranker.py +44 -0
- docpilot/template_generator/__init__.py +4 -0
- docpilot/template_generator/analyzer.py +100 -0
- docpilot/template_generator/docx_extractor.py +71 -0
- docpilot/template_generator/extractor.py +131 -0
- docpilot/template_generator/generator.py +331 -0
- docpilot/templates/base/Contents/content.hpf +24 -0
- docpilot/templates/base/Contents/header.xml +821 -0
- docpilot/templates/base/Contents/section0.xml +48 -0
- docpilot/templates/base/META-INF/container.rdf +1 -0
- docpilot/templates/base/META-INF/container.xml +8 -0
- docpilot/templates/base/META-INF/manifest.xml +2 -0
- docpilot/templates/base/Preview/PrvImage.png +0 -0
- docpilot/templates/base/Preview/PrvText.txt +1 -0
- docpilot/templates/base/mimetype +1 -0
- docpilot/templates/base/settings.xml +4 -0
- docpilot/templates/base/version.xml +2 -0
- docpilot/templates/gonmun/header.xml +976 -0
- docpilot/templates/gonmun/section0.xml +197 -0
- docpilot/templates/gonmun/sidecar.json +24 -0
- docpilot/templates/minutes/header.xml +965 -0
- docpilot/templates/minutes/section0.xml +346 -0
- docpilot/templates/minutes/sidecar.json +17 -0
- docpilot/templates/proposal/header.xml +1040 -0
- docpilot/templates/proposal/section0.xml +242 -0
- docpilot/templates/proposal/sidecar.json +11 -0
- docpilot/templates/report/header.xml +1202 -0
- docpilot/templates/report/section0.xml +201 -0
- docpilot/templates/report/sidecar.json +18 -0
- smart_docgen-0.1.0.dist-info/METADATA +1269 -0
- smart_docgen-0.1.0.dist-info/RECORD +85 -0
- smart_docgen-0.1.0.dist-info/WHEEL +5 -0
- smart_docgen-0.1.0.dist-info/entry_points.txt +2 -0
- smart_docgen-0.1.0.dist-info/licenses/LICENSE +21 -0
- smart_docgen-0.1.0.dist-info/scm_file_list.json +123 -0
- smart_docgen-0.1.0.dist-info/scm_version.json +8 -0
- smart_docgen-0.1.0.dist-info/top_level.txt +1 -0
docpilot/__init__.py
ADDED
|
@@ -0,0 +1,1499 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import atexit
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
from importlib.metadata import version, PackageNotFoundError
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
__version__ = version("docpilot")
|
|
11
|
+
except PackageNotFoundError:
|
|
12
|
+
__version__ = "0.0.0+unknown"
|
|
13
|
+
import tempfile
|
|
14
|
+
import zipfile
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from dotenv import load_dotenv
|
|
19
|
+
|
|
20
|
+
from docpilot.exceptions import BuilderError, DocPilotError, MappingError
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from docpilot.ingestion.models import IngestedDocument
|
|
24
|
+
|
|
25
|
+
load_dotenv()
|
|
26
|
+
|
|
27
|
+
_PLACEHOLDER_RE = re.compile(r"\{\{\??(.+?)\??\}\}") # {{key}}, {{?key}}, {{?key?}}
|
|
28
|
+
_OPTIONAL_PLACEHOLDER_RE = re.compile(r"\{\{\?(.+?)\}\}") # only {{?key}} and {{?key?}}
|
|
29
|
+
_DYNAMIC_LIST_RE = re.compile(r"\{\{\?([^}?]+)\?\}\}") # only {{?key?}}
|
|
30
|
+
_NUMBERED_GROUP_RE = re.compile(r"^(.+?)(\d+)(.*)$") # "단어1_뜻" → ("단어","1","_뜻")
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class GenerateResult:
|
|
34
|
+
path: Path
|
|
35
|
+
model: str
|
|
36
|
+
input_tokens: int
|
|
37
|
+
output_tokens: int
|
|
38
|
+
elapsed_seconds: float
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def total_tokens(self) -> int:
|
|
42
|
+
return self.input_tokens + self.output_tokens
|
|
43
|
+
|
|
44
|
+
def __str__(self) -> str:
|
|
45
|
+
return str(self.path)
|
|
46
|
+
|
|
47
|
+
def __fspath__(self) -> str:
|
|
48
|
+
return str(self.path)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
_MODEL_PRICING: dict[str, tuple[float, float]] = {
|
|
52
|
+
"claude-opus-4-8": (5.00, 25.00),
|
|
53
|
+
"claude-opus-4-7": (5.00, 25.00),
|
|
54
|
+
"claude-opus-4-6": (5.00, 25.00),
|
|
55
|
+
"claude-sonnet-4-6": (3.00, 15.00),
|
|
56
|
+
"claude-haiku-4-5": (1.00, 5.00),
|
|
57
|
+
}
|
|
58
|
+
_EST_OUTPUT_TOKENS_PER_SECTION = 500
|
|
59
|
+
|
|
60
|
+
# Maps file extension → required extra (None = included in base install)
|
|
61
|
+
_EXT_EXTRAS: dict[str, str | None] = {
|
|
62
|
+
".txt": None,
|
|
63
|
+
".md": None,
|
|
64
|
+
".rst": None,
|
|
65
|
+
".csv": None,
|
|
66
|
+
".hwpx": None,
|
|
67
|
+
".hwp": "hwp",
|
|
68
|
+
".pdf": "pdf",
|
|
69
|
+
".docx": "docx",
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def suggest_extras(folder: str | Path) -> dict:
|
|
74
|
+
"""
|
|
75
|
+
Scan a data folder and suggest which docpilot extras to install.
|
|
76
|
+
|
|
77
|
+
Returns a dict with:
|
|
78
|
+
found — {extension: file_count} for all files found
|
|
79
|
+
unsupported — {extension: file_count} for files docpilot cannot process
|
|
80
|
+
required_extras — list of extras needed (e.g. ["pdf", "docx"])
|
|
81
|
+
install_command — ready-to-run pip command, or None if nothing extra needed
|
|
82
|
+
"""
|
|
83
|
+
folder = Path(folder)
|
|
84
|
+
if not folder.is_dir():
|
|
85
|
+
raise DocPilotError("Not a directory", detail=str(folder))
|
|
86
|
+
|
|
87
|
+
found: dict[str, int] = {}
|
|
88
|
+
for file in folder.rglob("*"):
|
|
89
|
+
if not file.is_file():
|
|
90
|
+
continue
|
|
91
|
+
ext = file.suffix.lower()
|
|
92
|
+
found[ext] = found.get(ext, 0) + 1
|
|
93
|
+
|
|
94
|
+
unsupported: dict[str, int] = {
|
|
95
|
+
ext: count for ext, count in found.items() if ext not in _EXT_EXTRAS
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
required: list[str] = sorted({
|
|
99
|
+
extra
|
|
100
|
+
for ext in found
|
|
101
|
+
if ext in _EXT_EXTRAS and (extra := _EXT_EXTRAS[ext]) is not None
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
install_command = (
|
|
105
|
+
f'pip install "docpilot[{",".join(required)}]"' if required else None
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
"found": found,
|
|
110
|
+
"unsupported": unsupported,
|
|
111
|
+
"required_extras": required,
|
|
112
|
+
"install_command": install_command,
|
|
113
|
+
}
|
|
114
|
+
_BUILTIN_TEMPLATES = Path(__file__).parent / "templates"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _get_builtin_metadata() -> dict[str, str]:
|
|
118
|
+
from docpilot.mapping.sidecar import load_sidecar
|
|
119
|
+
result = {}
|
|
120
|
+
for d in sorted(_BUILTIN_TEMPLATES.iterdir()):
|
|
121
|
+
if not d.is_dir() or d.name == "base":
|
|
122
|
+
continue
|
|
123
|
+
sidecar = load_sidecar(d)
|
|
124
|
+
if sidecar and sidecar.description:
|
|
125
|
+
result[d.name] = sidecar.description
|
|
126
|
+
else:
|
|
127
|
+
result[d.name] = d.name
|
|
128
|
+
return result
|
|
129
|
+
|
|
130
|
+
_ASSEMBLED_CACHE: dict[str, Path] = {}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _assemble_builtin_hwpx(name: str) -> Path:
|
|
134
|
+
"""Assemble a built-in HWPX template from base + per-template XML sources."""
|
|
135
|
+
if name in _ASSEMBLED_CACHE:
|
|
136
|
+
return _ASSEMBLED_CACHE[name]
|
|
137
|
+
|
|
138
|
+
base_dir = _BUILTIN_TEMPLATES / "base"
|
|
139
|
+
overlay_dir = _BUILTIN_TEMPLATES / name
|
|
140
|
+
|
|
141
|
+
if not base_dir.exists() or not overlay_dir.exists():
|
|
142
|
+
raise DocPilotError(
|
|
143
|
+
f"Built-in template '{name}' not found",
|
|
144
|
+
detail=f"Expected source at {overlay_dir}",
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
tmp = tempfile.NamedTemporaryFile(suffix=f"_{name}.hwpx", delete=False)
|
|
148
|
+
tmp.close()
|
|
149
|
+
tmp_path = Path(tmp.name)
|
|
150
|
+
|
|
151
|
+
with zipfile.ZipFile(tmp_path, "w", zipfile.ZIP_DEFLATED) as zf:
|
|
152
|
+
mimetype = base_dir / "mimetype"
|
|
153
|
+
if mimetype.exists():
|
|
154
|
+
zf.write(mimetype, "mimetype", compress_type=zipfile.ZIP_STORED)
|
|
155
|
+
|
|
156
|
+
for file in sorted(base_dir.rglob("*")):
|
|
157
|
+
if not file.is_file() or file.name == "mimetype":
|
|
158
|
+
continue
|
|
159
|
+
arcname = file.relative_to(base_dir)
|
|
160
|
+
# overlay_dir files are named header.xml / section0.xml — placed in Contents/
|
|
161
|
+
override = overlay_dir / file.name
|
|
162
|
+
source = override if override.exists() else file
|
|
163
|
+
zf.write(source, arcname.as_posix())
|
|
164
|
+
|
|
165
|
+
_ASSEMBLED_CACHE[name] = tmp_path
|
|
166
|
+
atexit.register(lambda p=tmp_path: p.unlink(missing_ok=True))
|
|
167
|
+
return tmp_path
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _m(model: str | None) -> dict:
|
|
171
|
+
return {"model": model} if model else {}
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _b(base_url: str | None) -> dict:
|
|
175
|
+
return {"base_url": base_url} if base_url else {}
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _ingest_instructions_doc(path: Path) -> str:
|
|
179
|
+
"""지침 문서를 읽어 텍스트로 반환한다. 지원하지 않는 형식이면 빈 문자열 반환."""
|
|
180
|
+
from docpilot.ingestion import text as text_ing
|
|
181
|
+
from docpilot.ingestion import hwpx as hwpx_ing
|
|
182
|
+
|
|
183
|
+
ext = path.suffix.lower()
|
|
184
|
+
|
|
185
|
+
try:
|
|
186
|
+
if ext == ".hwpx":
|
|
187
|
+
return hwpx_ing.ingest(path).content
|
|
188
|
+
if ext in text_ing.SUPPORTED_EXTENSIONS:
|
|
189
|
+
return text_ing.ingest(path).content
|
|
190
|
+
if ext == ".pdf":
|
|
191
|
+
from docpilot.ingestion import pdf as pdf_ing
|
|
192
|
+
return pdf_ing.ingest(path).content
|
|
193
|
+
if ext == ".docx":
|
|
194
|
+
from docpilot.ingestion import docx as docx_ing
|
|
195
|
+
return docx_ing.ingest(path).content
|
|
196
|
+
except Exception:
|
|
197
|
+
pass
|
|
198
|
+
return ""
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _validate_hwpx(path: Path) -> None:
|
|
202
|
+
"""생성된 HWPX 파일의 구조 무결성을 비차단 방식으로 검사한다."""
|
|
203
|
+
import logging
|
|
204
|
+
import warnings
|
|
205
|
+
|
|
206
|
+
_REQUIRED = [
|
|
207
|
+
"mimetype",
|
|
208
|
+
"Contents/content.hpf",
|
|
209
|
+
"Contents/header.xml",
|
|
210
|
+
]
|
|
211
|
+
|
|
212
|
+
try:
|
|
213
|
+
with zipfile.ZipFile(path, "r") as zf:
|
|
214
|
+
names = zf.namelist()
|
|
215
|
+
missing = [f for f in _REQUIRED if f not in names]
|
|
216
|
+
if missing:
|
|
217
|
+
warnings.warn(
|
|
218
|
+
f"HWPX 검증 경고 — 필수 파일 누락: {', '.join(missing)}",
|
|
219
|
+
stacklevel=4,
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
try:
|
|
223
|
+
from lxml import etree
|
|
224
|
+
for name in names:
|
|
225
|
+
if name.endswith(".xml") or name.endswith(".hpf"):
|
|
226
|
+
try:
|
|
227
|
+
etree.fromstring(zf.read(name))
|
|
228
|
+
except Exception as xml_err:
|
|
229
|
+
warnings.warn(
|
|
230
|
+
f"HWPX 검증 경고 — XML 오류 in {name}: {xml_err}",
|
|
231
|
+
stacklevel=4,
|
|
232
|
+
)
|
|
233
|
+
except ImportError:
|
|
234
|
+
pass
|
|
235
|
+
except Exception as exc:
|
|
236
|
+
logging.getLogger(__name__).debug("HWPX 검증 실패 (무시됨): %s", exc)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _estimate_tokens_from_folder(folder: Path, n_sections: int) -> int:
|
|
240
|
+
"""Rough token estimate from data folder file sizes. ~0.4 tokens/byte for Korean text."""
|
|
241
|
+
_TEXT_EXTS = {".txt", ".md", ".rst", ".csv", ".hwpx", ".docx"}
|
|
242
|
+
_BINARY_EXTS = {".pdf"}
|
|
243
|
+
total_bytes = 0
|
|
244
|
+
for f in folder.rglob("*"):
|
|
245
|
+
if not f.is_file():
|
|
246
|
+
continue
|
|
247
|
+
ext = f.suffix.lower()
|
|
248
|
+
if ext in _TEXT_EXTS:
|
|
249
|
+
total_bytes += f.stat().st_size
|
|
250
|
+
elif ext in _BINARY_EXTS:
|
|
251
|
+
total_bytes += f.stat().st_size * 3 # OCR/extracted text is larger than raw binary
|
|
252
|
+
tokens_per_byte = 0.4
|
|
253
|
+
raw_tokens = int(total_bytes * tokens_per_byte)
|
|
254
|
+
# RAG retrieves a subset, not the full corpus — cap at ~4,000 tokens per section
|
|
255
|
+
return min(raw_tokens, n_sections * 4000)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
_DEFAULT_MAX_INPUT_TOKENS_WARN = 50_000
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _estimate_tokens_raw(text: str) -> int:
|
|
262
|
+
"""Rough token estimate for text that goes into the prompt in full — no RAG retrieval
|
|
263
|
+
subset, so (unlike _estimate_tokens_from_folder) no per-section cap applies."""
|
|
264
|
+
return int(len(text.encode("utf-8")) * 0.4)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _resolve_instructions(
|
|
268
|
+
extra_instructions: str | None,
|
|
269
|
+
sidecar_instructions: str,
|
|
270
|
+
instructions_doc: str | Path | None,
|
|
271
|
+
) -> str | None:
|
|
272
|
+
"""Merge sidecar instructions + instructions_doc content into extra_instructions.
|
|
273
|
+
|
|
274
|
+
Priority: instructions_doc is prepended last (highest priority — read in full,
|
|
275
|
+
unlike sidecar/extra which are just strings), sidecar next, caller's extra_instructions last.
|
|
276
|
+
"""
|
|
277
|
+
if sidecar_instructions:
|
|
278
|
+
extra_instructions = (
|
|
279
|
+
f"{sidecar_instructions}\n\n{extra_instructions}"
|
|
280
|
+
if extra_instructions
|
|
281
|
+
else sidecar_instructions
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
if instructions_doc is not None:
|
|
285
|
+
doc_text = _ingest_instructions_doc(Path(instructions_doc))
|
|
286
|
+
if doc_text:
|
|
287
|
+
header = f"[지침 문서: {Path(instructions_doc).name}]\n{doc_text}"
|
|
288
|
+
extra_instructions = (
|
|
289
|
+
f"{header}\n\n{extra_instructions}" if extra_instructions
|
|
290
|
+
else header
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
return extra_instructions
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _resolve_content(content: str | list[str | Path] | list[IngestedDocument]) -> str:
|
|
297
|
+
"""Resolve the content union type shared by *_from_content() methods into a single string."""
|
|
298
|
+
from docpilot.ingestion.models import IngestedDocument
|
|
299
|
+
from docpilot.mapping.base import merge_documents
|
|
300
|
+
|
|
301
|
+
if isinstance(content, str):
|
|
302
|
+
return content
|
|
303
|
+
if content and isinstance(content[0], IngestedDocument):
|
|
304
|
+
return merge_documents(content)
|
|
305
|
+
|
|
306
|
+
from docpilot.ingestion import ingest_paths
|
|
307
|
+
docs = ingest_paths(content)
|
|
308
|
+
if not docs:
|
|
309
|
+
raise DocPilotError("content에서 ingest 가능한 파일을 찾지 못했습니다.", detail=str(content))
|
|
310
|
+
return merge_documents(docs)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _check_content_size(content_str: str, max_input_tokens: int | None) -> None:
|
|
314
|
+
"""Warn or raise on a rough byte-based token estimate — *_from_content() methods send
|
|
315
|
+
content in full with no RAG top_k to bound its size automatically."""
|
|
316
|
+
estimated_tokens = _estimate_tokens_raw(content_str)
|
|
317
|
+
if max_input_tokens is not None:
|
|
318
|
+
if estimated_tokens > max_input_tokens:
|
|
319
|
+
raise MappingError(
|
|
320
|
+
f"콘텐츠 예상 입력 토큰 수({estimated_tokens:,})가 "
|
|
321
|
+
f"max_input_tokens({max_input_tokens:,})를 초과합니다.",
|
|
322
|
+
detail="content 크기를 줄이거나 max_input_tokens를 늘리세요.",
|
|
323
|
+
)
|
|
324
|
+
elif estimated_tokens > _DEFAULT_MAX_INPUT_TOKENS_WARN:
|
|
325
|
+
import warnings
|
|
326
|
+
warnings.warn(
|
|
327
|
+
f"콘텐츠 예상 입력 토큰 수가 {estimated_tokens:,}로 큽니다 "
|
|
328
|
+
f"(경고 기준: {_DEFAULT_MAX_INPUT_TOKENS_WARN:,}). RAG와 달리 이 경로는 "
|
|
329
|
+
"content를 통째로 프롬프트에 넣으므로 컨텍스트 초과·비용 증가 위험이 있습니다. "
|
|
330
|
+
"content를 줄이거나 max_input_tokens로 명시적 상한을 거세요.",
|
|
331
|
+
stacklevel=2,
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _extract_placeholders(template_path: Path) -> list[str]:
|
|
336
|
+
"""Extract {{section}} placeholder names from a template file."""
|
|
337
|
+
suffix = template_path.suffix.lower()
|
|
338
|
+
|
|
339
|
+
if suffix == ".hwpx":
|
|
340
|
+
with zipfile.ZipFile(template_path, "r") as zf:
|
|
341
|
+
names = zf.namelist()
|
|
342
|
+
candidates = [n for n in names if n.endswith("content.hml")]
|
|
343
|
+
if not candidates:
|
|
344
|
+
candidates = [n for n in names if n.endswith("section0.xml")]
|
|
345
|
+
if not candidates:
|
|
346
|
+
return []
|
|
347
|
+
text = "".join(
|
|
348
|
+
zf.read(c).decode("utf-8", errors="ignore") for c in candidates
|
|
349
|
+
)
|
|
350
|
+
elif suffix == ".docx":
|
|
351
|
+
with zipfile.ZipFile(template_path, "r") as zf:
|
|
352
|
+
text = zf.read("word/document.xml").decode("utf-8", errors="ignore")
|
|
353
|
+
elif suffix == ".pdf":
|
|
354
|
+
try:
|
|
355
|
+
import pdfplumber
|
|
356
|
+
with pdfplumber.open(template_path) as pdf:
|
|
357
|
+
text = "\n".join(p.extract_text() or "" for p in pdf.pages)
|
|
358
|
+
except Exception:
|
|
359
|
+
return []
|
|
360
|
+
else:
|
|
361
|
+
return []
|
|
362
|
+
|
|
363
|
+
seen: dict[str, None] = {}
|
|
364
|
+
for match in _PLACEHOLDER_RE.finditer(text):
|
|
365
|
+
seen[match.group(1)] = None
|
|
366
|
+
return list(seen)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _extract_placeholder_sections(template_path: Path) -> list:
|
|
370
|
+
"""
|
|
371
|
+
Extract placeholders and return TemplateSection list with optional/list flags.
|
|
372
|
+
|
|
373
|
+
- {{key}} → required TemplateSection
|
|
374
|
+
- {{?key}} → optional TemplateSection (optional=True)
|
|
375
|
+
- {{?단어1}}, {{?단어2}}, ... → collapsed to TemplateSection(name="단어", is_list=True)
|
|
376
|
+
"""
|
|
377
|
+
from docpilot.mapping.base import TemplateSection
|
|
378
|
+
|
|
379
|
+
suffix = template_path.suffix.lower()
|
|
380
|
+
|
|
381
|
+
if suffix == ".hwpx":
|
|
382
|
+
with zipfile.ZipFile(template_path, "r") as zf:
|
|
383
|
+
names = zf.namelist()
|
|
384
|
+
candidates = [n for n in names if n.endswith("content.hml")]
|
|
385
|
+
if not candidates:
|
|
386
|
+
candidates = [n for n in names if n.endswith("section0.xml")]
|
|
387
|
+
if not candidates:
|
|
388
|
+
return []
|
|
389
|
+
text = "".join(
|
|
390
|
+
zf.read(c).decode("utf-8", errors="ignore") for c in candidates
|
|
391
|
+
)
|
|
392
|
+
elif suffix == ".docx":
|
|
393
|
+
with zipfile.ZipFile(template_path, "r") as zf:
|
|
394
|
+
text = zf.read("word/document.xml").decode("utf-8", errors="ignore")
|
|
395
|
+
elif suffix == ".pdf":
|
|
396
|
+
try:
|
|
397
|
+
import pdfplumber
|
|
398
|
+
with pdfplumber.open(template_path) as pdf:
|
|
399
|
+
text = "\n".join(p.extract_text() or "" for p in pdf.pages)
|
|
400
|
+
except Exception:
|
|
401
|
+
return []
|
|
402
|
+
else:
|
|
403
|
+
return []
|
|
404
|
+
|
|
405
|
+
# Collect all keys in first-seen order
|
|
406
|
+
all_keys: list[str] = []
|
|
407
|
+
seen_keys: set[str] = set()
|
|
408
|
+
optional_keys: set[str] = set()
|
|
409
|
+
dynamic_list_keys: set[str] = set() # {{?key?}}
|
|
410
|
+
|
|
411
|
+
for m in _DYNAMIC_LIST_RE.finditer(text):
|
|
412
|
+
dynamic_list_keys.add(m.group(1))
|
|
413
|
+
|
|
414
|
+
for m in _OPTIONAL_PLACEHOLDER_RE.finditer(text):
|
|
415
|
+
optional_keys.add(m.group(1))
|
|
416
|
+
|
|
417
|
+
for m in _PLACEHOLDER_RE.finditer(text):
|
|
418
|
+
key = m.group(1)
|
|
419
|
+
if key not in seen_keys:
|
|
420
|
+
all_keys.append(key)
|
|
421
|
+
seen_keys.add(key)
|
|
422
|
+
|
|
423
|
+
# Detect numbered optional groups: {{?단어1}}, {{?단어2}}, ... → base "단어"
|
|
424
|
+
numbered_groups: dict[str, int] = {} # base → max N
|
|
425
|
+
numbered_first_key: dict[str, str] = {} # base → first raw key (for style_hint lookup)
|
|
426
|
+
for key in all_keys:
|
|
427
|
+
if key in optional_keys and key not in dynamic_list_keys:
|
|
428
|
+
m = _NUMBERED_GROUP_RE.match(key)
|
|
429
|
+
if m:
|
|
430
|
+
base = m.group(1) + m.group(3)
|
|
431
|
+
n = int(m.group(2))
|
|
432
|
+
if base not in numbered_groups:
|
|
433
|
+
numbered_groups[base] = n
|
|
434
|
+
numbered_first_key[base] = key
|
|
435
|
+
else:
|
|
436
|
+
numbered_groups[base] = max(numbered_groups[base], n)
|
|
437
|
+
|
|
438
|
+
# Build TemplateSection list, collapsing numbered optional groups
|
|
439
|
+
result: list[TemplateSection] = []
|
|
440
|
+
emitted: set[str] = set()
|
|
441
|
+
|
|
442
|
+
for key in all_keys:
|
|
443
|
+
if key in dynamic_list_keys:
|
|
444
|
+
# {{?key?}} → dynamic list (count determined by LLM from source data)
|
|
445
|
+
if key not in emitted:
|
|
446
|
+
emitted.add(key)
|
|
447
|
+
result.append(TemplateSection(name=key, is_list=True, group_max=0))
|
|
448
|
+
elif key in optional_keys:
|
|
449
|
+
m = _NUMBERED_GROUP_RE.match(key)
|
|
450
|
+
if m:
|
|
451
|
+
base = m.group(1) + m.group(3)
|
|
452
|
+
if base not in emitted:
|
|
453
|
+
emitted.add(base)
|
|
454
|
+
result.append(TemplateSection(
|
|
455
|
+
name=base,
|
|
456
|
+
optional=True,
|
|
457
|
+
is_list=True,
|
|
458
|
+
group_max=numbered_groups[base],
|
|
459
|
+
))
|
|
460
|
+
continue
|
|
461
|
+
if key not in emitted:
|
|
462
|
+
emitted.add(key)
|
|
463
|
+
result.append(TemplateSection(name=key, optional=True))
|
|
464
|
+
else:
|
|
465
|
+
if key not in emitted:
|
|
466
|
+
emitted.add(key)
|
|
467
|
+
result.append(TemplateSection(name=key))
|
|
468
|
+
|
|
469
|
+
return result
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def convert_to_list_placeholder(
|
|
473
|
+
path: str | Path,
|
|
474
|
+
keys: list[str] | str,
|
|
475
|
+
output: str | Path | None = None,
|
|
476
|
+
) -> Path:
|
|
477
|
+
"""
|
|
478
|
+
Convert {{key}} placeholders to {{?key?}} (dynamic list) in an existing template.
|
|
479
|
+
|
|
480
|
+
path: source .hwpx or .docx template
|
|
481
|
+
keys: placeholder name(s) to convert (single string or list)
|
|
482
|
+
output: destination path; if None, overwrites the source file in-place
|
|
483
|
+
|
|
484
|
+
Returns the output path.
|
|
485
|
+
"""
|
|
486
|
+
path = Path(path)
|
|
487
|
+
output_path = Path(output) if output is not None else path
|
|
488
|
+
|
|
489
|
+
if isinstance(keys, str):
|
|
490
|
+
keys = [keys]
|
|
491
|
+
if not keys:
|
|
492
|
+
return output_path
|
|
493
|
+
|
|
494
|
+
ext = path.suffix.lower()
|
|
495
|
+
if ext == ".hwpx":
|
|
496
|
+
_convert_hwpx_placeholders(path, keys, output_path)
|
|
497
|
+
elif ext == ".docx":
|
|
498
|
+
_convert_docx_placeholders(path, keys, output_path)
|
|
499
|
+
else:
|
|
500
|
+
raise DocPilotError(
|
|
501
|
+
f"Unsupported format '{ext}'",
|
|
502
|
+
detail="Supported: .hwpx, .docx",
|
|
503
|
+
)
|
|
504
|
+
return output_path
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def _convert_hwpx_placeholders(path: Path, keys: list[str], output: Path) -> None:
|
|
508
|
+
import io
|
|
509
|
+
|
|
510
|
+
with zipfile.ZipFile(path, "r") as zf_in:
|
|
511
|
+
names = zf_in.namelist()
|
|
512
|
+
buf = io.BytesIO()
|
|
513
|
+
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf_out:
|
|
514
|
+
for name in names:
|
|
515
|
+
data = zf_in.read(name)
|
|
516
|
+
if name.endswith("section0.xml"):
|
|
517
|
+
text = data.decode("utf-8")
|
|
518
|
+
for key in keys:
|
|
519
|
+
text = re.sub(
|
|
520
|
+
r"\{\{" + re.escape(key) + r"\}\}",
|
|
521
|
+
"{{?" + key + "?}}",
|
|
522
|
+
text,
|
|
523
|
+
)
|
|
524
|
+
data = text.encode("utf-8")
|
|
525
|
+
compress = zipfile.ZIP_STORED if name == "mimetype" else zipfile.ZIP_DEFLATED
|
|
526
|
+
zf_out.writestr(name, data, compress_type=compress)
|
|
527
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
528
|
+
output.write_bytes(buf.getvalue())
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def _convert_docx_placeholders(path: Path, keys: list[str], output: Path) -> None:
|
|
532
|
+
try:
|
|
533
|
+
import docx
|
|
534
|
+
except ImportError as e:
|
|
535
|
+
raise DocPilotError("python-docx required: pip install python-docx") from e
|
|
536
|
+
|
|
537
|
+
pattern = re.compile(
|
|
538
|
+
r"\{\{(" + "|".join(re.escape(k) for k in keys) + r")\}\}"
|
|
539
|
+
)
|
|
540
|
+
|
|
541
|
+
doc = docx.Document(str(path))
|
|
542
|
+
|
|
543
|
+
def _convert_para(para) -> None:
|
|
544
|
+
full_text = "".join(run.text for run in para.runs)
|
|
545
|
+
if not pattern.search(full_text):
|
|
546
|
+
return
|
|
547
|
+
new_text = pattern.sub(lambda m: "{{?" + m.group(1) + "?}}", full_text)
|
|
548
|
+
if para.runs:
|
|
549
|
+
para.runs[0].text = new_text
|
|
550
|
+
for run in para.runs[1:]:
|
|
551
|
+
run.text = ""
|
|
552
|
+
|
|
553
|
+
for para in doc.paragraphs:
|
|
554
|
+
_convert_para(para)
|
|
555
|
+
for table in doc.tables:
|
|
556
|
+
for row in table.rows:
|
|
557
|
+
for cell in row.cells:
|
|
558
|
+
for para in cell.paragraphs:
|
|
559
|
+
_convert_para(para)
|
|
560
|
+
|
|
561
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
562
|
+
doc.save(str(output))
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def _infer_sections_from_content(content: str, mapper) -> list[str]:
|
|
566
|
+
"""Use LLM to infer fillable section names from a document's text content."""
|
|
567
|
+
import json
|
|
568
|
+
from docpilot.mapping.base import TemplateSection
|
|
569
|
+
|
|
570
|
+
mapping = mapper.map(
|
|
571
|
+
content=content[:4000],
|
|
572
|
+
sections=[
|
|
573
|
+
TemplateSection(
|
|
574
|
+
name="sections",
|
|
575
|
+
description=(
|
|
576
|
+
"위 문서 구조를 분석해 채워야 할 섹션 이름 목록을 추출하세요. "
|
|
577
|
+
"헤딩·제목·표 컬럼·항목명 등 구조적 요소를 기반으로 하되 "
|
|
578
|
+
"내용이 들어가야 할 곳의 레이블을 우선하세요. "
|
|
579
|
+
"결과는 JSON 배열 문자열로만 반환하세요: [\"섹션1\", \"섹션2\", ...]"
|
|
580
|
+
),
|
|
581
|
+
)
|
|
582
|
+
],
|
|
583
|
+
)
|
|
584
|
+
raw = mapping.sections.get("sections", "[]")
|
|
585
|
+
try:
|
|
586
|
+
sections = json.loads(raw)
|
|
587
|
+
if isinstance(sections, list):
|
|
588
|
+
return [str(s) for s in sections if s]
|
|
589
|
+
except (json.JSONDecodeError, ValueError):
|
|
590
|
+
pass
|
|
591
|
+
return []
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
def _apply_reference_mode(template_path: Path, mapper) -> tuple[Path, list]:
|
|
595
|
+
"""
|
|
596
|
+
Infer section structure via LLM from a placeholder-less reference document, then
|
|
597
|
+
inject {{placeholder}}s into a temp copy of it. Costs one extra LLM call (structure
|
|
598
|
+
inference) beyond the normal content-mapping call.
|
|
599
|
+
|
|
600
|
+
Returns (new_template_path, sections). Raises DocPilotError if the content can't be
|
|
601
|
+
read or the LLM fails to infer any section names.
|
|
602
|
+
"""
|
|
603
|
+
from docpilot.mapping.base import TemplateSection
|
|
604
|
+
from docpilot.template_generator.generator import _build_template, _build_docx_template
|
|
605
|
+
|
|
606
|
+
ref_content = _ingest_instructions_doc(template_path)
|
|
607
|
+
if not ref_content:
|
|
608
|
+
raise DocPilotError(
|
|
609
|
+
"No {{placeholders}} found and could not read template content",
|
|
610
|
+
detail=str(template_path),
|
|
611
|
+
)
|
|
612
|
+
placeholder_names = _infer_sections_from_content(ref_content, mapper)
|
|
613
|
+
if not placeholder_names:
|
|
614
|
+
raise DocPilotError(
|
|
615
|
+
"No {{placeholders}} found and LLM could not infer sections",
|
|
616
|
+
detail=str(template_path),
|
|
617
|
+
)
|
|
618
|
+
|
|
619
|
+
ref_ext = template_path.suffix.lower()
|
|
620
|
+
tpl_suffix = ref_ext if ref_ext in (".hwpx", ".docx") else ".hwpx"
|
|
621
|
+
tmp = tempfile.NamedTemporaryFile(suffix=tpl_suffix, delete=False)
|
|
622
|
+
tmp.close()
|
|
623
|
+
tmp_tpl = Path(tmp.name)
|
|
624
|
+
atexit.register(lambda p=tmp_tpl: p.unlink(missing_ok=True))
|
|
625
|
+
|
|
626
|
+
if ref_ext == ".docx":
|
|
627
|
+
_build_docx_template(template_path, placeholder_names, tmp_tpl)
|
|
628
|
+
elif ref_ext == ".hwpx":
|
|
629
|
+
_build_template(template_path, placeholder_names, tmp_tpl)
|
|
630
|
+
else:
|
|
631
|
+
# Non-buildable format (PDF, TXT …): use built-in report as base
|
|
632
|
+
_build_template(_assemble_builtin_hwpx("report"), placeholder_names, tmp_tpl)
|
|
633
|
+
|
|
634
|
+
return tmp_tpl, [TemplateSection(name=s) for s in placeholder_names]
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def _sidecar_source(template: str | Path) -> Path:
|
|
638
|
+
"""
|
|
639
|
+
Path to resolve sidecar.json against.
|
|
640
|
+
|
|
641
|
+
Built-in templates are resolved to a temp-assembled HWPX copy for actual reading/
|
|
642
|
+
building (see _assemble_builtin_hwpx), but their sidecar.json lives in the original
|
|
643
|
+
templates/<name>/ directory — so sidecar lookup must use *template*, not the resolved
|
|
644
|
+
template_path, or it silently finds nothing.
|
|
645
|
+
"""
|
|
646
|
+
path = Path(template)
|
|
647
|
+
if path.exists():
|
|
648
|
+
return path
|
|
649
|
+
if (_BUILTIN_TEMPLATES / str(template)).is_dir():
|
|
650
|
+
return _BUILTIN_TEMPLATES / str(template)
|
|
651
|
+
return path
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
def _resolve_template_path(template: str | Path, embed_fn=None) -> tuple[Path, dict]:
|
|
655
|
+
"""Return (template_path, sections_meta). sections_meta is non-empty only for DB templates."""
|
|
656
|
+
path = Path(template)
|
|
657
|
+
if path.exists():
|
|
658
|
+
return path, {}
|
|
659
|
+
|
|
660
|
+
name = str(template)
|
|
661
|
+
if (_BUILTIN_TEMPLATES / name).is_dir():
|
|
662
|
+
return _assemble_builtin_hwpx(name), {}
|
|
663
|
+
|
|
664
|
+
for ext in (".hwpx", ".docx", ".pdf"):
|
|
665
|
+
candidate = _BUILTIN_TEMPLATES / f"{template}{ext}"
|
|
666
|
+
if candidate.exists():
|
|
667
|
+
return candidate, {}
|
|
668
|
+
local = Path("templates") / f"{template}{ext}"
|
|
669
|
+
if local.exists():
|
|
670
|
+
return local, {}
|
|
671
|
+
|
|
672
|
+
# DB lookup by name
|
|
673
|
+
try:
|
|
674
|
+
from docpilot.db import template_store
|
|
675
|
+
records = template_store.search(name, embed_fn=embed_fn, top_k=1, fallback=False)
|
|
676
|
+
if records:
|
|
677
|
+
record = records[0]
|
|
678
|
+
section_xml_path = Path(record.path)
|
|
679
|
+
if section_xml_path.exists():
|
|
680
|
+
from docpilot.builder.hwpx_dynamic_builder import pack_hwpx
|
|
681
|
+
_base_header = _BUILTIN_TEMPLATES / "base" / "Contents" / "header.xml"
|
|
682
|
+
header = (
|
|
683
|
+
Path(record.header_xml)
|
|
684
|
+
if record.header_xml and Path(record.header_xml).exists()
|
|
685
|
+
else _base_header
|
|
686
|
+
)
|
|
687
|
+
tmp = tempfile.NamedTemporaryFile(suffix=".hwpx", delete=False)
|
|
688
|
+
tmp.close()
|
|
689
|
+
tmp_hwpx = Path(tmp.name)
|
|
690
|
+
atexit.register(lambda p=tmp_hwpx: p.unlink(missing_ok=True))
|
|
691
|
+
pack_hwpx(header, section_xml_path.read_text(encoding="utf-8"), tmp_hwpx)
|
|
692
|
+
sections_meta = (record.metadata_ or {}).get("sections", {})
|
|
693
|
+
return tmp_hwpx, sections_meta
|
|
694
|
+
except Exception:
|
|
695
|
+
pass
|
|
696
|
+
|
|
697
|
+
raise DocPilotError("Template not found", detail=str(template))
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
def _resolve_builder(output: Path):
|
|
701
|
+
from docpilot.builder import HwpxBuilder, PdfBuilder, DocxBuilder
|
|
702
|
+
|
|
703
|
+
match output.suffix.lower():
|
|
704
|
+
case ".hwpx":
|
|
705
|
+
return HwpxBuilder()
|
|
706
|
+
case ".pdf":
|
|
707
|
+
return PdfBuilder()
|
|
708
|
+
case ".docx":
|
|
709
|
+
return DocxBuilder()
|
|
710
|
+
case _:
|
|
711
|
+
raise BuilderError(
|
|
712
|
+
f"Unsupported output format '{output.suffix}'",
|
|
713
|
+
detail="Supported: .hwpx, .pdf, .docx",
|
|
714
|
+
)
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
def _merge_section_meta(sections: list, template_path: Path, sidecar, db_sections_meta: dict) -> list:
|
|
718
|
+
"""Apply sidecar.json rules, DB sections_meta, and template style hints onto *sections* in place."""
|
|
719
|
+
if sidecar is not None and sidecar.sections:
|
|
720
|
+
_sc_map = {s.name: s for s in sidecar.sections}
|
|
721
|
+
for _sec in sections:
|
|
722
|
+
_sc = _sc_map.get(_sec.name)
|
|
723
|
+
if _sc is None:
|
|
724
|
+
continue
|
|
725
|
+
if not _sec.description and _sc.description:
|
|
726
|
+
_sec.description = _sc.description
|
|
727
|
+
if not _sec.rule and _sc.rule:
|
|
728
|
+
_sec.rule = _sc.rule
|
|
729
|
+
if not _sec.style_hint and _sc.style_hint:
|
|
730
|
+
_sec.style_hint = _sc.style_hint
|
|
731
|
+
if _sc.is_list:
|
|
732
|
+
_sec.is_list = True
|
|
733
|
+
if _sc.optional:
|
|
734
|
+
_sec.optional = True
|
|
735
|
+
if _sc.group_max > 0 and _sec.group_max == 0:
|
|
736
|
+
_sec.group_max = _sc.group_max
|
|
737
|
+
|
|
738
|
+
if db_sections_meta:
|
|
739
|
+
for _sec in sections:
|
|
740
|
+
_dm = db_sections_meta.get(_sec.name, {})
|
|
741
|
+
if not _dm:
|
|
742
|
+
continue
|
|
743
|
+
if not _sec.description and _dm.get("description"):
|
|
744
|
+
_sec.description = _dm["description"]
|
|
745
|
+
if not _sec.rule and _dm.get("rule"):
|
|
746
|
+
_sec.rule = _dm["rule"]
|
|
747
|
+
if not _sec.style_hint and _dm.get("style_hint"):
|
|
748
|
+
_sec.style_hint = _dm["style_hint"]
|
|
749
|
+
if not _sec.is_list and _dm.get("is_list"):
|
|
750
|
+
_sec.is_list = True
|
|
751
|
+
if not _sec.optional and _dm.get("optional"):
|
|
752
|
+
_sec.optional = True
|
|
753
|
+
if _sec.group_max == 0 and _dm.get("group_max", 0) > 0:
|
|
754
|
+
_sec.group_max = _dm["group_max"]
|
|
755
|
+
|
|
756
|
+
style_hints: dict[str, str] = {}
|
|
757
|
+
_tpl_ext = template_path.suffix.lower()
|
|
758
|
+
if _tpl_ext == ".hwpx":
|
|
759
|
+
from docpilot.builder.hwpx_analyzer import extract_style_hints
|
|
760
|
+
style_hints = extract_style_hints(template_path)
|
|
761
|
+
elif _tpl_ext == ".docx":
|
|
762
|
+
from docpilot.builder.docx_analyzer import extract_style_hints
|
|
763
|
+
style_hints = extract_style_hints(template_path)
|
|
764
|
+
|
|
765
|
+
for s in sections:
|
|
766
|
+
if s.style_hint:
|
|
767
|
+
continue
|
|
768
|
+
if s.is_list:
|
|
769
|
+
for candidate in [f"{s.name}1", s.name]:
|
|
770
|
+
hint = style_hints.get(candidate, "")
|
|
771
|
+
if hint:
|
|
772
|
+
s.style_hint = hint
|
|
773
|
+
break
|
|
774
|
+
else:
|
|
775
|
+
s.style_hint = style_hints.get(s.name, "")
|
|
776
|
+
|
|
777
|
+
return sections
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
def _resolve_sections(
|
|
781
|
+
template: str | Path,
|
|
782
|
+
template_path: Path,
|
|
783
|
+
db_sections_meta: dict,
|
|
784
|
+
mapper=None,
|
|
785
|
+
) -> tuple[Path, list, str]:
|
|
786
|
+
"""
|
|
787
|
+
Extract + merge section metadata for a template.
|
|
788
|
+
|
|
789
|
+
template: the original name/path passed by the caller — used to locate sidecar.json,
|
|
790
|
+
since built-in templates resolve to a temp-assembled file (see _sidecar_source).
|
|
791
|
+
template_path: the resolved, readable template file (from _resolve_template_path).
|
|
792
|
+
mapper: if given, a placeholder-less reference document triggers LLM-based section
|
|
793
|
+
inference (Reference Mode — one extra LLM call) instead of raising. If None
|
|
794
|
+
(default; used by describe_template()/fill_template(), which stay LLM-free),
|
|
795
|
+
raises DocPilotError instead.
|
|
796
|
+
|
|
797
|
+
Returns (template_path, sections, instructions). template_path is reassigned only when
|
|
798
|
+
Reference Mode ran (it then points at the generated temp template).
|
|
799
|
+
"""
|
|
800
|
+
sections = _extract_placeholder_sections(template_path)
|
|
801
|
+
if not sections:
|
|
802
|
+
if mapper is None:
|
|
803
|
+
raise DocPilotError(
|
|
804
|
+
"템플릿에서 {{placeholder}}를 찾을 수 없습니다.",
|
|
805
|
+
detail=(
|
|
806
|
+
f"{template_path} — 플레이스홀더가 없는 참조 문서는 generate_template()으로 "
|
|
807
|
+
"먼저 템플릿을 만들거나, LLM 추론을 지원하는 generate()/generate_from_content()를 "
|
|
808
|
+
"쓰세요."
|
|
809
|
+
),
|
|
810
|
+
)
|
|
811
|
+
template_path, sections = _apply_reference_mode(template_path, mapper)
|
|
812
|
+
|
|
813
|
+
from docpilot.mapping.sidecar import load_sidecar
|
|
814
|
+
sidecar = load_sidecar(_sidecar_source(template))
|
|
815
|
+
sections = _merge_section_meta(sections, template_path, sidecar, db_sections_meta)
|
|
816
|
+
instructions = sidecar.instructions if sidecar is not None else ""
|
|
817
|
+
return template_path, sections, instructions
|
|
818
|
+
|
|
819
|
+
|
|
820
|
+
def describe_template(template: str | Path) -> dict:
|
|
821
|
+
"""
|
|
822
|
+
Return a template's fillable structure — no LLM/RAG call.
|
|
823
|
+
|
|
824
|
+
Use this before fill_template() to see exact section keys, per-section rules/
|
|
825
|
+
descriptions, and a ready-to-copy example dict shape.
|
|
826
|
+
"""
|
|
827
|
+
template_path, db_sections_meta = _resolve_template_path(template)
|
|
828
|
+
template_path, sections, instructions = _resolve_sections(template, template_path, db_sections_meta)
|
|
829
|
+
|
|
830
|
+
example: dict[str, str | list[str]] = {}
|
|
831
|
+
for s in sections:
|
|
832
|
+
if s.is_list:
|
|
833
|
+
example[s.name] = ["항목1", "항목2"]
|
|
834
|
+
elif s.optional:
|
|
835
|
+
example[s.name] = ""
|
|
836
|
+
else:
|
|
837
|
+
example[s.name] = "..."
|
|
838
|
+
|
|
839
|
+
return {
|
|
840
|
+
"template": str(template_path),
|
|
841
|
+
"instructions": instructions,
|
|
842
|
+
"sections": [
|
|
843
|
+
{
|
|
844
|
+
"name": s.name,
|
|
845
|
+
"description": s.description,
|
|
846
|
+
"rule": s.rule,
|
|
847
|
+
"style_hint": s.style_hint,
|
|
848
|
+
"optional": s.optional,
|
|
849
|
+
"is_list": s.is_list,
|
|
850
|
+
"group_max": s.group_max,
|
|
851
|
+
}
|
|
852
|
+
for s in sections
|
|
853
|
+
],
|
|
854
|
+
"example": example,
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
|
|
858
|
+
def fill_template(
|
|
859
|
+
template: str | Path,
|
|
860
|
+
sections: dict[str, str | list[str]],
|
|
861
|
+
output: str | Path,
|
|
862
|
+
) -> Path:
|
|
863
|
+
"""
|
|
864
|
+
Fill an already-authored section dict into a template — pure mechanical build,
|
|
865
|
+
no LLM/RAG call. Validates keys against the template's actual placeholders first,
|
|
866
|
+
and warns if any placeholder is left unfilled in the output.
|
|
867
|
+
"""
|
|
868
|
+
template_path, db_sections_meta = _resolve_template_path(template)
|
|
869
|
+
template_path, resolved, _ = _resolve_sections(template, template_path, db_sections_meta)
|
|
870
|
+
resolved_names = {s.name for s in resolved}
|
|
871
|
+
|
|
872
|
+
required_missing = [
|
|
873
|
+
s.name for s in resolved
|
|
874
|
+
if not s.optional and not s.is_list and not str(sections.get(s.name, "")).strip()
|
|
875
|
+
]
|
|
876
|
+
unknown_keys = [k for k in sections if k not in resolved_names]
|
|
877
|
+
if required_missing or unknown_keys:
|
|
878
|
+
detail_lines = []
|
|
879
|
+
if required_missing:
|
|
880
|
+
detail_lines.append(f"누락된 필수 섹션: {', '.join(required_missing)}")
|
|
881
|
+
if unknown_keys:
|
|
882
|
+
detail_lines.append(f"템플릿에 없는 키 (오탈자 의심): {', '.join(unknown_keys)}")
|
|
883
|
+
raise MappingError(
|
|
884
|
+
"fill_template 입력 검증 실패 — describe_template()으로 정확한 섹션 키를 확인하세요.",
|
|
885
|
+
detail="\n".join(detail_lines),
|
|
886
|
+
)
|
|
887
|
+
|
|
888
|
+
fill: dict[str, str | list[str]] = {}
|
|
889
|
+
for s in resolved:
|
|
890
|
+
val = sections.get(s.name, [] if s.is_list else "")
|
|
891
|
+
if s.is_list and isinstance(val, str):
|
|
892
|
+
val = [v.strip() for v in val.split("\n") if v.strip()]
|
|
893
|
+
fill[s.name] = val
|
|
894
|
+
|
|
895
|
+
output_path = Path(output)
|
|
896
|
+
builder = _resolve_builder(output_path)
|
|
897
|
+
out_path = builder.build(template_path, fill, output_path)
|
|
898
|
+
|
|
899
|
+
if out_path.suffix.lower() == ".hwpx":
|
|
900
|
+
_validate_hwpx(out_path)
|
|
901
|
+
|
|
902
|
+
leftover = _extract_placeholders(out_path)
|
|
903
|
+
if leftover:
|
|
904
|
+
import warnings
|
|
905
|
+
warnings.warn(
|
|
906
|
+
f"생성된 문서에 채워지지 않은 플레이스홀더가 남아 있습니다: {', '.join(leftover)}",
|
|
907
|
+
stacklevel=2,
|
|
908
|
+
)
|
|
909
|
+
|
|
910
|
+
return out_path
|
|
911
|
+
|
|
912
|
+
|
|
913
|
+
class DocPilot:
|
|
914
|
+
"""
|
|
915
|
+
Main entry point for docpilot.
|
|
916
|
+
|
|
917
|
+
pilot = DocPilot(llm="claude")
|
|
918
|
+
pilot.index(data_folder="./data")
|
|
919
|
+
pilot.generate(data_folder="./data", template="research_report", output="./out.hwpx")
|
|
920
|
+
"""
|
|
921
|
+
|
|
922
|
+
def __init__(
|
|
923
|
+
self,
|
|
924
|
+
llm: str | None = None,
|
|
925
|
+
api_key: str | None = None,
|
|
926
|
+
model: str | None = None,
|
|
927
|
+
base_url: str | None = None,
|
|
928
|
+
database_url: str | None = None,
|
|
929
|
+
embed_fn=None,
|
|
930
|
+
use_reranker: bool = False,
|
|
931
|
+
) -> None:
|
|
932
|
+
self._llm = llm or os.environ.get("DOCPILOT_LLM", "claude")
|
|
933
|
+
self._api_key = api_key
|
|
934
|
+
if embed_fn is None:
|
|
935
|
+
from docpilot.search.embedding import default_embed_fn
|
|
936
|
+
embed_fn = default_embed_fn() # None when sentence-transformers not installed
|
|
937
|
+
self._embed_fn = embed_fn
|
|
938
|
+
base_mapper = self._build_mapper(self._llm, api_key, model, base_url)
|
|
939
|
+
|
|
940
|
+
from docpilot.mapping.rag import RagMapper
|
|
941
|
+
self._mapper = base_mapper
|
|
942
|
+
self._rag_mapper = RagMapper(base_mapper, embed_fn=embed_fn, use_reranker=use_reranker)
|
|
943
|
+
|
|
944
|
+
from docpilot.db import client as db_client
|
|
945
|
+
db_client.init(database_url)
|
|
946
|
+
db_client.create_tables()
|
|
947
|
+
|
|
948
|
+
def index(self, data_folder: str | Path) -> list[int]:
|
|
949
|
+
"""Ingest and index all supported files in a folder."""
|
|
950
|
+
from docpilot.db import indexer
|
|
951
|
+
return indexer.index_folder(data_folder, embed_fn=self._embed_fn)
|
|
952
|
+
|
|
953
|
+
def search(
|
|
954
|
+
self,
|
|
955
|
+
query: str,
|
|
956
|
+
top_k: int = 10,
|
|
957
|
+
mode: str = "hybrid",
|
|
958
|
+
filters=None,
|
|
959
|
+
or_fallback: bool = True,
|
|
960
|
+
highlight: bool = False,
|
|
961
|
+
group_by_doc: bool = False,
|
|
962
|
+
):
|
|
963
|
+
"""
|
|
964
|
+
Search indexed documents.
|
|
965
|
+
|
|
966
|
+
mode:
|
|
967
|
+
``"hybrid"`` — BM25 + Vector fused with RRF (default; falls back to BM25 when no embed_fn).
|
|
968
|
+
``"bm25"`` — Morpheme FTS5 / BM25 only.
|
|
969
|
+
``"vector"`` — Vector similarity only (embed_fn required).
|
|
970
|
+
``"exact"`` — ILIKE keyword match.
|
|
971
|
+
filters:
|
|
972
|
+
``SearchFilter`` instance for source/MIME/metadata/date constraints.
|
|
973
|
+
or_fallback:
|
|
974
|
+
For BM25/hybrid — retry with OR logic when AND returns no results.
|
|
975
|
+
highlight:
|
|
976
|
+
If True, populate each result's ``.highlights`` spans for *query* terms.
|
|
977
|
+
Render the marked-up text with ``docpilot.search.highlight.render(result)``.
|
|
978
|
+
group_by_doc:
|
|
979
|
+
If True, aggregate chunk-level results into per-document ``DocumentResult``
|
|
980
|
+
objects — returns ``list[DocumentResult]`` instead of ``list[SearchResult]``.
|
|
981
|
+
|
|
982
|
+
"bm25"/"hybrid" modes fall back to "exact" and emit a ``UserWarning`` when
|
|
983
|
+
kiwipiepy isn't installed, instead of raising.
|
|
984
|
+
"""
|
|
985
|
+
import warnings
|
|
986
|
+
from docpilot.exceptions import SearchError
|
|
987
|
+
from docpilot.search import exact, morpheme
|
|
988
|
+
|
|
989
|
+
try:
|
|
990
|
+
match mode:
|
|
991
|
+
case "exact":
|
|
992
|
+
results = exact.search(query, top_k=top_k, filters=filters)
|
|
993
|
+
case "bm25":
|
|
994
|
+
results = morpheme.search(query, top_k=top_k, or_fallback=or_fallback, filters=filters)
|
|
995
|
+
case "vector":
|
|
996
|
+
if self._embed_fn is None:
|
|
997
|
+
raise SearchError(
|
|
998
|
+
"vector 모드는 embed_fn 설정이 필요합니다",
|
|
999
|
+
detail="DOCPILOT_EMBED 환경변수 또는 DocPilot(embed_fn=...) 설정을 확인하세요",
|
|
1000
|
+
)
|
|
1001
|
+
from docpilot.search import embedding
|
|
1002
|
+
results = embedding.search(query, embed_fn=self._embed_fn, top_k=top_k, filters=filters)
|
|
1003
|
+
case _: # "hybrid"
|
|
1004
|
+
from docpilot.search.hybrid import hybrid
|
|
1005
|
+
results = hybrid(
|
|
1006
|
+
query,
|
|
1007
|
+
embed_fn=self._embed_fn,
|
|
1008
|
+
top_k=top_k,
|
|
1009
|
+
filters=filters,
|
|
1010
|
+
or_fallback=or_fallback,
|
|
1011
|
+
)
|
|
1012
|
+
except SearchError as e:
|
|
1013
|
+
if mode in ("bm25", "hybrid") and "kiwipiepy" in str(e):
|
|
1014
|
+
warnings.warn(
|
|
1015
|
+
f"kiwipiepy 미설치로 exact 검색으로 대체합니다 (형태소 검색을 쓰려면 "
|
|
1016
|
+
f"pip install kiwipiepy — 기본 설치에 포함되므로 보통은 발생하지 않는 상황입니다): {e}",
|
|
1017
|
+
stacklevel=2,
|
|
1018
|
+
)
|
|
1019
|
+
results = exact.search(query, top_k=top_k, filters=filters)
|
|
1020
|
+
else:
|
|
1021
|
+
raise
|
|
1022
|
+
|
|
1023
|
+
if highlight:
|
|
1024
|
+
from docpilot.search import highlight as highlight_fn
|
|
1025
|
+
results = [highlight_fn(r, query) for r in results]
|
|
1026
|
+
|
|
1027
|
+
if group_by_doc:
|
|
1028
|
+
from docpilot.search import group_by_document
|
|
1029
|
+
return group_by_document(results, top_chunks=3, score="max")
|
|
1030
|
+
|
|
1031
|
+
return results
|
|
1032
|
+
|
|
1033
|
+
def generate(
|
|
1034
|
+
self,
|
|
1035
|
+
data_folder: str | Path,
|
|
1036
|
+
template: str | Path,
|
|
1037
|
+
output: str | Path,
|
|
1038
|
+
reindex: bool = False,
|
|
1039
|
+
extra_instructions: str | None = None,
|
|
1040
|
+
instructions_doc: str | Path | None = None,
|
|
1041
|
+
top_k: int = 10,
|
|
1042
|
+
) -> GenerateResult:
|
|
1043
|
+
"""
|
|
1044
|
+
Full pipeline: index → search → map → build.
|
|
1045
|
+
|
|
1046
|
+
template: file path (.hwpx/.docx/.pdf) or built-in template name.
|
|
1047
|
+
output: destination file path — extension determines builder.
|
|
1048
|
+
reindex: re-index data_folder even if already indexed.
|
|
1049
|
+
extra_instructions: additional writing guidelines injected into the LLM prompt.
|
|
1050
|
+
Use this to pass document-specific rules such as proposal
|
|
1051
|
+
writing guidelines extracted from an RFP.
|
|
1052
|
+
"""
|
|
1053
|
+
template_path, _db_sections_meta = self._resolve_template(template)
|
|
1054
|
+
output_path = Path(output)
|
|
1055
|
+
|
|
1056
|
+
if template_path.suffix.lower() == ".docx" and output_path.suffix.lower() == ".hwpx":
|
|
1057
|
+
from docpilot.builder.hwp_convert import convert_to_hwpx
|
|
1058
|
+
_tmp = tempfile.NamedTemporaryFile(suffix=".hwpx", delete=False)
|
|
1059
|
+
_tmp.close()
|
|
1060
|
+
_tmp_path = Path(_tmp.name)
|
|
1061
|
+
atexit.register(lambda p=_tmp_path: p.unlink(missing_ok=True))
|
|
1062
|
+
template_path = convert_to_hwpx(template_path, _tmp_path)
|
|
1063
|
+
|
|
1064
|
+
from docpilot.mapping.sidecar import load_sidecar
|
|
1065
|
+
_sidecar = load_sidecar(_sidecar_source(template))
|
|
1066
|
+
extra_instructions = _resolve_instructions(
|
|
1067
|
+
extra_instructions,
|
|
1068
|
+
_sidecar.instructions if _sidecar is not None else "",
|
|
1069
|
+
instructions_doc,
|
|
1070
|
+
)
|
|
1071
|
+
|
|
1072
|
+
from docpilot.db import indexer
|
|
1073
|
+
doc_ids = indexer.index_folder(data_folder, embed_fn=self._embed_fn, force=reindex)
|
|
1074
|
+
if not doc_ids:
|
|
1075
|
+
raise DocPilotError(
|
|
1076
|
+
"데이터 폴더에서 인덱싱된 문서가 없습니다.",
|
|
1077
|
+
detail=(
|
|
1078
|
+
f"{data_folder} 에서 지원하는 파일을 찾지 못했거나 "
|
|
1079
|
+
"모든 파일의 수집(ingestion)에 실패했습니다. "
|
|
1080
|
+
"지원 형식: .txt .md .csv .hwpx .hwp .docx .pdf"
|
|
1081
|
+
),
|
|
1082
|
+
)
|
|
1083
|
+
|
|
1084
|
+
template_path, sections, _ = _resolve_sections(
|
|
1085
|
+
template, template_path, _db_sections_meta, mapper=self._mapper,
|
|
1086
|
+
)
|
|
1087
|
+
|
|
1088
|
+
from docpilot.search.models import SearchFilter
|
|
1089
|
+
filters = SearchFilter(source_pattern=f"{Path(data_folder).resolve()}{os.sep}*")
|
|
1090
|
+
mapping_result = self._rag_mapper.map(sections, instructions=extra_instructions, top_k=top_k, filters=filters)
|
|
1091
|
+
|
|
1092
|
+
builder = self._build_builder(output_path)
|
|
1093
|
+
out_path = builder.build(template_path, mapping_result.sections, output_path)
|
|
1094
|
+
|
|
1095
|
+
if out_path.suffix.lower() == ".hwpx":
|
|
1096
|
+
_validate_hwpx(out_path)
|
|
1097
|
+
|
|
1098
|
+
return GenerateResult(
|
|
1099
|
+
path=out_path,
|
|
1100
|
+
model=mapping_result.model,
|
|
1101
|
+
input_tokens=mapping_result.input_tokens,
|
|
1102
|
+
output_tokens=mapping_result.output_tokens,
|
|
1103
|
+
elapsed_seconds=mapping_result.elapsed_seconds,
|
|
1104
|
+
)
|
|
1105
|
+
|
|
1106
|
+
def generate_from_content(
|
|
1107
|
+
self,
|
|
1108
|
+
content: str | list[str | Path] | list[IngestedDocument],
|
|
1109
|
+
template: str | Path,
|
|
1110
|
+
output: str | Path,
|
|
1111
|
+
extra_instructions: str | None = None,
|
|
1112
|
+
instructions_doc: str | Path | None = None,
|
|
1113
|
+
max_input_tokens: int | None = None,
|
|
1114
|
+
) -> GenerateResult:
|
|
1115
|
+
"""
|
|
1116
|
+
Fill a template from already-prepared content — no RAG retrieval, no data folder.
|
|
1117
|
+
|
|
1118
|
+
content: a finished string, a list of IngestedDocuments, or a list of file
|
|
1119
|
+
paths (ingested directly, no chunking/embedding/DB storage).
|
|
1120
|
+
template: {{placeholder}} template, or a placeholder-less reference document —
|
|
1121
|
+
the latter costs one extra LLM call to infer section structure
|
|
1122
|
+
(Reference Mode, same as generate()).
|
|
1123
|
+
max_input_tokens: if set, raises MappingError when the estimated input token count
|
|
1124
|
+
exceeds it. If unset, only warns — RAG's top_k has no equivalent
|
|
1125
|
+
here, so there's no automatic context-size limit.
|
|
1126
|
+
"""
|
|
1127
|
+
template_path, db_sections_meta = self._resolve_template(template)
|
|
1128
|
+
output_path = Path(output)
|
|
1129
|
+
|
|
1130
|
+
if template_path.suffix.lower() == ".docx" and output_path.suffix.lower() == ".hwpx":
|
|
1131
|
+
from docpilot.builder.hwp_convert import convert_to_hwpx
|
|
1132
|
+
_tmp = tempfile.NamedTemporaryFile(suffix=".hwpx", delete=False)
|
|
1133
|
+
_tmp.close()
|
|
1134
|
+
_tmp_path = Path(_tmp.name)
|
|
1135
|
+
atexit.register(lambda p=_tmp_path: p.unlink(missing_ok=True))
|
|
1136
|
+
template_path = convert_to_hwpx(template_path, _tmp_path)
|
|
1137
|
+
|
|
1138
|
+
template_path, sections, sidecar_instructions = _resolve_sections(
|
|
1139
|
+
template, template_path, db_sections_meta, mapper=self._mapper,
|
|
1140
|
+
)
|
|
1141
|
+
extra_instructions = _resolve_instructions(extra_instructions, sidecar_instructions, instructions_doc)
|
|
1142
|
+
|
|
1143
|
+
content_str = _resolve_content(content)
|
|
1144
|
+
_check_content_size(content_str, max_input_tokens)
|
|
1145
|
+
|
|
1146
|
+
mapping_result = self._mapper.map(content_str, sections, instructions=extra_instructions)
|
|
1147
|
+
|
|
1148
|
+
builder = self._build_builder(output_path)
|
|
1149
|
+
out_path = builder.build(template_path, mapping_result.sections, output_path)
|
|
1150
|
+
|
|
1151
|
+
if out_path.suffix.lower() == ".hwpx":
|
|
1152
|
+
_validate_hwpx(out_path)
|
|
1153
|
+
|
|
1154
|
+
return GenerateResult(
|
|
1155
|
+
path=out_path,
|
|
1156
|
+
model=mapping_result.model,
|
|
1157
|
+
input_tokens=mapping_result.input_tokens,
|
|
1158
|
+
output_tokens=mapping_result.output_tokens,
|
|
1159
|
+
elapsed_seconds=mapping_result.elapsed_seconds,
|
|
1160
|
+
)
|
|
1161
|
+
|
|
1162
|
+
def generate_template(
|
|
1163
|
+
self,
|
|
1164
|
+
samples: list[str | Path],
|
|
1165
|
+
output: str | Path,
|
|
1166
|
+
use_llm: bool | None = None,
|
|
1167
|
+
) -> Path:
|
|
1168
|
+
"""Generate an HWPX template from sample documents."""
|
|
1169
|
+
from docpilot.template_generator import generate
|
|
1170
|
+
return generate(
|
|
1171
|
+
samples=samples,
|
|
1172
|
+
output=output,
|
|
1173
|
+
use_llm=use_llm,
|
|
1174
|
+
llm_mapper=self._mapper if use_llm else None,
|
|
1175
|
+
)
|
|
1176
|
+
|
|
1177
|
+
def save_template(
|
|
1178
|
+
self,
|
|
1179
|
+
name: str,
|
|
1180
|
+
path: str | Path,
|
|
1181
|
+
description: str = "",
|
|
1182
|
+
tags: list[str] | None = None,
|
|
1183
|
+
sections_meta: dict | None = None,
|
|
1184
|
+
auto_sections_meta: bool = True,
|
|
1185
|
+
) -> int:
|
|
1186
|
+
"""
|
|
1187
|
+
Save an HWPX template to the DB so it can be found by name in generate().
|
|
1188
|
+
|
|
1189
|
+
Extracts section0.xml and header.xml from the .hwpx ZIP into
|
|
1190
|
+
~/.docpilot/templates/<name>/ for persistent access across sessions.
|
|
1191
|
+
|
|
1192
|
+
auto_sections_meta: if True (default) and sections_meta is None, ask the LLM to
|
|
1193
|
+
infer per-section description/rule for each placeholder — this makes a
|
|
1194
|
+
billed API call every time save_template() is called without explicit
|
|
1195
|
+
sections_meta. Pass auto_sections_meta=False or sections_meta={} to skip it
|
|
1196
|
+
(a UserWarning is also raised when the automatic call is about to run).
|
|
1197
|
+
|
|
1198
|
+
Returns the saved template record ID.
|
|
1199
|
+
"""
|
|
1200
|
+
import zipfile
|
|
1201
|
+
from docpilot.db import template_store
|
|
1202
|
+
|
|
1203
|
+
path = Path(path)
|
|
1204
|
+
if not path.exists():
|
|
1205
|
+
raise DocPilotError("Template file not found", detail=str(path))
|
|
1206
|
+
if path.suffix.lower() != ".hwpx":
|
|
1207
|
+
raise DocPilotError(
|
|
1208
|
+
"save_template() only supports .hwpx files",
|
|
1209
|
+
detail=f"Got '{path.suffix}'. Pass .docx/.pdf paths directly to generate().",
|
|
1210
|
+
)
|
|
1211
|
+
|
|
1212
|
+
store_dir = Path.home() / ".docpilot" / "templates" / name
|
|
1213
|
+
store_dir.mkdir(parents=True, exist_ok=True)
|
|
1214
|
+
section_xml_path = store_dir / "section0.xml"
|
|
1215
|
+
header_xml_path = store_dir / "header.xml"
|
|
1216
|
+
|
|
1217
|
+
with zipfile.ZipFile(path, "r") as zf:
|
|
1218
|
+
names = zf.namelist()
|
|
1219
|
+
section_candidates = [n for n in names if n.endswith("section0.xml")]
|
|
1220
|
+
if not section_candidates:
|
|
1221
|
+
raise DocPilotError("No section0.xml in HWPX", detail=str(path))
|
|
1222
|
+
section_xml_path.write_bytes(zf.read(section_candidates[0]))
|
|
1223
|
+
if "Contents/header.xml" in names:
|
|
1224
|
+
header_xml_path.write_bytes(zf.read("Contents/header.xml"))
|
|
1225
|
+
else:
|
|
1226
|
+
header_xml_path = None
|
|
1227
|
+
|
|
1228
|
+
return template_store.save(
|
|
1229
|
+
name=name,
|
|
1230
|
+
path=str(section_xml_path),
|
|
1231
|
+
description=description or name,
|
|
1232
|
+
header_xml=str(header_xml_path) if header_xml_path else None,
|
|
1233
|
+
tags=tags,
|
|
1234
|
+
sections_meta=sections_meta,
|
|
1235
|
+
auto_sections_meta=auto_sections_meta,
|
|
1236
|
+
mapper=self._mapper,
|
|
1237
|
+
embed_fn=self._embed_fn,
|
|
1238
|
+
)
|
|
1239
|
+
|
|
1240
|
+
def estimate_cost(
|
|
1241
|
+
self,
|
|
1242
|
+
data_folder: str | Path,
|
|
1243
|
+
template: str | Path,
|
|
1244
|
+
quick: bool = False,
|
|
1245
|
+
) -> str:
|
|
1246
|
+
"""
|
|
1247
|
+
Estimate API token cost before generating.
|
|
1248
|
+
|
|
1249
|
+
quick=True (default in MCP): file-size based estimate, no indexing or API call.
|
|
1250
|
+
quick=False: full indexing + RAG retrieval + token-counting API for accuracy.
|
|
1251
|
+
"""
|
|
1252
|
+
template_path, db_sections_meta = self._resolve_template(template)
|
|
1253
|
+
template_path, sections, _ = _resolve_sections(
|
|
1254
|
+
template, template_path, db_sections_meta, mapper=self._mapper,
|
|
1255
|
+
)
|
|
1256
|
+
|
|
1257
|
+
model: str = getattr(self._mapper, "_model", "claude-sonnet-4-6")
|
|
1258
|
+
in_price, out_price = _MODEL_PRICING.get(model, (3.00, 15.00))
|
|
1259
|
+
est_output = len(sections) * _EST_OUTPUT_TOKENS_PER_SECTION
|
|
1260
|
+
|
|
1261
|
+
if quick:
|
|
1262
|
+
input_tokens = _estimate_tokens_from_folder(Path(data_folder), len(sections))
|
|
1263
|
+
input_cost = input_tokens / 1_000_000 * in_price
|
|
1264
|
+
output_cost = est_output / 1_000_000 * out_price
|
|
1265
|
+
lines = [
|
|
1266
|
+
"=== docpilot 비용 추정 (빠른 추정) ===",
|
|
1267
|
+
f"모델: {model}",
|
|
1268
|
+
f"섹션 수: {len(sections)}개",
|
|
1269
|
+
f"입력 토큰 (추정): {input_tokens:,} (파일 크기 기반, ±30% 오차)",
|
|
1270
|
+
f"출력 토큰 (추정): {est_output:,} (섹션당 {_EST_OUTPUT_TOKENS_PER_SECTION} 추정)",
|
|
1271
|
+
f"예상 비용: ${input_cost + output_cost:.4f}",
|
|
1272
|
+
f" 입력 ${in_price:.2f}/1M → ${input_cost:.4f}",
|
|
1273
|
+
f" 출력 ${out_price:.2f}/1M → ${output_cost:.4f}",
|
|
1274
|
+
"",
|
|
1275
|
+
"정확한 추정: estimate_cost(quick=False) — 인덱싱 + 토큰 카운팅 API 사용",
|
|
1276
|
+
]
|
|
1277
|
+
return "\n".join(lines)
|
|
1278
|
+
|
|
1279
|
+
from docpilot.db import indexer
|
|
1280
|
+
indexer.index_folder(data_folder, embed_fn=self._embed_fn)
|
|
1281
|
+
content = self._rag_mapper.retrieve_content(sections)
|
|
1282
|
+
|
|
1283
|
+
if not hasattr(self._mapper, "count_tokens"):
|
|
1284
|
+
n = len(sections)
|
|
1285
|
+
return (
|
|
1286
|
+
f"섹션 수: {n}개\n"
|
|
1287
|
+
f"토큰 카운팅은 Claude 매퍼에서만 지원됩니다.\n"
|
|
1288
|
+
f"섹션당 ~3,000 입력 + ~{_EST_OUTPUT_TOKENS_PER_SECTION} 출력 토큰 기준\n"
|
|
1289
|
+
f"대략 {n * 3000:,} 입력 / {n * _EST_OUTPUT_TOKENS_PER_SECTION:,} 출력 예상"
|
|
1290
|
+
)
|
|
1291
|
+
|
|
1292
|
+
input_tokens = self._mapper.count_tokens(content, sections)
|
|
1293
|
+
input_cost = input_tokens / 1_000_000 * in_price
|
|
1294
|
+
output_cost = est_output / 1_000_000 * out_price
|
|
1295
|
+
|
|
1296
|
+
lines = [
|
|
1297
|
+
"=== docpilot 비용 추정 ===",
|
|
1298
|
+
f"모델: {model}",
|
|
1299
|
+
f"섹션 수: {len(sections)}개",
|
|
1300
|
+
f"입력 토큰: {input_tokens:,}",
|
|
1301
|
+
f"출력 토큰 (추정): {est_output:,} (섹션당 {_EST_OUTPUT_TOKENS_PER_SECTION} 추정)",
|
|
1302
|
+
f"예상 비용: ${input_cost + output_cost:.4f}",
|
|
1303
|
+
f" 입력 ${in_price:.2f}/1M → ${input_cost:.4f}",
|
|
1304
|
+
f" 출력 ${out_price:.2f}/1M → ${output_cost:.4f}",
|
|
1305
|
+
]
|
|
1306
|
+
return "\n".join(lines)
|
|
1307
|
+
|
|
1308
|
+
def estimate_cost_from_content(
|
|
1309
|
+
self,
|
|
1310
|
+
content: str | list[str | Path] | list[IngestedDocument],
|
|
1311
|
+
template: str | Path,
|
|
1312
|
+
quick: bool = False,
|
|
1313
|
+
) -> str:
|
|
1314
|
+
"""
|
|
1315
|
+
Estimate API token cost for generate_from_content() — no indexing, no RAG retrieval.
|
|
1316
|
+
|
|
1317
|
+
quick=True: byte-based heuristic, no API call.
|
|
1318
|
+
quick=False (default): real token-counting API call against the actual content.
|
|
1319
|
+
"""
|
|
1320
|
+
template_path, db_sections_meta = self._resolve_template(template)
|
|
1321
|
+
template_path, sections, _ = _resolve_sections(
|
|
1322
|
+
template, template_path, db_sections_meta, mapper=self._mapper,
|
|
1323
|
+
)
|
|
1324
|
+
content_str = _resolve_content(content)
|
|
1325
|
+
|
|
1326
|
+
model: str = getattr(self._mapper, "_model", "claude-sonnet-4-6")
|
|
1327
|
+
in_price, out_price = _MODEL_PRICING.get(model, (3.00, 15.00))
|
|
1328
|
+
est_output = len(sections) * _EST_OUTPUT_TOKENS_PER_SECTION
|
|
1329
|
+
|
|
1330
|
+
if quick:
|
|
1331
|
+
input_tokens = _estimate_tokens_raw(content_str)
|
|
1332
|
+
input_cost = input_tokens / 1_000_000 * in_price
|
|
1333
|
+
output_cost = est_output / 1_000_000 * out_price
|
|
1334
|
+
lines = [
|
|
1335
|
+
"=== docpilot 비용 추정 (빠른 추정, RAG 없음) ===",
|
|
1336
|
+
f"모델: {model}",
|
|
1337
|
+
f"섹션 수: {len(sections)}개",
|
|
1338
|
+
f"입력 토큰 (추정): {input_tokens:,} (바이트 기반, ±30% 오차)",
|
|
1339
|
+
f"출력 토큰 (추정): {est_output:,} (섹션당 {_EST_OUTPUT_TOKENS_PER_SECTION} 추정)",
|
|
1340
|
+
f"예상 비용: ${input_cost + output_cost:.4f}",
|
|
1341
|
+
f" 입력 ${in_price:.2f}/1M → ${input_cost:.4f}",
|
|
1342
|
+
f" 출력 ${out_price:.2f}/1M → ${output_cost:.4f}",
|
|
1343
|
+
"",
|
|
1344
|
+
"정확한 추정: estimate_cost_from_content(quick=False) — 토큰 카운팅 API 사용",
|
|
1345
|
+
]
|
|
1346
|
+
return "\n".join(lines)
|
|
1347
|
+
|
|
1348
|
+
if not hasattr(self._mapper, "count_tokens"):
|
|
1349
|
+
n = len(sections)
|
|
1350
|
+
return (
|
|
1351
|
+
f"섹션 수: {n}개\n"
|
|
1352
|
+
f"토큰 카운팅은 Claude 매퍼에서만 지원됩니다.\n"
|
|
1353
|
+
f"섹션당 ~3,000 입력 + ~{_EST_OUTPUT_TOKENS_PER_SECTION} 출력 토큰 기준\n"
|
|
1354
|
+
f"대략 {n * 3000:,} 입력 / {n * _EST_OUTPUT_TOKENS_PER_SECTION:,} 출력 예상"
|
|
1355
|
+
)
|
|
1356
|
+
|
|
1357
|
+
input_tokens = self._mapper.count_tokens(content_str, sections)
|
|
1358
|
+
input_cost = input_tokens / 1_000_000 * in_price
|
|
1359
|
+
output_cost = est_output / 1_000_000 * out_price
|
|
1360
|
+
|
|
1361
|
+
lines = [
|
|
1362
|
+
"=== docpilot 비용 추정 (RAG 없음) ===",
|
|
1363
|
+
f"모델: {model}",
|
|
1364
|
+
f"섹션 수: {len(sections)}개",
|
|
1365
|
+
f"입력 토큰: {input_tokens:,}",
|
|
1366
|
+
f"출력 토큰 (추정): {est_output:,} (섹션당 {_EST_OUTPUT_TOKENS_PER_SECTION} 추정)",
|
|
1367
|
+
f"예상 비용: ${input_cost + output_cost:.4f}",
|
|
1368
|
+
f" 입력 ${in_price:.2f}/1M → ${input_cost:.4f}",
|
|
1369
|
+
f" 출력 ${out_price:.2f}/1M → ${output_cost:.4f}",
|
|
1370
|
+
]
|
|
1371
|
+
return "\n".join(lines)
|
|
1372
|
+
|
|
1373
|
+
def benchmark(
|
|
1374
|
+
self,
|
|
1375
|
+
data_folder: str | Path,
|
|
1376
|
+
template: str | Path,
|
|
1377
|
+
output: str | Path,
|
|
1378
|
+
mappers: dict | None = None,
|
|
1379
|
+
) -> str:
|
|
1380
|
+
"""Run mapping benchmark across multiple LLM mappers and return report."""
|
|
1381
|
+
from docpilot.mapping import benchmark, ClaudeMapper, OpenAIMapper
|
|
1382
|
+
|
|
1383
|
+
template_path, db_sections_meta = self._resolve_template(template)
|
|
1384
|
+
self.index(data_folder)
|
|
1385
|
+
|
|
1386
|
+
template_path, template_sections, _ = _resolve_sections(
|
|
1387
|
+
template, template_path, db_sections_meta, mapper=self._mapper,
|
|
1388
|
+
)
|
|
1389
|
+
content = self._rag_mapper.retrieve_content(template_sections)
|
|
1390
|
+
|
|
1391
|
+
if mappers is None:
|
|
1392
|
+
mappers = {"claude": ClaudeMapper(api_key=self._api_key)}
|
|
1393
|
+
oai_key = os.environ.get("OPENAI_API_KEY")
|
|
1394
|
+
if oai_key:
|
|
1395
|
+
mappers["openai"] = OpenAIMapper(api_key=oai_key)
|
|
1396
|
+
|
|
1397
|
+
results = benchmark.run(content, template_sections, mappers)
|
|
1398
|
+
return benchmark.report(results)
|
|
1399
|
+
|
|
1400
|
+
def benchmark_from_content(
|
|
1401
|
+
self,
|
|
1402
|
+
content: str | list[str | Path] | list[IngestedDocument],
|
|
1403
|
+
template: str | Path,
|
|
1404
|
+
output: str | Path,
|
|
1405
|
+
mappers: dict | None = None,
|
|
1406
|
+
max_input_tokens: int | None = None,
|
|
1407
|
+
) -> str:
|
|
1408
|
+
"""Run mapping benchmark across multiple LLM mappers — no indexing, no RAG retrieval."""
|
|
1409
|
+
from docpilot.mapping import benchmark, ClaudeMapper, OpenAIMapper
|
|
1410
|
+
|
|
1411
|
+
template_path, db_sections_meta = self._resolve_template(template)
|
|
1412
|
+
template_path, template_sections, _ = _resolve_sections(
|
|
1413
|
+
template, template_path, db_sections_meta, mapper=self._mapper,
|
|
1414
|
+
)
|
|
1415
|
+
content_str = _resolve_content(content)
|
|
1416
|
+
_check_content_size(content_str, max_input_tokens)
|
|
1417
|
+
|
|
1418
|
+
if mappers is None:
|
|
1419
|
+
mappers = {"claude": ClaudeMapper(api_key=self._api_key)}
|
|
1420
|
+
oai_key = os.environ.get("OPENAI_API_KEY")
|
|
1421
|
+
if oai_key:
|
|
1422
|
+
mappers["openai"] = OpenAIMapper(api_key=oai_key)
|
|
1423
|
+
|
|
1424
|
+
results = benchmark.run(content_str, template_sections, mappers)
|
|
1425
|
+
return benchmark.report(results)
|
|
1426
|
+
|
|
1427
|
+
def _resolve_template(self, template: str | Path) -> tuple[Path, dict]:
|
|
1428
|
+
"""Return (template_path, sections_meta). sections_meta is non-empty only for DB templates."""
|
|
1429
|
+
return _resolve_template_path(template, self._embed_fn)
|
|
1430
|
+
|
|
1431
|
+
@staticmethod
|
|
1432
|
+
def list_templates() -> dict[str, str]:
|
|
1433
|
+
"""Return available template names and their descriptions (built-in + DB-saved)."""
|
|
1434
|
+
result = _get_builtin_metadata()
|
|
1435
|
+
try:
|
|
1436
|
+
from docpilot.db import template_store
|
|
1437
|
+
for record in template_store.list_all():
|
|
1438
|
+
if record.name not in result:
|
|
1439
|
+
result[record.name] = record.description
|
|
1440
|
+
except Exception:
|
|
1441
|
+
pass
|
|
1442
|
+
return result
|
|
1443
|
+
|
|
1444
|
+
@staticmethod
|
|
1445
|
+
def delete_template(template_id: int) -> bool:
|
|
1446
|
+
"""Delete a saved template by ID. Returns True if found and deleted."""
|
|
1447
|
+
from docpilot.db import template_store
|
|
1448
|
+
return template_store.delete(template_id)
|
|
1449
|
+
|
|
1450
|
+
@staticmethod
|
|
1451
|
+
def delete_template_by_path(path: str | Path) -> bool:
|
|
1452
|
+
"""Delete a saved template by its section0.xml path. Returns True if found and deleted."""
|
|
1453
|
+
from docpilot.db import template_store
|
|
1454
|
+
return template_store.delete_by_path(path)
|
|
1455
|
+
|
|
1456
|
+
@staticmethod
|
|
1457
|
+
def convert_to_list_placeholder(
|
|
1458
|
+
path: str | Path,
|
|
1459
|
+
keys: list[str] | str,
|
|
1460
|
+
output: str | Path | None = None,
|
|
1461
|
+
) -> Path:
|
|
1462
|
+
"""
|
|
1463
|
+
Convert {{key}} placeholders to {{?key?}} (dynamic list) in a template file.
|
|
1464
|
+
|
|
1465
|
+
keys: placeholder name(s) to convert
|
|
1466
|
+
output: destination path; if None, overwrites source in-place
|
|
1467
|
+
"""
|
|
1468
|
+
return convert_to_list_placeholder(path, keys, output)
|
|
1469
|
+
|
|
1470
|
+
@staticmethod
|
|
1471
|
+
def suggest_extras(folder: str | Path) -> dict:
|
|
1472
|
+
"""Scan a data folder and suggest which docpilot extras to install."""
|
|
1473
|
+
return suggest_extras(folder)
|
|
1474
|
+
|
|
1475
|
+
@staticmethod
|
|
1476
|
+
def _build_mapper(llm: str, api_key: str | None, model: str | None, base_url: str | None):
|
|
1477
|
+
from docpilot.mapping import ClaudeMapper, OpenAIMapper, GeminiMapper
|
|
1478
|
+
from docpilot.mapping.openai_compat import GrokMapper, OllamaMapper
|
|
1479
|
+
|
|
1480
|
+
match llm.lower():
|
|
1481
|
+
case "claude":
|
|
1482
|
+
return ClaudeMapper(api_key=api_key, **(_m(model)))
|
|
1483
|
+
case "openai":
|
|
1484
|
+
return OpenAIMapper(api_key=api_key, **(_m(model)))
|
|
1485
|
+
case "gemini":
|
|
1486
|
+
return GeminiMapper(api_key=api_key, **(_m(model)))
|
|
1487
|
+
case "grok":
|
|
1488
|
+
return GrokMapper(api_key=api_key, **(_m(model)))
|
|
1489
|
+
case "ollama":
|
|
1490
|
+
return OllamaMapper(**(_m(model)), **(_b(base_url)))
|
|
1491
|
+
case _:
|
|
1492
|
+
raise MappingError(
|
|
1493
|
+
f"Unknown LLM '{llm}'. "
|
|
1494
|
+
"Supported: claude, openai, gemini, grok, ollama"
|
|
1495
|
+
)
|
|
1496
|
+
|
|
1497
|
+
@staticmethod
|
|
1498
|
+
def _build_builder(output: Path):
|
|
1499
|
+
return _resolve_builder(output)
|