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.
Files changed (85) hide show
  1. docpilot/__init__.py +1499 -0
  2. docpilot/builder/__init__.py +12 -0
  3. docpilot/builder/base.py +50 -0
  4. docpilot/builder/docx_analyzer.py +208 -0
  5. docpilot/builder/docx_builder.py +266 -0
  6. docpilot/builder/html_blocks.py +174 -0
  7. docpilot/builder/html_table.py +269 -0
  8. docpilot/builder/hwp_convert.py +104 -0
  9. docpilot/builder/hwpx_analyzer.py +190 -0
  10. docpilot/builder/hwpx_auto_builder.py +314 -0
  11. docpilot/builder/hwpx_builder.py +301 -0
  12. docpilot/builder/hwpx_bullets.py +164 -0
  13. docpilot/builder/hwpx_dynamic_builder.py +361 -0
  14. docpilot/builder/hwpx_section_generator.py +130 -0
  15. docpilot/builder/hwpx_style_extractor.py +128 -0
  16. docpilot/builder/pdf_builder.py +100 -0
  17. docpilot/db/__init__.py +4 -0
  18. docpilot/db/client.py +155 -0
  19. docpilot/db/indexer.py +355 -0
  20. docpilot/db/schema.py +94 -0
  21. docpilot/db/template_store.py +412 -0
  22. docpilot/exceptions.py +34 -0
  23. docpilot/ingestion/__init__.py +48 -0
  24. docpilot/ingestion/docx.py +78 -0
  25. docpilot/ingestion/hwp.py +77 -0
  26. docpilot/ingestion/hwpx.py +91 -0
  27. docpilot/ingestion/models.py +12 -0
  28. docpilot/ingestion/pdf.py +125 -0
  29. docpilot/ingestion/text.py +35 -0
  30. docpilot/mapping/__init__.py +24 -0
  31. docpilot/mapping/base.py +184 -0
  32. docpilot/mapping/benchmark.py +103 -0
  33. docpilot/mapping/claude.py +93 -0
  34. docpilot/mapping/gemini.py +79 -0
  35. docpilot/mapping/openai.py +78 -0
  36. docpilot/mapping/openai_compat.py +106 -0
  37. docpilot/mapping/rag.py +157 -0
  38. docpilot/mapping/sidecar.py +107 -0
  39. docpilot/mcp_server.py +282 -0
  40. docpilot/search/__init__.py +64 -0
  41. docpilot/search/_filter.py +77 -0
  42. docpilot/search/embedding.py +268 -0
  43. docpilot/search/eval.py +86 -0
  44. docpilot/search/exact.py +50 -0
  45. docpilot/search/highlight.py +73 -0
  46. docpilot/search/hybrid.py +92 -0
  47. docpilot/search/models.py +44 -0
  48. docpilot/search/morpheme.py +132 -0
  49. docpilot/search/reranker.py +44 -0
  50. docpilot/template_generator/__init__.py +4 -0
  51. docpilot/template_generator/analyzer.py +100 -0
  52. docpilot/template_generator/docx_extractor.py +71 -0
  53. docpilot/template_generator/extractor.py +131 -0
  54. docpilot/template_generator/generator.py +331 -0
  55. docpilot/templates/base/Contents/content.hpf +24 -0
  56. docpilot/templates/base/Contents/header.xml +821 -0
  57. docpilot/templates/base/Contents/section0.xml +48 -0
  58. docpilot/templates/base/META-INF/container.rdf +1 -0
  59. docpilot/templates/base/META-INF/container.xml +8 -0
  60. docpilot/templates/base/META-INF/manifest.xml +2 -0
  61. docpilot/templates/base/Preview/PrvImage.png +0 -0
  62. docpilot/templates/base/Preview/PrvText.txt +1 -0
  63. docpilot/templates/base/mimetype +1 -0
  64. docpilot/templates/base/settings.xml +4 -0
  65. docpilot/templates/base/version.xml +2 -0
  66. docpilot/templates/gonmun/header.xml +976 -0
  67. docpilot/templates/gonmun/section0.xml +197 -0
  68. docpilot/templates/gonmun/sidecar.json +24 -0
  69. docpilot/templates/minutes/header.xml +965 -0
  70. docpilot/templates/minutes/section0.xml +346 -0
  71. docpilot/templates/minutes/sidecar.json +17 -0
  72. docpilot/templates/proposal/header.xml +1040 -0
  73. docpilot/templates/proposal/section0.xml +242 -0
  74. docpilot/templates/proposal/sidecar.json +11 -0
  75. docpilot/templates/report/header.xml +1202 -0
  76. docpilot/templates/report/section0.xml +201 -0
  77. docpilot/templates/report/sidecar.json +18 -0
  78. smart_docgen-0.1.0.dist-info/METADATA +1269 -0
  79. smart_docgen-0.1.0.dist-info/RECORD +85 -0
  80. smart_docgen-0.1.0.dist-info/WHEEL +5 -0
  81. smart_docgen-0.1.0.dist-info/entry_points.txt +2 -0
  82. smart_docgen-0.1.0.dist-info/licenses/LICENSE +21 -0
  83. smart_docgen-0.1.0.dist-info/scm_file_list.json +123 -0
  84. smart_docgen-0.1.0.dist-info/scm_version.json +8 -0
  85. 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)