From dbdb482553730e9a3f1647c700806d965bb12c2f Mon Sep 17 00:00:00 2001 From: Baekho Lim Date: Tue, 3 Mar 2026 07:19:03 +0900 Subject: [PATCH 01/24] feat(md2hwp): add collaboration infrastructure and template injection engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AGENTS.md: Instructions for ChatGPT Codex and other AI agents - docs/md2hwp/DESIGN.md: Full architecture, fill_plan.json schema, gap analysis - .github/ISSUE_TEMPLATE/task.md: AI-consumable issue template - tools/md2hwp/fill_hwpx.py: HWPX template injection engine (3 strategies) - tools/md2hwp-ui/: Web preview server + HWPX-to-HTML renderer - testdata/hwpx_20260302_200059.hwpx: 재도전성공패키지 test template Co-Authored-By: Claude Opus 4.6 --- .github/ISSUE_TEMPLATE/task.md | 33 ++ AGENTS.md | 139 ++++++++ docs/md2hwp/DESIGN.md | 312 +++++++++++++++++ testdata/hwpx_20260302_200059.hwpx | Bin 0 -> 35025 bytes tools/md2hwp-ui/renderer.py | 181 ++++++++++ tools/md2hwp-ui/server.py | 515 +++++++++++++++++++++++++++++ tools/md2hwp/fill_hwpx.py | 355 ++++++++++++++++++++ 7 files changed, 1535 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/task.md create mode 100644 AGENTS.md create mode 100644 docs/md2hwp/DESIGN.md create mode 100644 testdata/hwpx_20260302_200059.hwpx create mode 100644 tools/md2hwp-ui/renderer.py create mode 100644 tools/md2hwp-ui/server.py create mode 100644 tools/md2hwp/fill_hwpx.py diff --git a/.github/ISSUE_TEMPLATE/task.md b/.github/ISSUE_TEMPLATE/task.md new file mode 100644 index 0000000..fced34a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/task.md @@ -0,0 +1,33 @@ +--- +name: Implementation Task +about: AI-consumable task with full context and acceptance criteria +title: '' +labels: '' +assignees: '' +--- + +## Context + + + +## Spec + + + +## Files to Modify + + + +## Test Plan + + + +## Acceptance Criteria + +- [ ] All existing tests pass +- [ ] New tests cover the change +- [ ] Code follows project conventions (see AGENTS.md) + +## References + + diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..8467dcd --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,139 @@ +# AGENTS.md + +Instructions for AI coding agents (ChatGPT Codex, etc.) working on this repository. + +## Project Overview + +hwp2md is a CLI tool for converting HWP/HWPX documents to Markdown (Go) with a reverse pipeline **md2hwp** for filling HWPX templates with content (Python). + +## Repository Structure + +``` +hwp2md/ +├── cmd/hwp2md/ # CLI entry point (Go) +├── internal/ # Core Go implementation +│ ├── parser/hwpx/ # HWPX XML parser +│ ├── parser/hwp5/ # HWP5 binary parser +│ ├── ir/ # Intermediate Representation +│ ├── llm/ # LLM provider abstraction +│ ├── formatter/ # Output formatting +│ └── cli/ # CLI commands +├── tools/md2hwp-ui/ # Web preview UI (Python, lower priority) +│ ├── server.py # HTTP server + SSE +│ └── renderer.py # HWPX -> HTML converter +├── tools/md2hwp/ # fill_hwpx.py (Python template injection engine) +├── tests/ # E2E tests (Go) +├── testdata/ # Test fixtures +└── docs/ # Technical documentation + └── md2hwp/ # md2hwp design docs & specs +``` + +## Build & Test + +```bash +# Go (hwp2md core) +make build # Build binary to bin/hwp2md +make test # Unit tests with race detection + coverage +make test-e2e # E2E tests +make lint # golangci-lint +make fmt # gofmt + +# Python (md2hwp / fill_hwpx.py) +pip install lxml # Required dependency +python3 tools/md2hwp/fill_hwpx.py --help +python3 tools/md2hwp/fill_hwpx.py --inspect +python3 tools/md2hwp/fill_hwpx.py --inspect-tables +python3 tools/md2hwp/fill_hwpx.py --analyze +python3 tools/md2hwp/fill_hwpx.py +``` + +## Key Conventions + +- **Go code**: Follow golangci-lint rules, `make fmt` before commit +- **Python code**: Follow PEP 8, type hints where practical +- **Commits**: Conventional Commits 1.0.0 (feat/fix/docs/test/refactor/chore) +- **Language**: Korean for user-facing messages, English for code/docs/commits +- **Tests**: TDD workflow - write tests first, then implement + +## md2hwp Architecture + +See [docs/md2hwp/DESIGN.md](docs/md2hwp/DESIGN.md) for full design. + +### fill_hwpx.py Overview + +Template injection engine that modifies HWPX (ZIP + XML) files: + +- **Input**: `fill_plan.json` with replacement instructions +- **Output**: Modified HWPX file with content injected +- **Preservation**: All formatting (fonts, cell sizes, merge patterns) preserved + +### HWPX XML Structure + +``` +hs:sec (section root) + hp:p (paragraph) + hp:run (text run with style reference) + hp:t (text content) + hp:tbl (table) + hp:tr (table row) + hp:tc (table cell) + hp:cellAddr (colAddr, rowAddr) + hp:cellSpan (colSpan, rowSpan) + hp:subList + hp:p > hp:run > hp:t +``` + +### Namespace Map + +```python +HWPX_NS = { + "hp": "http://www.hancom.co.kr/hwpml/2011/paragraph", + "hs": "http://www.hancom.co.kr/hwpml/2011/section", + "hc": "http://www.hancom.co.kr/hwpml/2011/core", + "hh": "http://www.hancom.co.kr/hwpml/2011/head", +} +``` + +## Working with Issues + +Each issue assigned to you will contain: + +1. **Context**: Why this change is needed +2. **Spec**: Exact interface/behavior expected +3. **Test fixtures**: Input/output examples in `testdata/` or inline +4. **Acceptance criteria**: What must pass for the PR to be accepted + +### Branch Naming + +``` +codex/- +# Example: codex/25-fix-empty-cell-fill +``` + +### PR Checklist + +Before submitting a PR: +- [ ] All existing tests pass (`make test` for Go, pytest for Python) +- [ ] New tests added for new functionality +- [ ] Code formatted (`make fmt` for Go, PEP 8 for Python) +- [ ] Commit messages follow Conventional Commits +- [ ] No new linter warnings + +## Important Files Reference + +| File | Purpose | +|------|---------| +| `tools/md2hwp/fill_hwpx.py` | Template injection engine (Python) | +| `docs/md2hwp/DESIGN.md` | Architecture & fill_plan.json schema | +| `docs/md2hwp/FILL_PLAN_SCHEMA.md` | JSON schema reference | +| `testdata/hwpx_20260302_200059.hwpx` | Primary test template | +| `internal/parser/hwpx/parser.go` | HWPX parser (Go) | +| `docs/hwpx-schema.md` | HWPX XML format specification | + +## Python-specific Notes + +- **Python version**: 3.12+ (3.13 removed `cgi` module) +- **Dependencies**: `lxml` only (no Flask, no external frameworks) +- **File size limit**: 800 lines max per file +- **Function size**: 50 lines max +- **Error handling**: Always catch + descriptive error messages diff --git a/docs/md2hwp/DESIGN.md b/docs/md2hwp/DESIGN.md new file mode 100644 index 0000000..550d3f1 --- /dev/null +++ b/docs/md2hwp/DESIGN.md @@ -0,0 +1,312 @@ +# md2hwp Design Document + +> Reverse pipeline: Fill HWPX government templates with business plan content. + +## Problem + +Korean government funding applications require submission in HWP format with strict template compliance. Manual form-filling is tedious and error-prone. md2hwp automates this by injecting structured content into HWPX templates while preserving all formatting. + +## Target Workflow + +``` +1. User uploads HWPX template → Claude analyzes structure +2. User discusses business plan → content finalized +3. Claude generates fill_plan.json → fill_hwpx.py injects content +4. User downloads completed HWPX → submits to government +``` + +## Architecture + +``` +fill_plan.json ──→ fill_hwpx.py ──→ output.hwpx + │ + template.hwpx + (ZIP + XML) +``` + +### Core Engine: `tools/md2hwp/fill_hwpx.py` + +Single Python script using `zipfile + lxml` for direct XML manipulation. + +**Why not python-hwpx?** It misses text inside table cells. Direct XML parsing captures ALL `` elements. + +### Replacement Strategies + +| Strategy | Purpose | fill_plan.json key | +|----------|---------|-------------------| +| Simple | Exact text match → replace | `simple_replacements` | +| Section | Guide text → actual content (cell-scoped) | `section_replacements` | +| Table Cell | Label cell → adjacent value cell | `table_cell_fills` | +| Multi-paragraph | Inject multiple paragraphs into a cell | `multi_paragraph_fills` | + +### Processing Order + +1. Simple replacements (longest-first to prevent partial matches) +2. Section replacements (clears entire cell of guide text) +3. Table cell fills (cellAddr-based lookup with flat-scan fallback) +4. Multi-paragraph fills (creates new `` elements) + +--- + +## fill_plan.json Schema + +```json +{ + "template_file": "/absolute/path/to/template.hwpx", + "output_file": "/absolute/path/to/output.hwpx", + + "simple_replacements": [ + { + "find": "OO기업", + "replace": "테스트기업 주식회사", + "occurrence": 1 + } + ], + + "section_replacements": [ + { + "section_id": "1-1", + "guide_text_prefix": "※ 과거 폐업 원인을", + "content": "Actual content replacing the guide text.", + "clear_cell": true + } + ], + + "table_cell_fills": [ + { + "find_label": "과제명", + "value": "AI 자세분석 플랫폼", + "target_offset": {"col": 1, "row": 0} + } + ], + + "multi_paragraph_fills": [ + { + "section_id": "1-1", + "guide_text_prefix": "※ 과거 폐업 원인을", + "paragraphs": [ + "First paragraph of content...", + "Second paragraph of content...", + "Third paragraph of content..." + ] + } + ] +} +``` + +### Field Reference + +#### simple_replacements + +| Field | Type | Required | Default | Description | +|-------|------|----------|---------|-------------| +| `find` | string | yes | - | Exact text to find in `` elements | +| `replace` | string | yes | - | Replacement text | +| `occurrence` | int | no | all | Limit to N replacements | + +#### section_replacements + +| Field | Type | Required | Default | Description | +|-------|------|----------|---------|-------------| +| `section_id` | string | no | "?" | Section identifier for logging | +| `guide_text_prefix` | string | yes | - | Prefix of guide text to find | +| `content` | string | yes | - | Content to replace guide text | +| `clear_cell` | bool | no | true | Clear all other runs/paragraphs in the cell | + +#### table_cell_fills + +| Field | Type | Required | Default | Description | +|-------|------|----------|---------|-------------| +| `find_label` | string | yes | - | Label text in the label cell | +| `value` | string | yes | - | Value to write in the target cell | +| `target_offset` | object | no | `{"col":1,"row":0}` | Column/row offset from label cell | + +#### multi_paragraph_fills + +| Field | Type | Required | Default | Description | +|-------|------|----------|---------|-------------| +| `section_id` | string | no | "?" | Section identifier for logging | +| `guide_text_prefix` | string | yes | - | Prefix to locate the target cell | +| `paragraphs` | string[] | yes | - | Array of paragraph texts | + +--- + +## CLI Interface + +```bash +# Inspection +python3 tools/md2hwp/fill_hwpx.py --inspect # List all text elements +python3 tools/md2hwp/fill_hwpx.py --inspect -q "text" # Search text elements +python3 tools/md2hwp/fill_hwpx.py --inspect-tables # Show table structure +python3 tools/md2hwp/fill_hwpx.py --analyze # Extract fillable field schema + +# Filling +python3 tools/md2hwp/fill_hwpx.py +python3 tools/md2hwp/fill_hwpx.py -o + +# Environment +MD2HWP_EVENT_FILE=/tmp/events.jsonl # Enable SSE event logging +``` + +--- + +## HWPX XML Reference + +### Element Hierarchy + +```xml + + + + Text content + + ... + + +``` + +### Table Cell Hierarchy + +```xml + + + + + + + Cell text + + ... + + + + + + + + +``` + +### Namespaces + +| Prefix | URI | +|--------|-----| +| `hp` | `http://www.hancom.co.kr/hwpml/2011/paragraph` | +| `hs` | `http://www.hancom.co.kr/hwpml/2011/section` | +| `hc` | `http://www.hancom.co.kr/hwpml/2011/core` | +| `hh` | `http://www.hancom.co.kr/hwpml/2011/head` | +| `hp10` | `http://www.hancom.co.kr/hwpml/2016/paragraph` | + +--- + +## Target Template: 재도전성공패키지 + +Primary test template: `testdata/hwpx_20260302_200059.hwpx` + +### Structure + +- **28 tables**, 382 `` text elements +- 7 page limit (excl. TOC + appendix) + +### Sections + +| Section | Tables | Content Type | +|---------|--------|-------------| +| 과제 개요 | T5 (3x2) | 과제명, 기업명, 아이템 개요 | +| 폐업 이력 | T6 (14x4) | Repeatable rows (max 3) | +| 1. 문제인식 | T7-T9 | Guide text → multi-paragraph | +| 2. 실현가능성 | T10-T12 | Guide text → multi-paragraph | +| 3. 성장전략 | T13-T18 | Guide text + timeline table + budget table | +| 4. 기업 구성 | T19-T22 | Team table + staffing plan | +| 가점/면제 | T23-T28 | Checklist + evidence placeholders | + +### Complex Tables + +- **T6** (폐업이력 14x4): colspan patterns, 3 repeatable company rows +- **T16** (실현일정 5x4): 4 data rows with deliverables +- **T18** (사업비 9x6): 3-level rowspan header, budget items +- **T21** (팀구성 5x8): Personnel roster with colspan +- **T24** (가점체크 14x4): Grouped checkbox items with rowspan + +### Template Constraints + +| Constraint | Value | +|-----------|-------| +| Page limit | 7 pages (excl. TOC + appendix) | +| Budget max | 100,000,000 KRW | +| Gov support | ≤75% of total | +| Cash contribution | ≥5% of total | +| In-kind contribution | ≤20% of total | +| Closure history | Max 3 companies (most recent) | +| PII masking | Required (name, gender, DOB, university) | + +--- + +## Known Gaps & Roadmap + +### P0: Must-have (blocking basic operation) + +| ID | Gap | Solution | +|----|-----|---------| +| P0-1 | section_replacements only replaces first ``, orphans rest | Cell-scoped clearing: replace first, remove other runs/paragraphs | +| P0-2 | table_cell_fills skips empty target cells (no ``) | cellAddr-based lookup + create `` in empty runs | +| P0-3 | --inspect lacks table/cell context | Add `[T2 R3 C1]` context + `--inspect-tables` mode | + +### P1: Quality improvements + +| ID | Gap | Solution | +|----|-----|---------| +| P1-4 | No multi-paragraph injection | New `multi_paragraph_fills` strategy, clones `` structure | +| P1-5 | No template schema extraction | `--analyze` mode outputs structured JSON of fillable fields | + +### P2: Nice-to-have + +| ID | Gap | Solution | +|----|-----|---------| +| P2-6 | No content validation | `--validate` mode checks char limits, budget math, required fields | + +--- + +## Helper Functions (Implementation Reference) + +These shared helpers are used across all strategies: + +```python +_build_parent_map(tree) -> dict # Element -> parent mapping +_get_ancestor(elem, tag, parent_map) # Walk up to find ancestor (tc, tbl, etc.) +_clear_cell_except(tc, keep_elem, pm) # Remove all runs/paragraphs except one +_find_cell_by_addr(tbl, col, row) # Find cell by cellAddr coordinates +_set_cell_text(tc, text) # Set cell text, create if needed +_get_table_index(tree, tbl) # Get table ordinal in document +``` + +--- + +## Testing Strategy + +### Unit Tests (per function) + +Each replacement strategy must have tests for: +- Normal case: text found and replaced +- Empty cell: target cell has no `` +- Multi-run guide text: guide text spans multiple `` elements +- Missing text: `find` text not in template (warning, no crash) +- Edge cases: colspan/rowspan cells, nested tables + +### Integration Tests + +- Full fill_plan.json → output.hwpx → hwp2md reverse → verify content +- Use `testdata/hwpx_20260302_200059.hwpx` as primary fixture + +### Test Fixtures Location + +``` +testdata/ +├── hwpx_20260302_200059.hwpx # Primary template +├── md2hwp-outputs/ # Test output directory +└── fill_plans/ # Test fill_plan.json files (to create) + ├── test_simple.json + ├── test_section.json + ├── test_table_cell.json + └── test_multi_paragraph.json +``` diff --git a/testdata/hwpx_20260302_200059.hwpx b/testdata/hwpx_20260302_200059.hwpx new file mode 100644 index 0000000000000000000000000000000000000000..adfcb94c684ac1903fd5c59d4a1d2abeb3d89836 GIT binary patch literal 35025 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 literal 0 HcmV?d00001 diff --git a/tools/md2hwp-ui/renderer.py b/tools/md2hwp-ui/renderer.py new file mode 100644 index 0000000..ed2817b --- /dev/null +++ b/tools/md2hwp-ui/renderer.py @@ -0,0 +1,181 @@ +"""renderer.py - HWPX to HTML converter for browser preview. + +Parses HWPX (ZIP+XML) and generates HTML with data-idx attributes +on each text element for real-time highlight support. +""" + +import zipfile +from html import escape +from lxml import etree + +HP = "http://www.hancom.co.kr/hwpml/2011/paragraph" +HS = "http://www.hancom.co.kr/hwpml/2011/section" +NS = {"hp": HP, "hs": HS} + +_T = f"{{{HP}}}t" +_P = f"{{{HP}}}p" +_RUN = f"{{{HP}}}run" +_TBL = f"{{{HP}}}tbl" +_TR = f"{{{HP}}}tr" +_TC = f"{{{HP}}}tc" +_CELL_SPAN = f"{{{HP}}}cellSpan" +_CELL_SZ = f"{{{HP}}}cellSz" +_SUB_LIST = f"{{{HP}}}subList" +_LINE_BREAK = f"{{{HP}}}lineBreak" +_SEC = f"{{{HS}}}sec" + + +def render_hwpx_to_html(hwpx_path: str) -> tuple[str, int]: + """Convert HWPX file to HTML string. + + Returns (html_string, total_text_count). + Each gets a for SSE targeting. + """ + xml_bytes = _extract_section_xml(hwpx_path) + root = etree.fromstring(xml_bytes) + ctx = {"idx": 0} + html = _render_element(root, ctx) + return html, ctx["idx"] + + +def _extract_section_xml(hwpx_path: str) -> bytes: + """Extract section0.xml from HWPX ZIP.""" + with zipfile.ZipFile(hwpx_path, "r") as zf: + for name in sorted(zf.namelist()): + if name.startswith("Contents/section") and name.endswith(".xml"): + return zf.read(name) + raise FileNotFoundError("No section XML found in HWPX") + + +def _render_element(elem, ctx: dict) -> str: + """Recursively render an XML element to HTML.""" + tag = _local_tag(elem) + + if tag == "sec": + return _render_children(elem, ctx) + if tag == "tbl": + return _render_table(elem, ctx) + if tag == "p": + return _render_paragraph(elem, ctx) + if tag == "run": + return _render_run(elem, ctx) + if tag == "t": + return _render_text(elem, ctx) + if tag == "lineBreak": + return "
" + if tag in ("subList",): + return _render_children(elem, ctx) + + return _render_children(elem, ctx) + + +def _render_children(elem, ctx: dict) -> str: + """Render all children of an element.""" + parts = [] + for child in elem: + parts.append(_render_element(child, ctx)) + return "".join(parts) + + +def _render_table(tbl, ctx: dict) -> str: + """Render as HTML .""" + rows = tbl.findall(_TR) + if not rows: + return "" + + # Calculate column width ratios from first row + first_row_cells = rows[0].findall(_TC) + total_width = 0 + col_widths = [] + for cell in first_row_cells: + sz = cell.find(_CELL_SZ) + w = int(sz.get("width", "0")) if sz is not None else 0 + span = cell.find(_CELL_SPAN) + cs = int(span.get("colSpan", "1")) if span is not None else 1 + for _ in range(cs): + col_widths.append(w // cs if cs > 0 else w) + total_width += w + + html = '
' + if total_width > 0 and col_widths: + html += "" + for w in col_widths: + pct = round(w / total_width * 100, 1) if total_width else 0 + html += f'' + html += "" + + for row in rows: + html += _render_row(row, ctx) + html += "
" + return html + + +def _render_row(tr, ctx: dict) -> str: + """Render as HTML .""" + cells = tr.findall(_TC) + html = "" + for cell in cells: + html += _render_cell(cell, ctx) + html += "" + return html + + +def _render_cell(tc, ctx: dict) -> str: + """Render as HTML .""" + span = tc.find(_CELL_SPAN) + cs = int(span.get("colSpan", "1")) if span is not None else 1 + rs = int(span.get("rowSpan", "1")) if span is not None else 1 + + is_header = tc.get("header") == "1" + tag = "th" if is_header else "td" + + attrs = "" + if cs > 1: + attrs += f' colspan="{cs}"' + if rs > 1: + attrs += f' rowspan="{rs}"' + + # Render cell content (paragraphs inside subList) + content = "" + sub_list = tc.find(_SUB_LIST) + if sub_list is not None: + content = _render_children(sub_list, ctx) + else: + content = _render_children(tc, ctx) + + return f"<{tag}{attrs}>{content}" + + +def _render_paragraph(p, ctx: dict) -> str: + """Render as HTML

.""" + content = _render_children(p, ctx) + if not content.strip(): + return "" + return f'

{content}

' + + +def _render_run(run, ctx: dict) -> str: + """Render as inline content.""" + return _render_children(run, ctx) + + +def _render_text(t, ctx: dict) -> str: + """Render as . + + Always increments idx (even for empty text) to stay in sync with + fill_hwpx.py's enumerate(get_all_text_elements(tree)). + """ + text = t.text or "" + idx = ctx["idx"] + ctx["idx"] += 1 + if not text: + return "" + return f'{escape(text)}' + + +def _local_tag(elem) -> str: + """Get local tag name without namespace.""" + tag = elem.tag + if "}" in tag: + return tag.split("}", 1)[1] + return tag diff --git a/tools/md2hwp-ui/server.py b/tools/md2hwp-ui/server.py new file mode 100644 index 0000000..14aa5cf --- /dev/null +++ b/tools/md2hwp-ui/server.py @@ -0,0 +1,515 @@ +#!/usr/bin/env python3 +"""md2hwp-ui server — HWPX template viewer with real-time fill preview. + +Usage: + python3 server.py [--port 8080] + +Browse to http://localhost:8080 after starting. +""" + +import argparse +import json +import os +import re +import shutil +import tempfile +import time +import threading +from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler +from pathlib import Path +from urllib.parse import urlparse, parse_qs + +# Add renderer to path +import sys +sys.path.insert(0, str(Path(__file__).parent)) +from renderer import render_hwpx_to_html + +# Session state +STATE = { + "upload_dir": None, + "template_path": None, + "template_html": None, + "text_count": 0, + "output_path": None, + "event_file": None, +} + +EVENT_FILE_PATH = "/tmp/md2hwp-events.jsonl" + + +def _parse_multipart(body: bytes, boundary: bytes) -> tuple: + """Parse multipart form data, return (filename, file_bytes) or (None, None).""" + delimiter = b"--" + boundary + parts = body.split(delimiter) + + for part in parts: + if b"Content-Disposition" not in part: + continue + # Split headers from body at double newline + header_end = part.find(b"\r\n\r\n") + if header_end == -1: + continue + headers_raw = part[:header_end].decode("utf-8", errors="replace") + file_body = part[header_end + 4:] + # Remove trailing \r\n-- if present + if file_body.endswith(b"\r\n"): + file_body = file_body[:-2] + + # Extract filename from Content-Disposition + fn_match = re.search(r'filename="([^"]+)"', headers_raw) + if fn_match: + return fn_match.group(1), file_body + + return None, None + + +def init_session(): + """Initialize temp directory for uploads.""" + if STATE["upload_dir"] and os.path.exists(STATE["upload_dir"]): + shutil.rmtree(STATE["upload_dir"]) + STATE["upload_dir"] = tempfile.mkdtemp(prefix="md2hwp-ui-") + STATE["event_file"] = EVENT_FILE_PATH + # Clear event file + with open(EVENT_FILE_PATH, "w") as f: + f.write("") + + +class Handler(BaseHTTPRequestHandler): + def log_message(self, format, *args): + pass # Suppress default logging + + def do_GET(self): + path = urlparse(self.path).path + + if path == "/": + self._serve_html() + elif path == "/api/events": + self._serve_sse() + elif path.startswith("/api/download/"): + self._serve_download() + else: + self._respond(404, "Not found") + + def do_POST(self): + path = urlparse(self.path).path + + if path == "/api/upload": + self._handle_upload() + elif path == "/api/fill": + self._handle_fill() + else: + self._respond(404, "Not found") + + # --- Handlers --- + + def _serve_html(self): + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.end_headers() + self.wfile.write(HTML_PAGE.encode("utf-8")) + + def _serve_sse(self): + self.send_response(200) + self.send_header("Content-Type", "text/event-stream") + self.send_header("Cache-Control", "no-cache") + self.send_header("Connection", "keep-alive") + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers() + + event_file = EVENT_FILE_PATH + last_pos = 0 + + # Start from end of file + if os.path.exists(event_file): + last_pos = os.path.getsize(event_file) + + try: + while True: + if os.path.exists(event_file): + size = os.path.getsize(event_file) + if size > last_pos: + with open(event_file, "r", encoding="utf-8") as f: + f.seek(last_pos) + new_lines = f.read() + last_pos = f.tell() + + for line in new_lines.strip().split("\n"): + if line.strip(): + self.wfile.write(f"data: {line}\n\n".encode()) + self.wfile.flush() + + # Heartbeat + self.wfile.write(b": heartbeat\n\n") + self.wfile.flush() + time.sleep(0.3) + except (BrokenPipeError, ConnectionResetError): + pass + + def _handle_upload(self): + content_type = self.headers.get("Content-Type", "") + if "multipart/form-data" not in content_type: + self._respond_json(400, {"error": "multipart/form-data required"}) + return + + content_length = int(self.headers.get("Content-Length", 0)) + body = self.rfile.read(content_length) + + # Extract boundary from Content-Type + boundary_match = re.search(r"boundary=(.+)", content_type) + if not boundary_match: + self._respond_json(400, {"error": "No boundary in Content-Type"}) + return + boundary = boundary_match.group(1).strip().encode() + + # Parse multipart: split by boundary, find file part + filename, file_data = _parse_multipart(body, boundary) + if not filename or file_data is None: + self._respond_json(400, {"error": "No file uploaded"}) + return + + init_session() + + # Save uploaded file + filename = os.path.basename(filename) + save_path = os.path.join(STATE["upload_dir"], filename) + with open(save_path, "wb") as f: + f.write(file_data) + + STATE["template_path"] = save_path + + # Render to HTML + try: + html, count = render_hwpx_to_html(save_path) + STATE["template_html"] = html + STATE["text_count"] = count + self._respond_json(200, { + "html": html, + "text_count": count, + "filename": filename, + "event_file": EVENT_FILE_PATH, + }) + except (BrokenPipeError, ConnectionResetError): + pass + except Exception as e: + try: + self._respond_json(500, {"error": str(e)}) + except (BrokenPipeError, ConnectionResetError): + pass + + def _handle_fill(self): + body = self.rfile.read(int(self.headers.get("Content-Length", 0))) + try: + plan = json.loads(body) + except json.JSONDecodeError: + self._respond_json(400, {"error": "Invalid JSON"}) + return + + if not STATE["template_path"]: + self._respond_json(400, {"error": "No template uploaded"}) + return + + # Set template and output in plan + output_name = "result_" + os.path.basename(STATE["template_path"]) + output_path = os.path.join(STATE["upload_dir"], output_name) + plan["template_file"] = STATE["template_path"] + plan["output_file"] = output_path + + # Save plan + plan_path = os.path.join(STATE["upload_dir"], "fill_plan.json") + with open(plan_path, "w", encoding="utf-8") as f: + json.dump(plan, f, ensure_ascii=False, indent=2) + + # Run fill_hwpx.py in background thread + def run_fill(): + fill_script = str(Path.home() / ".claude/skills/md2hwp/scripts/fill_hwpx.py") + env = os.environ.copy() + env["MD2HWP_EVENT_FILE"] = EVENT_FILE_PATH + import subprocess + result = subprocess.run( + [sys.executable, fill_script, plan_path], + env=env, capture_output=True, text=True, + ) + # Write done event + done_event = {"type": "done", "output": output_name, "log": result.stdout + result.stderr} + with open(EVENT_FILE_PATH, "a", encoding="utf-8") as f: + f.write(json.dumps(done_event, ensure_ascii=False) + "\n") + STATE["output_path"] = output_path + + threading.Thread(target=run_fill, daemon=True).start() + self._respond_json(200, {"status": "started", "plan_path": plan_path}) + + def _serve_download(self): + filename = urlparse(self.path).path.split("/api/download/", 1)[-1] + filepath = os.path.join(STATE["upload_dir"] or "", filename) + + if not os.path.exists(filepath): + self._respond(404, "File not found") + return + + self.send_response(200) + self.send_header("Content-Type", "application/octet-stream") + self.send_header("Content-Disposition", f'attachment; filename="{filename}"') + self.send_header("Content-Length", str(os.path.getsize(filepath))) + self.end_headers() + with open(filepath, "rb") as f: + shutil.copyfileobj(f, self.wfile) + + # --- Helpers --- + + def _respond(self, code, text): + self.send_response(code) + self.send_header("Content-Type", "text/plain; charset=utf-8") + self.end_headers() + self.wfile.write(text.encode("utf-8")) + + def _respond_json(self, code, data): + self.send_response(code) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.end_headers() + self.wfile.write(json.dumps(data, ensure_ascii=False).encode("utf-8")) + + +# ===== Inline HTML/CSS/JS ===== + +HTML_PAGE = """ + + + + +md2hwp Viewer + + + + +
+

md2hwp Viewer

+
+ + +
+
+ +
+
+ + HWPX 파일을 드래그하거나 클릭하여 업로드 +
+
+ +
+ +
+
HWPX 파일을 업로드하면 미리보기가 표시됩니다
+
+ +
+ 대기 중 + +
+ + + + + +""" + + +def main(): + parser = argparse.ArgumentParser(description="md2hwp Viewer Server") + parser.add_argument("--port", type=int, default=8080, help="Port (default: 8080)") + args = parser.parse_args() + + init_session() + + server = ThreadingHTTPServer(("127.0.0.1", args.port), Handler) + server.daemon_threads = True + print(f"md2hwp Viewer running at http://localhost:{args.port}") + print(f"Event file: {EVENT_FILE_PATH}") + print("Press Ctrl+C to stop") + + try: + server.serve_forever() + except KeyboardInterrupt: + print("\nStopping...") + server.server_close() + if STATE["upload_dir"] and os.path.exists(STATE["upload_dir"]): + shutil.rmtree(STATE["upload_dir"]) + + +if __name__ == "__main__": + main() diff --git a/tools/md2hwp/fill_hwpx.py b/tools/md2hwp/fill_hwpx.py new file mode 100644 index 0000000..cebcced --- /dev/null +++ b/tools/md2hwp/fill_hwpx.py @@ -0,0 +1,355 @@ +#!/usr/bin/env python3 +"""fill_hwpx.py - Template Injection for HWPX files. + +Reads a fill_plan.json and applies text replacements to an HWPX template, +preserving all original formatting (cell sizes, merge patterns, styles). + +Uses direct XML manipulation (zipfile + lxml) to handle ALL text elements +including those inside table cells, which python-hwpx's iter_runs() misses. + +Usage: + python3 fill_hwpx.py + python3 fill_hwpx.py -o + python3 fill_hwpx.py --inspect # List all text runs + python3 fill_hwpx.py --inspect -q # Search for text +""" + +import json +import sys +import os +import argparse +import shutil +import tempfile +import zipfile +from pathlib import Path + +try: + from lxml import etree +except ImportError: + import xml.etree.ElementTree as etree + print("WARNING: lxml not installed, using stdlib xml.etree (less robust)", file=sys.stderr) + +# HWPX namespaces +HWPX_NS = { + "hp": "http://www.hancom.co.kr/hwpml/2011/paragraph", + "hs": "http://www.hancom.co.kr/hwpml/2011/section", + "hc": "http://www.hancom.co.kr/hwpml/2011/core", + "hh": "http://www.hancom.co.kr/hwpml/2011/head", + "ha": "http://www.hancom.co.kr/hwpml/2011/app", + "hp10": "http://www.hancom.co.kr/hwpml/2016/paragraph", +} + +HP_T_TAG = f"{{{HWPX_NS['hp']}}}t" +HP_TC_TAG = f"{{{HWPX_NS['hp']}}}tc" +HP_TBL_TAG = f"{{{HWPX_NS['hp']}}}tbl" +HP_P_TAG = f"{{{HWPX_NS['hp']}}}p" +HP_RUN_TAG = f"{{{HWPX_NS['hp']}}}run" +HP_SUBLIST_TAG = f"{{{HWPX_NS['hp']}}}subList" +HP_CELLADDR_TAG = f"{{{HWPX_NS['hp']}}}cellAddr" +HP_CELLSPAN_TAG = f"{{{HWPX_NS['hp']}}}cellSpan" + +# Event logging for real-time UI +EVENT_FILE = os.environ.get("MD2HWP_EVENT_FILE") + + +def _log_event(event: dict) -> None: + """Append event to JSONL file for SSE streaming.""" + if not EVENT_FILE: + return + with open(EVENT_FILE, "a", encoding="utf-8") as f: + f.write(json.dumps(event, ensure_ascii=False) + "\n") + + +def load_plan(plan_path: str) -> dict: + """Load and validate fill_plan.json.""" + with open(plan_path, encoding="utf-8") as f: + plan = json.load(f) + + required_keys = ["template_file", "output_file"] + for key in required_keys: + if key not in plan: + raise ValueError(f"Missing required key in fill_plan.json: {key}") + + if not os.path.exists(plan["template_file"]): + raise FileNotFoundError(f"Template file not found: {plan['template_file']}") + + return plan + + +def find_section_xmls(hwpx_path: str) -> list[str]: + """Find all section XML files in HWPX archive.""" + sections = [] + with zipfile.ZipFile(hwpx_path, "r") as zf: + for name in zf.namelist(): + if name.startswith("Contents/section") and name.endswith(".xml"): + sections.append(name) + sections.sort() + return sections + + +def get_all_text_elements(tree) -> list: + """Get all text elements from an XML tree.""" + return tree.findall(f".//{HP_T_TAG}") + + +def apply_simple_replacements_xml(tree, replacements: list) -> int: + """Apply exact text match replacements on XML tree. + + Each replacement: {"find": str, "replace": str, "occurrence"?: int} + Replacements are sorted by find text length (longest first) to prevent + shorter matches from breaking longer ones. + """ + total = 0 + text_elements = get_all_text_elements(tree) + + # Sort by find text length descending to avoid partial match conflicts + sorted_replacements = sorted(replacements, key=lambda r: len(r["find"]), reverse=True) + + for r in sorted_replacements: + find_text = r["find"] + replace_text = r["replace"] + limit = r.get("occurrence") + count = 0 + + for elem_idx, elem in enumerate(text_elements): + if elem.text and find_text in elem.text: + elem.text = elem.text.replace(find_text, replace_text, 1) + count += 1 + _log_event({"type": "replace", "idx": elem_idx, "find": find_text, "replace": replace_text}) + if limit and count >= limit: + break + + total += count + find_display = find_text[:50] + ("..." if len(find_text) > 50 else "") + replace_display = replace_text[:50] + ("..." if len(replace_text) > 50 else "") + if count == 0: + print(f" WARNING: '{find_display}' not found", file=sys.stderr) + else: + print(f" Replaced '{find_display}' -> '{replace_display}' ({count}x)") + + return total + + +def apply_section_replacements_xml(tree, replacements: list) -> int: + """Replace guide text with actual content on XML tree. + + Each replacement: {"section_id": str, "guide_text_prefix": str, "content": str} + """ + total = 0 + text_elements = get_all_text_elements(tree) + + for r in replacements: + prefix = r["guide_text_prefix"] + content = r["content"] + section_id = r.get("section_id", "?") + replaced = False + + for elem_idx, elem in enumerate(text_elements): + if elem.text and prefix in elem.text: + elem.text = elem.text.replace(prefix, content, 1) + total += 1 + replaced = True + _log_event({"type": "replace", "idx": elem_idx, "find": prefix, "replace": content}) + print(f" Section {section_id}: replaced guide text") + break + + if not replaced: + prefix_display = prefix[:50] + ("..." if len(prefix) > 50 else "") + print( + f" WARNING: Section {section_id} guide text not found: '{prefix_display}'", + file=sys.stderr, + ) + + return total + + +def apply_table_cell_fills_xml(tree, fills: list) -> int: + """Fill table cells by finding label text and replacing the adjacent value cell. + + Each fill: {"find_label": str, "value": str} + + Strategy: Find with the label text, then find the next element + that is in a different table cell (different parent chain) and replace it. + """ + total = 0 + text_elements = get_all_text_elements(tree) + + # Build parent map for cell boundary detection + parent_map = {} + for parent in tree.iter(): + for child in parent: + parent_map[child] = parent + + def get_cell_ancestor(elem): + """Walk up to find the nearest table cell (hp:tc or similar).""" + current = elem + while current is not None: + tag = current.tag if isinstance(current.tag, str) else "" + if "tc" in tag.lower() or "cell" in tag.lower(): + return current + current = parent_map.get(current) + return None + + for fill in fills: + label = fill["find_label"] + value = fill["value"] + found = False + + for i, elem in enumerate(text_elements): + if elem.text and label in elem.text: + label_cell = get_cell_ancestor(elem) + + # Look for next non-empty text in a DIFFERENT cell + for j in range(i + 1, min(i + 30, len(text_elements))): + next_elem = text_elements[j] + if next_elem.text and next_elem.text.strip(): + next_cell = get_cell_ancestor(next_elem) + # Only replace if it's in a different cell (or no cell found) + if next_cell is not label_cell or next_cell is None: + next_elem.text = value + total += 1 + found = True + _log_event({"type": "replace", "idx": j, "find": label, "replace": value}) + value_display = value[:40] + ("..." if len(value) > 40 else "") + print(f" Table cell '{label}' -> '{value_display}'") + break + break + + if not found: + print(f" WARNING: Table label '{label}' not found or no adjacent cell", file=sys.stderr) + + return total + + +def fill_hwpx(plan: dict, output_path: str) -> int: + """Main fill operation: copy template, modify XML, save.""" + template_path = plan["template_file"] + + # Copy template to output + shutil.copy2(template_path, output_path) + + # Find section XMLs + section_files = find_section_xmls(template_path) + if not section_files: + raise ValueError("No section XML files found in HWPX") + + print(f"Found {len(section_files)} section(s): {', '.join(section_files)}") + + total_replacements = 0 + + # Process each section XML + with zipfile.ZipFile(template_path, "r") as zf_in: + for section_file in section_files: + xml_bytes = zf_in.read(section_file) + tree = etree.fromstring(xml_bytes) + + section_total = 0 + + # 1. Simple replacements + if plan.get("simple_replacements"): + print(f"\n--- Simple Replacements ({section_file}) ---") + section_total += apply_simple_replacements_xml(tree, plan["simple_replacements"]) + + # 2. Section replacements + if plan.get("section_replacements"): + print(f"\n--- Section Replacements ({section_file}) ---") + section_total += apply_section_replacements_xml(tree, plan["section_replacements"]) + + # 3. Table cell fills + if plan.get("table_cell_fills"): + print(f"\n--- Table Cell Fills ({section_file}) ---") + section_total += apply_table_cell_fills_xml(tree, plan["table_cell_fills"]) + + total_replacements += section_total + + if section_total > 0: + # Write modified XML back into the ZIP + modified_xml = etree.tostring(tree, xml_declaration=True, encoding="UTF-8") + _update_zip_file(output_path, section_file, modified_xml) + print(f" Updated {section_file} ({section_total} replacements)") + + _log_event({"type": "done", "total": total_replacements, "output": output_path}) + return total_replacements + + +def _update_zip_file(zip_path: str, target_file: str, new_content: bytes) -> None: + """Replace a single file inside a ZIP archive.""" + tmp_fd, tmp_path = tempfile.mkstemp(suffix=".hwpx") + os.close(tmp_fd) + + with zipfile.ZipFile(zip_path, "r") as zf_in, \ + zipfile.ZipFile(tmp_path, "w") as zf_out: + for item in zf_in.infolist(): + if item.filename == target_file: + zf_out.writestr(item, new_content) + else: + zf_out.writestr(item, zf_in.read(item.filename)) + + shutil.move(tmp_path, zip_path) + + +def inspect_template(template_path: str, query: str | None = None) -> None: + """List all text elements in a template for debugging. + + Uses direct XML parsing to find ALL text including table cells. + """ + section_files = find_section_xmls(template_path) + + total_elements = 0 + with zipfile.ZipFile(template_path, "r") as zf: + for section_file in section_files: + xml_bytes = zf.read(section_file) + tree = etree.fromstring(xml_bytes) + text_elements = get_all_text_elements(tree) + total_elements += len(text_elements) + + print(f"Section: {section_file} ({len(text_elements)} text elements)\n") + + for i, elem in enumerate(text_elements): + text = elem.text or "" + if not text.strip(): + continue + if query and query.lower() not in text.lower(): + continue + display = text[:100] + ("..." if len(text) > 100 else "") + print(f" [{i:4d}] {display}") + + print(f"\nTotal elements: {total_elements}") + + +def main(): + parser = argparse.ArgumentParser(description="Fill HWPX template with content") + parser.add_argument("plan", nargs="?", help="Path to fill_plan.json") + parser.add_argument("-o", "--output", help="Override output path") + parser.add_argument("--inspect", metavar="HWPX", help="Inspect template text runs") + parser.add_argument("-q", "--query", help="Filter runs by text (with --inspect)") + args = parser.parse_args() + + if args.inspect: + inspect_template(args.inspect, args.query) + return + + if not args.plan: + parser.error("fill_plan.json is required (or use --inspect)") + + # Load plan + plan = load_plan(args.plan) + template_path = plan["template_file"] + output_path = args.output or plan["output_file"] + + print(f"Template: {template_path}") + print(f"Output: {output_path}") + print() + + # Fill template + total = fill_hwpx(plan, output_path) + + # Report + size = os.path.getsize(output_path) + print(f"\n--- Done ---") + print(f"Saved: {output_path} ({size:,} bytes)") + print(f"Total replacements: {total}") + + +if __name__ == "__main__": + main() From f9402bbab09fc14e8a0b3fe868db851cacaf8bad Mon Sep 17 00:00:00 2001 From: Baekho Lim Date: Tue, 3 Mar 2026 07:23:20 +0900 Subject: [PATCH 02/24] docs: add collaboration workflow to CLAUDE.md + refine AGENTS.md - CLAUDE.md: Add md2hwp section with collaboration model - AGENTS.md: Fix file paths (scripts/ -> tools/md2hwp/) Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index b39b34d..0b98817 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -74,6 +74,31 @@ HWP/HWPX → Stage 1 (Parser) → IR → Stage 2 (LLM, optional) → Markdown | `HWP2MD_BASE_URL` | Private API endpoint (Bedrock, Azure, local) | | `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GOOGLE_API_KEY`, `UPSTAGE_API_KEY` | Provider API keys | +## md2hwp (Reverse Pipeline) + +HWPX template injection engine: fill government templates with business plan content. + +- **Engine**: `tools/md2hwp/fill_hwpx.py` (Python, lxml) +- **Design doc**: `docs/md2hwp/DESIGN.md` +- **Test template**: `testdata/hwpx_20260302_200059.hwpx` (재도전성공패키지) + +```bash +# Inspect template +python3 tools/md2hwp/fill_hwpx.py --inspect +python3 tools/md2hwp/fill_hwpx.py --inspect-tables + +# Fill template +python3 tools/md2hwp/fill_hwpx.py +``` + +### Collaboration + +- **Claude (architect)**: Design, review PRs, integration testing +- **ChatGPT Codex (implementer)**: Code implementation, unit tests +- **Sync point**: GitHub issues on baekho-lim/hwp2md +- **Codex instructions**: `AGENTS.md` +- **Epic**: baekho-lim/hwp2md#7 + ## Conventions - Korean is the primary language for CLI messages, comments, and documentation From 8ecbbd68d618f79ee912e519ca5692e3131df52d Mon Sep 17 00:00:00 2001 From: Baekho Lim Date: Tue, 3 Mar 2026 07:28:17 +0900 Subject: [PATCH 03/24] feat(md2hwp): add XML tree navigation helper functions --- tools/md2hwp/fill_hwpx.py | 100 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) diff --git a/tools/md2hwp/fill_hwpx.py b/tools/md2hwp/fill_hwpx.py index cebcced..d4b5673 100644 --- a/tools/md2hwp/fill_hwpx.py +++ b/tools/md2hwp/fill_hwpx.py @@ -60,6 +60,106 @@ def _log_event(event: dict) -> None: f.write(json.dumps(event, ensure_ascii=False) + "\n") +def _build_parent_map(tree) -> dict: + """Build element-to-parent mapping for ancestor traversal.""" + parent_map = {} + for parent in tree.iter(): + for child in parent: + parent_map[child] = parent + return parent_map + + +def _local_name(tag: str) -> str: + """Extract local tag name from a namespaced XML tag.""" + if "}" in tag: + return tag.split("}", 1)[1] + return tag + + +def _get_ancestor(elem, tag_local: str, parent_map: dict): + """Walk up parent chain to find ancestor by local tag name.""" + current = parent_map.get(elem) + while current is not None: + tag = current.tag if isinstance(current.tag, str) else "" + if _local_name(tag) == tag_local: + return current + current = parent_map.get(current) + return None + + +def _find_cell_by_addr(tbl, col: int, row: int): + """Find by its coordinates.""" + for tc in tbl.findall(f".//{HP_TC_TAG}"): + cell_addr = tc.find(f"./{HP_CELLADDR_TAG}") + if cell_addr is None: + continue + try: + col_addr = int(cell_addr.get("colAddr", "-1")) + row_addr = int(cell_addr.get("rowAddr", "-1")) + except ValueError: + continue + if col_addr == col and row_addr == row: + return tc + return None + + +def _set_cell_text(tc, text: str) -> None: + """Set cell text, creating in first when absent.""" + run = tc.find(f".//{HP_RUN_TAG}") + if run is None: + sub_list = tc.find(f"./{HP_SUBLIST_TAG}") + if sub_list is None: + sub_list = etree.Element(HP_SUBLIST_TAG) + tc.insert(0, sub_list) + paragraph = sub_list.find(f"./{HP_P_TAG}") + if paragraph is None: + paragraph = etree.Element(HP_P_TAG) + sub_list.append(paragraph) + run = etree.Element(HP_RUN_TAG) + paragraph.append(run) + + text_elem = run.find(f"./{HP_T_TAG}") + if text_elem is None: + text_elem = etree.Element(HP_T_TAG) + run.append(text_elem) + + text_elem.text = text + for child in list(text_elem): + text_elem.remove(child) + + +def _clear_cell_except(tc, keep_elem, parent_map: dict) -> None: + """Clear a cell except the run/paragraph containing keep_elem.""" + keep_run = _get_ancestor(keep_elem, "run", parent_map) + keep_paragraph = _get_ancestor(keep_elem, "p", parent_map) + + for paragraph in list(tc.findall(f".//{HP_P_TAG}")): + paragraph_parent = parent_map.get(paragraph) + if paragraph is not keep_paragraph: + if paragraph_parent is not None: + paragraph_parent.remove(paragraph) + continue + + for run in list(paragraph.findall(f"./{HP_RUN_TAG}")): + if run is keep_run: + continue + paragraph.remove(run) + + if keep_run is not None: + for text_elem in list(keep_run.findall(f"./{HP_T_TAG}")): + if text_elem is keep_elem: + continue + keep_run.remove(text_elem) + + +def _get_table_index(tree, tbl) -> int: + """Return ordinal table index in document tree.""" + for idx, candidate in enumerate(tree.findall(f".//{HP_TBL_TAG}")): + if candidate is tbl: + return idx + return -1 + + def load_plan(plan_path: str) -> dict: """Load and validate fill_plan.json.""" with open(plan_path, encoding="utf-8") as f: From 83424d18869a2ad48cf4c4c0f3b81f182c109aae Mon Sep 17 00:00:00 2001 From: Baekho Lim Date: Tue, 3 Mar 2026 07:29:46 +0900 Subject: [PATCH 04/24] feat(md2hwp): rewrite section replacements with cell-scoped clearing --- tools/md2hwp/fill_hwpx.py | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/tools/md2hwp/fill_hwpx.py b/tools/md2hwp/fill_hwpx.py index d4b5673..c05a828 100644 --- a/tools/md2hwp/fill_hwpx.py +++ b/tools/md2hwp/fill_hwpx.py @@ -235,22 +235,35 @@ def apply_section_replacements_xml(tree, replacements: list) -> int: Each replacement: {"section_id": str, "guide_text_prefix": str, "content": str} """ - total = 0 + parent_map = _build_parent_map(tree) text_elements = get_all_text_elements(tree) + total = 0 for r in replacements: prefix = r["guide_text_prefix"] content = r["content"] section_id = r.get("section_id", "?") + clear_cell = r.get("clear_cell", True) replaced = False for elem_idx, elem in enumerate(text_elements): if elem.text and prefix in elem.text: - elem.text = elem.text.replace(prefix, content, 1) + elem.text = content + for child in list(elem): + elem.remove(child) + + if clear_cell: + cell = _get_ancestor(elem, "tc", parent_map) + if cell is not None: + _clear_cell_except(cell, elem, parent_map) + total += 1 replaced = True _log_event({"type": "replace", "idx": elem_idx, "find": prefix, "replace": content}) - print(f" Section {section_id}: replaced guide text") + if clear_cell: + print(f" Section {section_id}: replaced guide text (cell cleared)") + else: + print(f" Section {section_id}: replaced guide text") break if not replaced: From 6647de300a6a0f6229e99e11af0634d77fb85739 Mon Sep 17 00:00:00 2001 From: Baekho Lim Date: Tue, 3 Mar 2026 07:31:59 +0900 Subject: [PATCH 05/24] feat(md2hwp): rewrite table cell fills with cellAddr lookup --- tools/md2hwp/fill_hwpx.py | 105 +++++++++++++++++++++++++------------- 1 file changed, 69 insertions(+), 36 deletions(-) diff --git a/tools/md2hwp/fill_hwpx.py b/tools/md2hwp/fill_hwpx.py index c05a828..33d62ab 100644 --- a/tools/md2hwp/fill_hwpx.py +++ b/tools/md2hwp/fill_hwpx.py @@ -281,51 +281,84 @@ def apply_table_cell_fills_xml(tree, fills: list) -> int: Each fill: {"find_label": str, "value": str} - Strategy: Find with the label text, then find the next element - that is in a different table cell (different parent chain) and replace it. + Primary strategy: cellAddr-based table lookup by offset. + Fallback strategy: flat scan for next text element in a different cell. """ total = 0 + parent_map = _build_parent_map(tree) text_elements = get_all_text_elements(tree) - # Build parent map for cell boundary detection - parent_map = {} - for parent in tree.iter(): - for child in parent: - parent_map[child] = parent - - def get_cell_ancestor(elem): - """Walk up to find the nearest table cell (hp:tc or similar).""" - current = elem - while current is not None: - tag = current.tag if isinstance(current.tag, str) else "" - if "tc" in tag.lower() or "cell" in tag.lower(): - return current - current = parent_map.get(current) - return None - for fill in fills: label = fill["find_label"] value = fill["value"] + offset = fill.get("target_offset", {"col": 1, "row": 0}) + offset_col = int(offset.get("col", 1)) + offset_row = int(offset.get("row", 0)) found = False - for i, elem in enumerate(text_elements): - if elem.text and label in elem.text: - label_cell = get_cell_ancestor(elem) - - # Look for next non-empty text in a DIFFERENT cell - for j in range(i + 1, min(i + 30, len(text_elements))): - next_elem = text_elements[j] - if next_elem.text and next_elem.text.strip(): - next_cell = get_cell_ancestor(next_elem) - # Only replace if it's in a different cell (or no cell found) - if next_cell is not label_cell or next_cell is None: - next_elem.text = value - total += 1 - found = True - _log_event({"type": "replace", "idx": j, "find": label, "replace": value}) - value_display = value[:40] + ("..." if len(value) > 40 else "") - print(f" Table cell '{label}' -> '{value_display}'") - break + exact_matches = [ + (i, elem) + for i, elem in enumerate(text_elements) + if elem.text and elem.text.strip() == label + ] + contains_matches = [ + (i, elem) + for i, elem in enumerate(text_elements) + if elem.text and label in elem.text + ] + matches = exact_matches if exact_matches else contains_matches + + for i, elem in matches: + label_cell = _get_ancestor(elem, "tc", parent_map) + if label_cell is None: + continue + + table = _get_ancestor(label_cell, "tbl", parent_map) + label_addr = label_cell.find(f"./{HP_CELLADDR_TAG}") + + # Primary: cellAddr lookup with configurable target offset. + if table is not None and label_addr is not None: + try: + label_col = int(label_addr.get("colAddr", "-1")) + label_row = int(label_addr.get("rowAddr", "-1")) + except ValueError: + label_col = -1 + label_row = -1 + + target_col = label_col + offset_col + target_row = label_row + offset_row + target_cell = _find_cell_by_addr(table, target_col, target_row) + if target_cell is not None: + _set_cell_text(target_cell, value) + total += 1 + found = True + table_idx = _get_table_index(tree, table) + _log_event({"type": "replace", "idx": i, "find": label, "replace": value}) + value_display = value[:40] + ("..." if len(value) > 40 else "") + print( + f" Table cell '{label}' -> '{value_display}' " + f"(T{table_idx} R{target_row} C{target_col})" + ) + break + + # Fallback: flat scan for first text element in a different cell. + for j in range(i + 1, min(i + 50, len(text_elements))): + next_elem = text_elements[j] + next_cell = _get_ancestor(next_elem, "tc", parent_map) + if next_cell is label_cell and next_cell is not None: + continue + + next_elem.text = value + for child in list(next_elem): + next_elem.remove(child) + total += 1 + found = True + _log_event({"type": "replace", "idx": j, "find": label, "replace": value}) + value_display = value[:40] + ("..." if len(value) > 40 else "") + print(f" Table cell '{label}' -> '{value_display}' (fallback)") + break + + if found: break if not found: From 51c107d05bf27df065004a190873c44f5e330d5d Mon Sep 17 00:00:00 2001 From: Baekho Lim Date: Tue, 3 Mar 2026 07:32:53 +0900 Subject: [PATCH 06/24] feat(md2hwp): enhance inspect with table context and table mode --- tools/md2hwp/fill_hwpx.py | 83 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 81 insertions(+), 2 deletions(-) diff --git a/tools/md2hwp/fill_hwpx.py b/tools/md2hwp/fill_hwpx.py index 33d62ab..11c3ad9 100644 --- a/tools/md2hwp/fill_hwpx.py +++ b/tools/md2hwp/fill_hwpx.py @@ -12,6 +12,7 @@ python3 fill_hwpx.py -o python3 fill_hwpx.py --inspect # List all text runs python3 fill_hwpx.py --inspect -q # Search for text + python3 fill_hwpx.py --inspect-tables # Show table structure """ import json @@ -447,6 +448,7 @@ def inspect_template(template_path: str, query: str | None = None) -> None: xml_bytes = zf.read(section_file) tree = etree.fromstring(xml_bytes) text_elements = get_all_text_elements(tree) + parent_map = _build_parent_map(tree) total_elements += len(text_elements) print(f"Section: {section_file} ({len(text_elements)} text elements)\n") @@ -458,25 +460,102 @@ def inspect_template(template_path: str, query: str | None = None) -> None: if query and query.lower() not in text.lower(): continue display = text[:100] + ("..." if len(text) > 100 else "") - print(f" [{i:4d}] {display}") + context = "" + cell = _get_ancestor(elem, "tc", parent_map) + if cell is not None: + table = _get_ancestor(cell, "tbl", parent_map) + cell_addr = cell.find(f"./{HP_CELLADDR_TAG}") + if table is not None and cell_addr is not None: + table_idx = _get_table_index(tree, table) + try: + col = int(cell_addr.get("colAddr", "-1")) + row = int(cell_addr.get("rowAddr", "-1")) + except ValueError: + col = -1 + row = -1 + context = f"[T{table_idx} R{row} C{col}] " + + print(f" [{i:4d}] {context}{display}") print(f"\nTotal elements: {total_elements}") +def _inspect_table_structure(template_path: str) -> None: + """Inspect table layout with cell coordinates and spans.""" + section_files = find_section_xmls(template_path) + + with zipfile.ZipFile(template_path, "r") as zf: + for section_file in section_files: + xml_bytes = zf.read(section_file) + tree = etree.fromstring(xml_bytes) + tables = tree.findall(f".//{HP_TBL_TAG}") + + print(f"Section: {section_file} ({len(tables)} tables)\n") + + for table_idx, table in enumerate(tables): + row_cnt = table.get("rowCnt", "?") + col_cnt = table.get("colCnt", "?") + print(f" Table {table_idx}: {row_cnt} rows x {col_cnt} cols") + + cell_infos = [] + for cell in table.findall(f".//{HP_TC_TAG}"): + cell_addr = cell.find(f"./{HP_CELLADDR_TAG}") + if cell_addr is None: + continue + try: + col = int(cell_addr.get("colAddr", "-1")) + row = int(cell_addr.get("rowAddr", "-1")) + except ValueError: + col = -1 + row = -1 + + cell_span = cell.find(f"./{HP_CELLSPAN_TAG}") + if cell_span is not None: + try: + col_span = int(cell_span.get("colSpan", "1")) + row_span = int(cell_span.get("rowSpan", "1")) + except ValueError: + col_span = 1 + row_span = 1 + else: + col_span = 1 + row_span = 1 + + text_parts = [t.text for t in cell.findall(f".//{HP_T_TAG}") if t.text] + text = "".join(text_parts).strip() + if not text: + text = "[EMPTY]" + + cell_infos.append((row, col, col_span, row_span, text)) + + cell_infos.sort(key=lambda x: (x[0], x[1])) + for row, col, col_span, row_span, text in cell_infos: + span = "" + if col_span > 1 or row_span > 1: + span = f" (span {col_span}x{row_span})" + print(f" R{row} C{col}{span}: {text}") + + print() + + def main(): parser = argparse.ArgumentParser(description="Fill HWPX template with content") parser.add_argument("plan", nargs="?", help="Path to fill_plan.json") parser.add_argument("-o", "--output", help="Override output path") parser.add_argument("--inspect", metavar="HWPX", help="Inspect template text runs") + parser.add_argument("--inspect-tables", metavar="HWPX", help="Show table structure of template") parser.add_argument("-q", "--query", help="Filter runs by text (with --inspect)") args = parser.parse_args() if args.inspect: inspect_template(args.inspect, args.query) return + if args.inspect_tables: + _inspect_table_structure(args.inspect_tables) + return if not args.plan: - parser.error("fill_plan.json is required (or use --inspect)") + parser.error("fill_plan.json is required (or use --inspect / --inspect-tables)") # Load plan plan = load_plan(args.plan) From 97daa93e9bbe67f6ee430d021692864f005caaa6 Mon Sep 17 00:00:00 2001 From: Baekho Lim Date: Tue, 3 Mar 2026 08:11:11 +0900 Subject: [PATCH 07/24] feat(md2hwp): add multi-paragraph content injection --- tools/md2hwp/fill_hwpx.py | 72 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/tools/md2hwp/fill_hwpx.py b/tools/md2hwp/fill_hwpx.py index 11c3ad9..65c3f11 100644 --- a/tools/md2hwp/fill_hwpx.py +++ b/tools/md2hwp/fill_hwpx.py @@ -22,6 +22,7 @@ import shutil import tempfile import zipfile +from copy import deepcopy from pathlib import Path try: @@ -368,6 +369,72 @@ def apply_table_cell_fills_xml(tree, fills: list) -> int: return total +def _create_paragraph(ref_p, text: str): + """Create paragraph by cloning reference paragraph style/layout.""" + new_p = deepcopy(ref_p) + + runs = new_p.findall(f"./{HP_RUN_TAG}") + if not runs: + run = etree.Element(HP_RUN_TAG) + new_p.append(run) + runs = [run] + + for run in runs[1:]: + new_p.remove(run) + + t_elem = runs[0].find(f"./{HP_T_TAG}") + if t_elem is None: + t_elem = etree.SubElement(runs[0], HP_T_TAG) + t_elem.text = text + for child in list(t_elem): + t_elem.remove(child) + + return new_p + + +def apply_multi_paragraph_fills(tree, fills: list) -> int: + """Inject multi-paragraph content into a target cell.""" + parent_map = _build_parent_map(tree) + text_elements = get_all_text_elements(tree) + total = 0 + + for fill in fills: + section_id = fill.get("section_id", "?") + prefix = fill["guide_text_prefix"] + paragraphs = fill.get("paragraphs", []) + replaced = False + + for elem_idx, elem in enumerate(text_elements): + if not (elem.text and prefix in elem.text): + continue + + cell = _get_ancestor(elem, "tc", parent_map) + if cell is None: + continue + sub_list = cell.find(f"./{HP_SUBLIST_TAG}") + if sub_list is None: + continue + ref_p = sub_list.find(f"./{HP_P_TAG}") + if ref_p is None: + continue + + for paragraph in list(sub_list.findall(f"./{HP_P_TAG}")): + sub_list.remove(paragraph) + for paragraph_text in paragraphs: + sub_list.append(_create_paragraph(ref_p, paragraph_text)) + + total += 1 + replaced = True + _log_event({"type": "replace", "idx": elem_idx, "find": prefix, "replace": f"{len(paragraphs)} paragraphs"}) + print(f" Section {section_id}: inserted {len(paragraphs)} paragraph(s)") + break + + if not replaced: + print(f" WARNING: Section {section_id} multi-paragraph target not found", file=sys.stderr) + + return total + + def fill_hwpx(plan: dict, output_path: str) -> int: """Main fill operation: copy template, modify XML, save.""" template_path = plan["template_file"] @@ -407,6 +474,11 @@ def fill_hwpx(plan: dict, output_path: str) -> int: print(f"\n--- Table Cell Fills ({section_file}) ---") section_total += apply_table_cell_fills_xml(tree, plan["table_cell_fills"]) + # 4. Multi-paragraph fills + if plan.get("multi_paragraph_fills"): + print(f"\n--- Multi Paragraph Fills ({section_file}) ---") + section_total += apply_multi_paragraph_fills(tree, plan["multi_paragraph_fills"]) + total_replacements += section_total if section_total > 0: From b7ee42d821d6f3c9a9efb4192f0059be6163e8a4 Mon Sep 17 00:00:00 2001 From: Baekho Lim Date: Tue, 3 Mar 2026 08:12:45 +0900 Subject: [PATCH 08/24] feat(md2hwp): add analyze mode for template schema extraction --- tools/md2hwp/fill_hwpx.py | 120 +++++++++++++++++++++++++++++++++++++- 1 file changed, 119 insertions(+), 1 deletion(-) diff --git a/tools/md2hwp/fill_hwpx.py b/tools/md2hwp/fill_hwpx.py index 65c3f11..73780ed 100644 --- a/tools/md2hwp/fill_hwpx.py +++ b/tools/md2hwp/fill_hwpx.py @@ -13,12 +13,14 @@ python3 fill_hwpx.py --inspect # List all text runs python3 fill_hwpx.py --inspect -q # Search for text python3 fill_hwpx.py --inspect-tables # Show table structure + python3 fill_hwpx.py --analyze # Output fillable schema """ import json import sys import os import argparse +import re import shutil import tempfile import zipfile @@ -52,6 +54,7 @@ # Event logging for real-time UI EVENT_FILE = os.environ.get("MD2HWP_EVENT_FILE") +PLACEHOLDER_PATTERNS = [r"OO+", r"○{2,}", r"0{3,}"] def _log_event(event: dict) -> None: @@ -610,12 +613,124 @@ def _inspect_table_structure(template_path: str) -> None: print() +def _get_cell_text(tc) -> str: + """Get normalized text content of a table cell.""" + return "".join((t.text or "") for t in tc.findall(f".//{HP_T_TAG}")).strip() + + +def _parse_cell_addr(tc) -> tuple[int | None, int | None]: + """Parse cell address (colAddr, rowAddr) from .""" + cell_addr = tc.find(f"./{HP_CELLADDR_TAG}") + if cell_addr is None: + return None, None + try: + return int(cell_addr.get("colAddr", "-1")), int(cell_addr.get("rowAddr", "-1")) + except ValueError: + return None, None + + +def _detect_placeholder_pattern(text: str) -> str | None: + """Detect placeholder-like patterns such as OO/○○/000.""" + for pattern in PLACEHOLDER_PATTERNS: + match = re.search(pattern, text) + if match: + return match.group(0) + return None + + +def _extract_table_schema(tree) -> list[dict]: + """Extract table layout and cell fillability metadata.""" + tables = [] + for table_idx, table in enumerate(tree.findall(f".//{HP_TBL_TAG}")): + row_cnt = table.get("rowCnt", "0") + col_cnt = table.get("colCnt", "0") + table_info = { + "index": table_idx, + "rows": int(row_cnt) if row_cnt.isdigit() else 0, + "cols": int(col_cnt) if col_cnt.isdigit() else 0, + "cells": [], + } + for cell in table.findall(f".//{HP_TC_TAG}"): + col, row = _parse_cell_addr(cell) + if col is None or row is None: + continue + text = _get_cell_text(cell) + cell_info = {"row": row, "col": col, "text": text} + if not text: + cell_info["is_empty"] = True + elif col == 0: + cell_info["is_label"] = True + table_info["cells"].append(cell_info) + + table_info["cells"].sort(key=lambda c: (c["row"], c["col"])) + tables.append(table_info) + return tables + + +def _extract_text_markers(tree, index_offset: int) -> tuple[list[dict], list[dict]]: + """Extract guide text markers and placeholders from text elements.""" + guide_texts = [] + placeholders = [] + parent_map = _build_parent_map(tree) + text_elements = get_all_text_elements(tree) + + for local_idx, elem in enumerate(text_elements): + text = (elem.text or "").strip() + if not text: + continue + element_index = index_offset + local_idx + cell = _get_ancestor(elem, "tc", parent_map) + table = _get_ancestor(cell, "tbl", parent_map) if cell is not None else None + table_index = _get_table_index(tree, table) if table is not None else -1 + col, row = _parse_cell_addr(cell) if cell is not None else (None, None) + + if text.startswith("※"): + guide = {"element_index": element_index, "prefix": text[:100]} + if table_index >= 0: + guide["table_index"] = table_index + if row is not None and col is not None: + guide["cell"] = f"R{row}C{col}" + guide_texts.append(guide) + + pattern = _detect_placeholder_pattern(text) + if pattern: + placeholders.append({"element_index": element_index, "text": text, "pattern": pattern}) + + return guide_texts, placeholders + + +def analyze_template(template_path: str) -> dict: + """Analyze template and return fillable schema metadata.""" + schema = { + "template_file": template_path, + "total_text_elements": 0, + "tables": [], + "guide_texts": [], + "placeholders": [], + } + index_offset = 0 + + with zipfile.ZipFile(template_path, "r") as zf: + for section_file in find_section_xmls(template_path): + tree = etree.fromstring(zf.read(section_file)) + text_elements = get_all_text_elements(tree) + schema["total_text_elements"] += len(text_elements) + schema["tables"].extend(_extract_table_schema(tree)) + guide_texts, placeholders = _extract_text_markers(tree, index_offset) + schema["guide_texts"].extend(guide_texts) + schema["placeholders"].extend(placeholders) + index_offset += len(text_elements) + + return schema + + def main(): parser = argparse.ArgumentParser(description="Fill HWPX template with content") parser.add_argument("plan", nargs="?", help="Path to fill_plan.json") parser.add_argument("-o", "--output", help="Override output path") parser.add_argument("--inspect", metavar="HWPX", help="Inspect template text runs") parser.add_argument("--inspect-tables", metavar="HWPX", help="Show table structure of template") + parser.add_argument("--analyze", metavar="HWPX", help="Analyze template and output JSON schema") parser.add_argument("-q", "--query", help="Filter runs by text (with --inspect)") args = parser.parse_args() @@ -625,9 +740,12 @@ def main(): if args.inspect_tables: _inspect_table_structure(args.inspect_tables) return + if args.analyze: + print(json.dumps(analyze_template(args.analyze), ensure_ascii=False, indent=2)) + return if not args.plan: - parser.error("fill_plan.json is required (or use --inspect / --inspect-tables)") + parser.error("fill_plan.json is required (or use --inspect / --inspect-tables / --analyze)") # Load plan plan = load_plan(args.plan) From b22f28c837ad567f235f963f2f557c0f170442ff Mon Sep 17 00:00:00 2001 From: Baekho Lim Date: Tue, 3 Mar 2026 09:24:50 +0900 Subject: [PATCH 09/24] fix(md2hwp): prevent fallback table fill from overwriting body text --- tools/md2hwp/fill_hwpx.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/md2hwp/fill_hwpx.py b/tools/md2hwp/fill_hwpx.py index 73780ed..959ff0c 100644 --- a/tools/md2hwp/fill_hwpx.py +++ b/tools/md2hwp/fill_hwpx.py @@ -350,7 +350,7 @@ def apply_table_cell_fills_xml(tree, fills: list) -> int: for j in range(i + 1, min(i + 50, len(text_elements))): next_elem = text_elements[j] next_cell = _get_ancestor(next_elem, "tc", parent_map) - if next_cell is label_cell and next_cell is not None: + if next_cell is None or next_cell is label_cell: continue next_elem.text = value From 293670623098994f172a0c602d2cce936aa1f1bd Mon Sep 17 00:00:00 2001 From: Baekho Lim Date: Tue, 3 Mar 2026 09:25:23 +0900 Subject: [PATCH 10/24] fix(md2hwp): validate fill plan selectors and paragraph lists --- tools/md2hwp/fill_hwpx.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tools/md2hwp/fill_hwpx.py b/tools/md2hwp/fill_hwpx.py index 959ff0c..0d4e372 100644 --- a/tools/md2hwp/fill_hwpx.py +++ b/tools/md2hwp/fill_hwpx.py @@ -178,6 +178,24 @@ def load_plan(plan_path: str) -> dict: if not os.path.exists(plan["template_file"]): raise FileNotFoundError(f"Template file not found: {plan['template_file']}") + for r in plan.get("simple_replacements", []): + if not r.get("find"): + raise ValueError("simple_replacements: 'find' must be non-empty") + + for r in plan.get("section_replacements", []): + if not r.get("guide_text_prefix"): + raise ValueError("section_replacements: 'guide_text_prefix' must be non-empty") + + for r in plan.get("table_cell_fills", []): + if not r.get("find_label"): + raise ValueError("table_cell_fills: 'find_label' must be non-empty") + + for r in plan.get("multi_paragraph_fills", []): + if not r.get("guide_text_prefix"): + raise ValueError("multi_paragraph_fills: 'guide_text_prefix' must be non-empty") + if not r.get("paragraphs"): + raise ValueError("multi_paragraph_fills: 'paragraphs' must be non-empty") + return plan From 7512fa66c4891cd7515fb8611c244bec9bd03248 Mon Sep 17 00:00:00 2001 From: Baekho Lim Date: Tue, 3 Mar 2026 09:26:02 +0900 Subject: [PATCH 11/24] fix(md2hwp): scope cell traversal to direct table descendants --- tools/md2hwp/fill_hwpx.py | 49 +++++++++++++++++++++------------------ 1 file changed, 27 insertions(+), 22 deletions(-) diff --git a/tools/md2hwp/fill_hwpx.py b/tools/md2hwp/fill_hwpx.py index 0d4e372..7a73b7f 100644 --- a/tools/md2hwp/fill_hwpx.py +++ b/tools/md2hwp/fill_hwpx.py @@ -94,17 +94,20 @@ def _get_ancestor(elem, tag_local: str, parent_map: dict): def _find_cell_by_addr(tbl, col: int, row: int): """Find by its coordinates.""" - for tc in tbl.findall(f".//{HP_TC_TAG}"): - cell_addr = tc.find(f"./{HP_CELLADDR_TAG}") - if cell_addr is None: - continue - try: - col_addr = int(cell_addr.get("colAddr", "-1")) - row_addr = int(cell_addr.get("rowAddr", "-1")) - except ValueError: - continue - if col_addr == col and row_addr == row: - return tc + for tr in tbl: + for tc in tr: + if tc.tag != HP_TC_TAG: + continue + cell_addr = tc.find(f"./{HP_CELLADDR_TAG}") + if cell_addr is None: + continue + try: + col_addr = int(cell_addr.get("colAddr", "-1")) + row_addr = int(cell_addr.get("rowAddr", "-1")) + except ValueError: + continue + if col_addr == col and row_addr == row: + return tc return None @@ -137,18 +140,20 @@ def _clear_cell_except(tc, keep_elem, parent_map: dict) -> None: """Clear a cell except the run/paragraph containing keep_elem.""" keep_run = _get_ancestor(keep_elem, "run", parent_map) keep_paragraph = _get_ancestor(keep_elem, "p", parent_map) - - for paragraph in list(tc.findall(f".//{HP_P_TAG}")): - paragraph_parent = parent_map.get(paragraph) - if paragraph is not keep_paragraph: - if paragraph_parent is not None: - paragraph_parent.remove(paragraph) - continue - - for run in list(paragraph.findall(f"./{HP_RUN_TAG}")): - if run is keep_run: + sub_list = tc.find(f"./{HP_SUBLIST_TAG}") + + if sub_list is not None: + for paragraph in list(sub_list.findall(f"./{HP_P_TAG}")): + paragraph_parent = parent_map.get(paragraph) + if paragraph is not keep_paragraph: + if paragraph_parent is not None: + paragraph_parent.remove(paragraph) continue - paragraph.remove(run) + + for run in list(paragraph.findall(f"./{HP_RUN_TAG}")): + if run is keep_run: + continue + paragraph.remove(run) if keep_run is not None: for text_elem in list(keep_run.findall(f"./{HP_T_TAG}")): From 5b40f1cb21e359346d8382e253f4b797a3c3db82 Mon Sep 17 00:00:00 2001 From: Baekho Lim Date: Tue, 3 Mar 2026 09:28:02 +0900 Subject: [PATCH 12/24] test(md2hwp): add pytest suite for fill engine --- tests/test_fill_hwpx.py | 376 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 376 insertions(+) create mode 100644 tests/test_fill_hwpx.py diff --git a/tests/test_fill_hwpx.py b/tests/test_fill_hwpx.py new file mode 100644 index 0000000..006ca53 --- /dev/null +++ b/tests/test_fill_hwpx.py @@ -0,0 +1,376 @@ +import json +import subprocess +import sys +import zipfile +from pathlib import Path + +import pytest +from lxml import etree + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT_PATH = ROOT / "tools" / "md2hwp" / "fill_hwpx.py" +sys.path.insert(0, str(SCRIPT_PATH.parent)) + +import fill_hwpx as fh # noqa: E402 + +NS = fh.HWPX_NS["hp"] +TEMPLATE_PATH = ROOT / "testdata" / "hwpx_20260302_200059.hwpx" +HWP2MD_BIN = ROOT / "bin" / "hwp2md" + + +def _make_cell(col, row, text="", colspan=1, rowspan=1, with_text=True): + tc = etree.Element(f"{{{NS}}}tc") + sub = etree.SubElement(tc, f"{{{NS}}}subList") + p = etree.SubElement(sub, f"{{{NS}}}p") + p.set("paraPrIDRef", "31") + p.set("styleIDRef", "0") + run = etree.SubElement(p, f"{{{NS}}}run") + run.set("charPrIDRef", "31") + if with_text: + t = etree.SubElement(run, f"{{{NS}}}t") + t.text = text + etree.SubElement(p, f"{{{NS}}}linesegarray") + addr = etree.SubElement(tc, f"{{{NS}}}cellAddr") + addr.set("colAddr", str(col)) + addr.set("rowAddr", str(row)) + span = etree.SubElement(tc, f"{{{NS}}}cellSpan") + span.set("colSpan", str(colspan)) + span.set("rowSpan", str(rowspan)) + return tc + + +def _make_table(cells): + tbl = etree.Element(f"{{{NS}}}tbl") + row_map = {} + for tc in cells: + row = int(tc.find(f"./{fh.HP_CELLADDR_TAG}").get("rowAddr", "0")) + row_map.setdefault(row, []).append(tc) + for row_idx in sorted(row_map): + tr = etree.SubElement(tbl, f"{{{NS}}}tr") + for tc in sorted( + row_map[row_idx], + key=lambda cell: int(cell.find(f"./{fh.HP_CELLADDR_TAG}").get("colAddr", "0")), + ): + tr.append(tc) + return tbl + + +def _first_text(elem): + t = elem.find(f".//{fh.HP_T_TAG}") + return t.text if t is not None else None + + +def test_build_parent_map(): + root = etree.Element("root") + p = etree.SubElement(root, fh.HP_P_TAG) + run = etree.SubElement(p, fh.HP_RUN_TAG) + t = etree.SubElement(run, fh.HP_T_TAG) + t.text = "A" + parent_map = fh._build_parent_map(root) + assert parent_map[t] is run + assert parent_map[run] is p + + +def test_get_ancestor(): + root = etree.Element("root") + tbl = _make_table([_make_cell(0, 0, "LABEL"), _make_cell(1, 0, "VALUE")]) + root.append(tbl) + t = root.find(f".//{fh.HP_T_TAG}") + parent_map = fh._build_parent_map(root) + assert fh._get_ancestor(t, "tc", parent_map).tag == fh.HP_TC_TAG + assert fh._get_ancestor(t, "tbl", parent_map).tag == fh.HP_TBL_TAG + + +def test_find_cell_by_addr(): + tbl = _make_table([_make_cell(0, 0, "A"), _make_cell(1, 0, "B")]) + tc = fh._find_cell_by_addr(tbl, 1, 0) + assert tc is not None + assert _first_text(tc) == "B" + + +def test_set_cell_text_creates_hp_t_for_empty_cell(): + tc = _make_cell(0, 0, with_text=False) + assert tc.find(f".//{fh.HP_T_TAG}") is None + fh._set_cell_text(tc, "NEW") + assert _first_text(tc) == "NEW" + + +def test_clear_cell_except_removes_other_paragraphs_and_runs(): + tc = _make_cell(0, 0, "KEEP") + sub = tc.find(f"./{fh.HP_SUBLIST_TAG}") + p = sub.find(f"./{fh.HP_P_TAG}") + extra_run = etree.SubElement(p, fh.HP_RUN_TAG) + extra_t = etree.SubElement(extra_run, fh.HP_T_TAG) + extra_t.text = "REMOVE-RUN" + extra_p = etree.SubElement(sub, fh.HP_P_TAG) + extra_run_2 = etree.SubElement(extra_p, fh.HP_RUN_TAG) + extra_t_2 = etree.SubElement(extra_run_2, fh.HP_T_TAG) + extra_t_2.text = "REMOVE-PARA" + + keep_elem = tc.find(f".//{fh.HP_T_TAG}") + root = etree.Element("root") + root.append(_make_table([tc])) + parent_map = fh._build_parent_map(root) + fh._clear_cell_except(tc, keep_elem, parent_map) + + texts = [(t.text or "") for t in tc.findall(f".//{fh.HP_T_TAG}")] + assert texts == ["KEEP"] + + +def test_get_table_index(): + root = etree.Element("root") + tbl0 = _make_table([_make_cell(0, 0, "A")]) + tbl1 = _make_table([_make_cell(0, 0, "B")]) + root.append(tbl0) + root.append(tbl1) + assert fh._get_table_index(root, tbl1) == 1 + + +@pytest.mark.parametrize( + ("text", "pattern"), + [("OO기업", "OO"), ("○○○", "○○○"), ("1000", "000"), ("일반 텍스트", None)], +) +def test_detect_placeholder_pattern(text, pattern): + assert fh._detect_placeholder_pattern(text) == pattern + + +def test_apply_simple_replacements_xml_basic_and_occurrence(): + root = etree.Element("root") + p1 = etree.SubElement(root, fh.HP_P_TAG) + run1 = etree.SubElement(p1, fh.HP_RUN_TAG) + t1 = etree.SubElement(run1, fh.HP_T_TAG) + t1.text = "AB AB" + p2 = etree.SubElement(root, fh.HP_P_TAG) + run2 = etree.SubElement(p2, fh.HP_RUN_TAG) + t2 = etree.SubElement(run2, fh.HP_T_TAG) + t2.text = "AB" + total = fh.apply_simple_replacements_xml( + root, + [{"find": "AB", "replace": "X", "occurrence": 2}], + ) + assert total == 2 + assert t1.text == "X AB" + assert t2.text == "X" + + +def test_apply_simple_replacements_xml_not_found_warning(capsys): + root = etree.Element("root") + p = etree.SubElement(root, fh.HP_P_TAG) + run = etree.SubElement(p, fh.HP_RUN_TAG) + t = etree.SubElement(run, fh.HP_T_TAG) + t.text = "hello" + total = fh.apply_simple_replacements_xml(root, [{"find": "missing", "replace": "X"}]) + captured = capsys.readouterr() + assert total == 0 + assert "WARNING" in captured.err + + +def test_apply_section_replacements_xml_clear_cell_true(): + root = etree.Element("root") + tc = _make_cell(0, 0, "※ guide text") + sub = tc.find(f"./{fh.HP_SUBLIST_TAG}") + extra_p = etree.SubElement(sub, fh.HP_P_TAG) + extra_run = etree.SubElement(extra_p, fh.HP_RUN_TAG) + extra_t = etree.SubElement(extra_run, fh.HP_T_TAG) + extra_t.text = "orphan" + root.append(_make_table([tc])) + + total = fh.apply_section_replacements_xml( + root, + [{"section_id": "1", "guide_text_prefix": "guide", "content": "NEW", "clear_cell": True}], + ) + texts = [(t.text or "") for t in tc.findall(f".//{fh.HP_T_TAG}")] + assert total == 1 + assert texts == ["NEW"] + + +def test_apply_section_replacements_xml_clear_cell_false(): + root = etree.Element("root") + tc = _make_cell(0, 0, "※ guide text") + sub = tc.find(f"./{fh.HP_SUBLIST_TAG}") + extra_p = etree.SubElement(sub, fh.HP_P_TAG) + extra_run = etree.SubElement(extra_p, fh.HP_RUN_TAG) + extra_t = etree.SubElement(extra_run, fh.HP_T_TAG) + extra_t.text = "keep-me" + root.append(_make_table([tc])) + + total = fh.apply_section_replacements_xml( + root, + [{"section_id": "1", "guide_text_prefix": "guide", "content": "NEW", "clear_cell": False}], + ) + texts = [(t.text or "") for t in tc.findall(f".//{fh.HP_T_TAG}")] + assert total == 1 + assert "NEW" in texts + assert "keep-me" in texts + + +def test_apply_table_cell_fills_xml_celladdr_lookup(): + root = etree.Element("root") + label = _make_cell(0, 0, "LABEL") + target = _make_cell(1, 0, "OLD") + root.append(_make_table([label, target])) + + total = fh.apply_table_cell_fills_xml(root, [{"find_label": "LABEL", "value": "VALUE"}]) + assert total == 1 + assert _first_text(target) == "VALUE" + + +def test_apply_table_cell_fills_xml_fills_empty_target_cell(): + root = etree.Element("root") + label = _make_cell(0, 0, "LABEL") + target = _make_cell(1, 0, with_text=False) + root.append(_make_table([label, target])) + + total = fh.apply_table_cell_fills_xml(root, [{"find_label": "LABEL", "value": "VALUE"}]) + assert total == 1 + assert _first_text(target) == "VALUE" + + +def test_apply_table_cell_fills_xml_fallback_does_not_modify_non_table_text(): + root = etree.Element("root") + root.append(_make_table([_make_cell(0, 0, "LABEL")])) + body_p = etree.SubElement(root, fh.HP_P_TAG) + body_run = etree.SubElement(body_p, fh.HP_RUN_TAG) + body_t = etree.SubElement(body_run, fh.HP_T_TAG) + body_t.text = "BODY_TEXT" + fallback_target = _make_cell(0, 0, "TARGET") + root.append(_make_table([fallback_target])) + + total = fh.apply_table_cell_fills_xml( + root, + [{"find_label": "LABEL", "value": "VALUE", "target_offset": {"col": 99, "row": 0}}], + ) + + assert total == 1 + assert body_t.text == "BODY_TEXT" + assert _first_text(fallback_target) == "VALUE" + + +def test_apply_multi_paragraph_fills_injects_multiple_paragraphs(): + root = etree.Element("root") + tc = _make_cell(0, 0, "※ TARGET") + root.append(_make_table([tc])) + total = fh.apply_multi_paragraph_fills( + root, + [ + { + "section_id": "2-1", + "guide_text_prefix": "TARGET", + "paragraphs": ["P1", "P2", "P3"], + } + ], + ) + sub = tc.find(f"./{fh.HP_SUBLIST_TAG}") + paragraphs = sub.findall(f"./{fh.HP_P_TAG}") + texts = ["".join((t.text or "") for t in p.findall(f".//{fh.HP_T_TAG}")) for p in paragraphs] + assert total == 1 + assert texts == ["P1", "P2", "P3"] + + +def test_apply_multi_paragraph_fills_deepcopy_preserves_style_and_isolation(): + root = etree.Element("root") + tc = _make_cell(0, 0, "※ TARGET") + root.append(_make_table([tc])) + + fh.apply_multi_paragraph_fills( + root, + [ + { + "section_id": "2-1", + "guide_text_prefix": "TARGET", + "paragraphs": ["A", "B"], + } + ], + ) + + sub = tc.find(f"./{fh.HP_SUBLIST_TAG}") + paragraphs = sub.findall(f"./{fh.HP_P_TAG}") + assert len(paragraphs) == 2 + assert len({id(p) for p in paragraphs}) == 2 + for p in paragraphs: + assert p.get("paraPrIDRef") == "31" + assert p.get("styleIDRef") == "0" + assert p.find(f"./{{{NS}}}linesegarray") is not None + run = p.find(f"./{fh.HP_RUN_TAG}") + assert run is not None and run.get("charPrIDRef") == "31" + + +def test_full_fill_cycle_real_template(tmp_path): + output = tmp_path / "filled.hwpx" + plan = { + "template_file": str(TEMPLATE_PATH), + "output_file": str(output), + "simple_replacements": [{"find": "OO학과 교수 재직(00년)", "replace": "테스트 경력"}], + "section_replacements": [ + { + "section_id": "1-1", + "guide_text_prefix": "※ 과거 폐업 원인을", + "content": "섹션 테스트 내용", + "clear_cell": True, + } + ], + "table_cell_fills": [{"find_label": "과제명", "value": "테스트 과제명"}], + "multi_paragraph_fills": [ + { + "section_id": "2-1", + "guide_text_prefix": "신청하기 이전까지", + "paragraphs": ["문단1", "문단2", "문단3"], + } + ], + } + total = fh.fill_hwpx(plan, str(output)) + + assert total >= 4 + assert output.exists() + with zipfile.ZipFile(output) as zf: + names = zf.namelist() + assert "Contents/section0.xml" in names + xml = zf.read("Contents/section0.xml").decode("utf-8") + assert "테스트 과제명" in xml + assert "문단1" in xml + + +def test_inspect_cli_includes_table_context(): + proc = subprocess.run( + [sys.executable, str(SCRIPT_PATH), "--inspect", str(TEMPLATE_PATH), "-q", "과제명"], + check=True, + capture_output=True, + text=True, + ) + assert "[T4 R0 C0]" in proc.stdout + + +def test_analyze_cli_outputs_valid_schema(): + proc = subprocess.run( + [sys.executable, str(SCRIPT_PATH), "--analyze", str(TEMPLATE_PATH)], + check=True, + capture_output=True, + text=True, + ) + data = json.loads(proc.stdout) + assert data["total_text_elements"] == 382 + assert len(data["tables"]) == 28 + assert len(data["guide_texts"]) > 0 + assert len(data["placeholders"]) > 0 + + +def test_full_fill_cycle_with_reverse_conversion_if_available(tmp_path): + if not HWP2MD_BIN.exists(): + pytest.skip("bin/hwp2md not found") + + output = tmp_path / "filled_reverse.hwpx" + plan = { + "template_file": str(TEMPLATE_PATH), + "output_file": str(output), + "table_cell_fills": [ + {"find_label": "과제명", "value": "역변환 과제명"}, + {"find_label": "기업명", "value": "역변환 기업명"}, + ], + } + fh.fill_hwpx(plan, str(output)) + + md_path = tmp_path / "verify.md" + subprocess.run([str(HWP2MD_BIN), str(output), "-o", str(md_path)], check=True) + md = md_path.read_text(encoding="utf-8") + assert "역변환 과제명" in md + assert "역변환 기업명" in md From 6ac71605f01263b9972d1af828693938c7746cf9 Mon Sep 17 00:00:00 2001 From: Baekho Lim Date: Tue, 3 Mar 2026 09:28:47 +0900 Subject: [PATCH 13/24] test(md2hwp): add sample fill plan and e2e fill coverage --- ...4\250\355\202\244\354\247\200_sample.json" | 43 +++++++++++++++++++ tests/test_fill_hwpx.py | 26 +++++++++++ 2 files changed, 69 insertions(+) create mode 100644 "testdata/fill_plans/\354\236\254\353\217\204\354\240\204\354\204\261\352\263\265\355\214\250\355\202\244\354\247\200_sample.json" diff --git "a/testdata/fill_plans/\354\236\254\353\217\204\354\240\204\354\204\261\352\263\265\355\214\250\355\202\244\354\247\200_sample.json" "b/testdata/fill_plans/\354\236\254\353\217\204\354\240\204\354\204\261\352\263\265\355\214\250\355\202\244\354\247\200_sample.json" new file mode 100644 index 0000000..8894b22 --- /dev/null +++ "b/testdata/fill_plans/\354\236\254\353\217\204\354\240\204\354\204\261\352\263\265\355\214\250\355\202\244\354\247\200_sample.json" @@ -0,0 +1,43 @@ +{ + "template_file": "testdata/hwpx_20260302_200059.hwpx", + "output_file": "/tmp/e2e_fill_test.hwpx", + "simple_replacements": [ + { + "find": "OO학과 교수 재직(00년)", + "replace": "테스트 경력 내용" + } + ], + "section_replacements": [ + { + "section_id": "1-1", + "guide_text_prefix": "※ 과거 폐업 원인을", + "content": "테스트 섹션 치환 내용", + "clear_cell": true + } + ], + "table_cell_fills": [ + { + "find_label": "과제명", + "value": "테스트 과제명" + }, + { + "find_label": "기업명", + "value": "테스트 기업명" + }, + { + "find_label": "아이템(서비스) 개요", + "value": "테스트 아이템 개요" + } + ], + "multi_paragraph_fills": [ + { + "section_id": "2-1", + "guide_text_prefix": "신청하기 이전까지", + "paragraphs": [ + "테스트 준비현황 문단 1", + "테스트 준비현황 문단 2", + "테스트 준비현황 문단 3" + ] + } + ] +} diff --git a/tests/test_fill_hwpx.py b/tests/test_fill_hwpx.py index 006ca53..7ce8227 100644 --- a/tests/test_fill_hwpx.py +++ b/tests/test_fill_hwpx.py @@ -16,6 +16,7 @@ NS = fh.HWPX_NS["hp"] TEMPLATE_PATH = ROOT / "testdata" / "hwpx_20260302_200059.hwpx" HWP2MD_BIN = ROOT / "bin" / "hwp2md" +SAMPLE_PLAN_PATH = ROOT / "testdata" / "fill_plans" / "재도전성공패키지_sample.json" def _make_cell(col, row, text="", colspan=1, rowspan=1, with_text=True): @@ -374,3 +375,28 @@ def test_full_fill_cycle_with_reverse_conversion_if_available(tmp_path): md = md_path.read_text(encoding="utf-8") assert "역변환 과제명" in md assert "역변환 기업명" in md + + +def test_e2e_fill_with_sample_plan(): + plan = json.loads(SAMPLE_PLAN_PATH.read_text(encoding="utf-8")) + output = Path(plan["output_file"]) + if output.exists(): + output.unlink() + + subprocess.run([sys.executable, str(SCRIPT_PATH), str(SAMPLE_PLAN_PATH)], check=True) + assert output.exists() + + with zipfile.ZipFile(output) as zf: + assert "Contents/section0.xml" in zf.namelist() + + if HWP2MD_BIN.exists(): + verify_path = Path("/tmp/e2e_verify.md") + subprocess.run([str(HWP2MD_BIN), str(output), "-o", str(verify_path)], check=True) + md = verify_path.read_text(encoding="utf-8") + assert "테스트 과제명" in md + assert "테스트 기업명" in md + else: + with zipfile.ZipFile(output) as zf: + xml = zf.read("Contents/section0.xml").decode("utf-8") + assert "테스트 과제명" in xml + assert "테스트 기업명" in xml From f3858b6df7c0cb6814a6ab4fe4c23fd48ac1a350 Mon Sep 17 00:00:00 2001 From: Baekho Lim Date: Tue, 3 Mar 2026 11:56:13 +0900 Subject: [PATCH 14/24] chore(automation): add pm-dev loop workflows and guardrails --- .github/ISSUE_TEMPLATE/task.md | 22 +- .github/automation/OPERATING_MODEL.md | 30 +++ .github/pull_request_template.md | 25 ++ .../workflows/automation-label-bootstrap.yml | 63 +++++ .../workflows/automation-state-machine.yml | 227 ++++++++++++++++++ .github/workflows/claude-review-scheduler.yml | 97 ++++++++ 6 files changed, 459 insertions(+), 5 deletions(-) create mode 100644 .github/automation/OPERATING_MODEL.md create mode 100644 .github/pull_request_template.md create mode 100644 .github/workflows/automation-label-bootstrap.yml create mode 100644 .github/workflows/automation-state-machine.yml create mode 100644 .github/workflows/claude-review-scheduler.yml diff --git a/.github/ISSUE_TEMPLATE/task.md b/.github/ISSUE_TEMPLATE/task.md index fced34a..dabcf04 100644 --- a/.github/ISSUE_TEMPLATE/task.md +++ b/.github/ISSUE_TEMPLATE/task.md @@ -2,18 +2,22 @@ name: Implementation Task about: AI-consumable task with full context and acceptance criteria title: '' -labels: '' +labels: 'task,backlog' assignees: '' --- -## Context +## Problem - + ## Spec +## Out of Scope + + + ## Files to Modify @@ -24,9 +28,17 @@ assignees: '' ## Acceptance Criteria -- [ ] All existing tests pass +- [ ] Behavior matches Spec exactly +- [ ] Existing tests pass - [ ] New tests cover the change -- [ ] Code follows project conventions (see AGENTS.md) +- [ ] No regression in related CLI features + +## Automation Policy + + +- Max automated attempts: 3 +- If same failure repeats 2+ times, add `needs-human` and stop auto-retry +- Do not auto-merge without Claude approval + CI green ## References diff --git a/.github/automation/OPERATING_MODEL.md b/.github/automation/OPERATING_MODEL.md new file mode 100644 index 0000000..e34ec9a --- /dev/null +++ b/.github/automation/OPERATING_MODEL.md @@ -0,0 +1,30 @@ +# PM/Dev Automation Operating Model + +## Roles +- Claude PM: issue definition, review decision, prioritization. +- Codex Dev: implementation, tests, PR delivery. + +## State Machine +- `backlog` -> `ready-codex` -> `in-progress` -> `needs-claude-review` -> `approved` -> `done` +- Exception states: `blocked`, `needs-human` + +## Automation Jobs +- `automation-label-bootstrap.yml`: ensures required labels exist. +- `automation-state-machine.yml`: + - issue enters execution when labeled `ready-codex` + - PRs are labeled `needs-claude-review` + - merged PRs are labeled `done` + - optional follow-up issue generation when PR has `auto-loop` +- `claude-review-scheduler.yml`: + - reminds pending Claude review every 6h (max 3 reminders) + - escalates to `needs-human` + `blocked` after max reminders + +## Loop Prevention Rules +- Max automated issue attempts: 3 +- Max review reminders: 3 +- Escalate to `needs-human` when thresholds are exceeded +- Follow-up issue creation is opt-in via `auto-loop` label only + +## Merge Gate +- Required: CI green + Claude approval +- Do not auto-merge when PR is `blocked` or `needs-human` diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..75a2e64 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,25 @@ +## Summary + + + +## Linked Issues + + + +## Verification + + +- [ ] Tests passed locally +- [ ] CI passed +- [ ] No unrelated files changed + +## Risks + + + +## Handoff To Claude PM + + +- Review focus: +- Open questions: +- Suggested next issue: diff --git a/.github/workflows/automation-label-bootstrap.yml b/.github/workflows/automation-label-bootstrap.yml new file mode 100644 index 0000000..d9899af --- /dev/null +++ b/.github/workflows/automation-label-bootstrap.yml @@ -0,0 +1,63 @@ +name: Automation Label Bootstrap + +on: + workflow_dispatch: + schedule: + - cron: "17 3 * * 1" + +permissions: + issues: write + pull-requests: write + +jobs: + bootstrap-labels: + runs-on: ubuntu-latest + steps: + - name: Ensure automation labels exist + uses: actions/github-script@v7 + with: + script: | + const labels = [ + { name: 'task', color: '0E8A16', description: 'Tracked implementation task' }, + { name: 'backlog', color: 'BFD4F2', description: 'Queued but not ready for execution' }, + { name: 'ready-codex', color: '1D76DB', description: 'Ready for Codex execution' }, + { name: 'in-progress', color: 'FBCA04', description: 'Actively being implemented' }, + { name: 'needs-claude-review', color: '5319E7', description: 'Awaiting Claude PM review' }, + { name: 'approved', color: '0E8A16', description: 'Approved for merge' }, + { name: 'blocked', color: 'D93F0B', description: 'Blocked by dependency or risk' }, + { name: 'needs-human', color: 'B60205', description: 'Automation halted, human decision required' }, + { name: 'done', color: 'C2E0C6', description: 'Completed and verified' }, + { name: 'auto-loop', color: '0052CC', description: 'Opt-in: create follow-up PM issue after merge' }, + { name: 'ready-claude', color: '5319E7', description: 'Ready for Claude PM planning/review' }, + ]; + + for (const label of labels) { + try { + await github.rest.issues.getLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name: label.name, + }); + await github.rest.issues.updateLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name: label.name, + color: label.color, + description: label.description, + }); + core.info(`Updated label: ${label.name}`); + } catch (error) { + if (error.status === 404) { + await github.rest.issues.createLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name: label.name, + color: label.color, + description: label.description, + }); + core.info(`Created label: ${label.name}`); + } else { + throw error; + } + } + } diff --git a/.github/workflows/automation-state-machine.yml b/.github/workflows/automation-state-machine.yml new file mode 100644 index 0000000..31d3acb --- /dev/null +++ b/.github/workflows/automation-state-machine.yml @@ -0,0 +1,227 @@ +name: Automation State Machine + +on: + issues: + types: [labeled, reopened] + pull_request: + types: [opened, reopened, synchronize, ready_for_review, closed] + +permissions: + issues: write + pull-requests: write + contents: read + +jobs: + issue-ready-codex: + if: | + github.event_name == 'issues' && + ( + (github.event.action == 'labeled' && github.event.label.name == 'ready-codex') || + github.event.action == 'reopened' + ) + runs-on: ubuntu-latest + steps: + - name: Transition issue into execution state + uses: actions/github-script@v7 + with: + script: | + const issue = context.payload.issue; + const issueNumber = issue.number; + const maxAttempts = 3; + const labels = issue.labels.map(l => l.name); + + if (!labels.includes('ready-codex')) { + core.info('Issue is not ready-codex; skipping.'); + return; + } + + const comments = await github.paginate(github.rest.issues.listComments, { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + per_page: 100, + }); + + const attemptMarker = ''; + const attempts = comments.filter(c => c.body && c.body.includes(attemptMarker)).length; + + const labelSet = new Set(labels); + const applyLabels = async (nextSet) => { + await github.rest.issues.setLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + labels: [...nextSet], + }); + }; + + if (attempts >= maxAttempts) { + labelSet.delete('ready-codex'); + labelSet.delete('in-progress'); + labelSet.add('blocked'); + labelSet.add('needs-human'); + await applyLabels(labelSet); + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + body: [ + '', + 'Automation halted: reached max automated attempts (3).', + 'Please perform human triage and then relabel with `ready-codex` if retry is still valid.', + ].join('\n'), + }); + return; + } + + labelSet.add('in-progress'); + labelSet.delete('backlog'); + labelSet.delete('blocked'); + labelSet.delete('needs-human'); + labelSet.delete('done'); + await applyLabels(labelSet); + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + body: [ + '', + `Codex execution started (attempt ${attempts + 1}/${maxAttempts}).`, + 'Next transition expected: `PR_OPEN` -> `needs-claude-review`.', + ].join('\n'), + }); + + pr-review-request: + if: | + github.event_name == 'pull_request' && + contains(fromJSON('["opened","reopened","synchronize","ready_for_review"]'), github.event.action) + runs-on: ubuntu-latest + steps: + - name: Mark PR as pending Claude review + uses: actions/github-script@v7 + with: + script: | + const pr = context.payload.pull_request; + const prNumber = pr.number; + const labels = new Set((pr.labels || []).map(l => l.name)); + labels.add('needs-claude-review'); + labels.delete('approved'); + labels.delete('done'); + + await github.rest.issues.setLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + labels: [...labels], + }); + + const sha = pr.head.sha; + const marker = ``; + const comments = await github.paginate(github.rest.issues.listComments, { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + per_page: 100, + }); + const alreadyPosted = comments.some(c => c.body && c.body.includes(marker)); + if (alreadyPosted) { + core.info('Review request already posted for this SHA.'); + return; + } + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body: [ + marker, + 'Codex implementation update is ready for Claude PM review.', + 'Review gate: spec match, risk checks, and merge/no-merge decision.', + ].join('\n'), + }); + + pr-merged-closeout: + if: github.event_name == 'pull_request' && github.event.action == 'closed' && github.event.pull_request.merged == true + runs-on: ubuntu-latest + steps: + - name: Close out merged PR and optionally create follow-up issue + uses: actions/github-script@v7 + with: + script: | + const pr = context.payload.pull_request; + const prNumber = pr.number; + const labels = new Set((pr.labels || []).map(l => l.name)); + const hasAutoLoop = labels.has('auto-loop'); + + labels.add('done'); + labels.delete('needs-claude-review'); + labels.delete('in-progress'); + labels.delete('ready-codex'); + labels.delete('blocked'); + + await github.rest.issues.setLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + labels: [...labels], + }); + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body: [ + '', + `Completed and merged in ${pr.merge_commit_sha}.`, + 'State transition: `MERGED -> DONE`.', + ].join('\n'), + }); + + const body = pr.body || ''; + const refs = [...body.matchAll(/(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+#(\d+)/ig)] + .map(m => Number(m[1])) + .filter(n => Number.isFinite(n)); + const uniqueRefs = [...new Set(refs)]; + + for (const issueNumber of uniqueRefs) { + try { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + body: `Implemented by PR #${prNumber} and merged in \`${pr.merge_commit_sha}\`.`, + }); + } catch (error) { + core.warning(`Failed to comment on issue #${issueNumber}: ${error.message}`); + } + } + + if (!hasAutoLoop) { + core.info('auto-loop label absent; skipping follow-up PM issue creation.'); + return; + } + + const followUpTitle = `pm: post-merge review for PR #${prNumber}`; + const followUpBody = [ + `Source PR: #${prNumber}`, + '', + '## Claude PM Review', + '- Validate merged behavior against acceptance criteria', + '- Identify residual risks and missing tests', + '- Decide whether a new Codex issue is required', + '', + '## Next Action', + '- If more work is needed, create a new issue and label it `ready-codex`', + '- If no further action is needed, close this issue', + '', + '', + ].join('\n'); + + await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: followUpTitle, + body: followUpBody, + labels: ['task', 'ready-claude', 'backlog'], + }); diff --git a/.github/workflows/claude-review-scheduler.yml b/.github/workflows/claude-review-scheduler.yml new file mode 100644 index 0000000..e477eec --- /dev/null +++ b/.github/workflows/claude-review-scheduler.yml @@ -0,0 +1,97 @@ +name: Claude Review Scheduler + +on: + workflow_dispatch: + schedule: + - cron: "0 */2 * * *" + +permissions: + pull-requests: write + issues: write + contents: read + +jobs: + remind-claude-review: + runs-on: ubuntu-latest + steps: + - name: Remind and escalate stale review requests + uses: actions/github-script@v7 + with: + script: | + const maxReminders = 3; + const minHoursBetweenReminders = 6; + const reminderMarker = ''; + const escalationMarker = ''; + + const prs = await github.paginate(github.rest.pulls.list, { + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + per_page: 100, + }); + + for (const pr of prs) { + if (pr.draft) { + continue; + } + const labels = new Set((pr.labels || []).map(l => l.name)); + if (!labels.has('needs-claude-review')) { + continue; + } + + const comments = await github.paginate(github.rest.issues.listComments, { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + per_page: 100, + }); + + const reminderComments = comments.filter(c => c.body && c.body.includes(reminderMarker)); + const remindersSent = reminderComments.length; + + if (remindersSent >= maxReminders) { + labels.delete('needs-claude-review'); + labels.add('needs-human'); + labels.add('blocked'); + await github.rest.issues.setLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + labels: [...labels], + }); + + const escalatedAlready = comments.some(c => c.body && c.body.includes(escalationMarker)); + if (!escalatedAlready) { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + body: [ + escalationMarker, + 'Claude review reminders reached max count (3).', + 'Escalated to `needs-human` + `blocked` to prevent unattended looping.', + ].join('\n'), + }); + } + continue; + } + + if (reminderComments.length > 0) { + const lastReminder = new Date(reminderComments[reminderComments.length - 1].created_at); + const elapsedHours = (Date.now() - lastReminder.getTime()) / (1000 * 60 * 60); + if (elapsedHours < minHoursBetweenReminders) { + continue; + } + } + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + body: [ + reminderMarker, + `Review reminder ${remindersSent + 1}/${maxReminders}: Claude PM review is pending.`, + '@baekho-lim please review this PR and decide: approve, request changes, or block.', + ].join('\n'), + }); + } From 2296a335a7d7a2349d32301481d72ab981e2d154 Mon Sep 17 00:00:00 2001 From: Baekho Lim Date: Tue, 3 Mar 2026 20:23:36 +0900 Subject: [PATCH 15/24] fix(md2hwp): scope fallback table-cell scan to label table --- tests/test_fill_hwpx.py | 6 +++--- tools/md2hwp/fill_hwpx.py | 11 ++++++----- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/tests/test_fill_hwpx.py b/tests/test_fill_hwpx.py index 7ce8227..95a4eb2 100644 --- a/tests/test_fill_hwpx.py +++ b/tests/test_fill_hwpx.py @@ -227,7 +227,7 @@ def test_apply_table_cell_fills_xml_fills_empty_target_cell(): assert _first_text(target) == "VALUE" -def test_apply_table_cell_fills_xml_fallback_does_not_modify_non_table_text(): +def test_apply_table_cell_fills_xml_fallback_does_not_cross_into_other_table(): root = etree.Element("root") root.append(_make_table([_make_cell(0, 0, "LABEL")])) body_p = etree.SubElement(root, fh.HP_P_TAG) @@ -242,9 +242,9 @@ def test_apply_table_cell_fills_xml_fallback_does_not_modify_non_table_text(): [{"find_label": "LABEL", "value": "VALUE", "target_offset": {"col": 99, "row": 0}}], ) - assert total == 1 + assert total == 0 assert body_t.text == "BODY_TEXT" - assert _first_text(fallback_target) == "VALUE" + assert _first_text(fallback_target) == "TARGET" def test_apply_multi_paragraph_fills_injects_multiple_paragraphs(): diff --git a/tools/md2hwp/fill_hwpx.py b/tools/md2hwp/fill_hwpx.py index 7a73b7f..f520659 100644 --- a/tools/md2hwp/fill_hwpx.py +++ b/tools/md2hwp/fill_hwpx.py @@ -341,11 +341,11 @@ def apply_table_cell_fills_xml(tree, fills: list) -> int: if label_cell is None: continue - table = _get_ancestor(label_cell, "tbl", parent_map) + label_tbl = _get_ancestor(label_cell, "tbl", parent_map) label_addr = label_cell.find(f"./{HP_CELLADDR_TAG}") # Primary: cellAddr lookup with configurable target offset. - if table is not None and label_addr is not None: + if label_tbl is not None and label_addr is not None: try: label_col = int(label_addr.get("colAddr", "-1")) label_row = int(label_addr.get("rowAddr", "-1")) @@ -355,12 +355,12 @@ def apply_table_cell_fills_xml(tree, fills: list) -> int: target_col = label_col + offset_col target_row = label_row + offset_row - target_cell = _find_cell_by_addr(table, target_col, target_row) + target_cell = _find_cell_by_addr(label_tbl, target_col, target_row) if target_cell is not None: _set_cell_text(target_cell, value) total += 1 found = True - table_idx = _get_table_index(tree, table) + table_idx = _get_table_index(tree, label_tbl) _log_event({"type": "replace", "idx": i, "find": label, "replace": value}) value_display = value[:40] + ("..." if len(value) > 40 else "") print( @@ -373,7 +373,8 @@ def apply_table_cell_fills_xml(tree, fills: list) -> int: for j in range(i + 1, min(i + 50, len(text_elements))): next_elem = text_elements[j] next_cell = _get_ancestor(next_elem, "tc", parent_map) - if next_cell is None or next_cell is label_cell: + next_tbl = _get_ancestor(next_cell, "tbl", parent_map) if next_cell is not None else None + if next_cell is None or next_cell is label_cell or next_tbl is not label_tbl: continue next_elem.text = value From 02f1e7b1c3a31b039edbea946899a66476289b5d Mon Sep 17 00:00:00 2001 From: Baekho Lim Date: Tue, 3 Mar 2026 20:24:11 +0900 Subject: [PATCH 16/24] test(md2hwp): add validation and nested-table regression tests --- tests/test_fill_hwpx.py | 76 +++++++++++++++++++++++++++++++++++++---- 1 file changed, 70 insertions(+), 6 deletions(-) diff --git a/tests/test_fill_hwpx.py b/tests/test_fill_hwpx.py index 95a4eb2..c3e4fd8 100644 --- a/tests/test_fill_hwpx.py +++ b/tests/test_fill_hwpx.py @@ -89,6 +89,21 @@ def test_find_cell_by_addr(): assert _first_text(tc) == "B" +def test_find_cell_by_addr_ignores_nested_table(): + inner_cell = _make_cell(1, 0, "NESTED") + inner_tbl = _make_table([inner_cell]) + + outer_cell = _make_cell(0, 0, "OUTER") + outer_sub = outer_cell.find(f"./{fh.HP_SUBLIST_TAG}") + outer_sub.append(inner_tbl) + target = _make_cell(1, 0, "TARGET") + outer_tbl = _make_table([outer_cell, target]) + + result = fh._find_cell_by_addr(outer_tbl, 1, 0) + assert result is not None + assert _first_text(result) == "TARGET" + + def test_set_cell_text_creates_hp_t_for_empty_cell(): tc = _make_cell(0, 0, with_text=False) assert tc.find(f".//{fh.HP_T_TAG}") is None @@ -135,6 +150,54 @@ def test_detect_placeholder_pattern(text, pattern): assert fh._detect_placeholder_pattern(text) == pattern +def test_load_plan_rejects_empty_find(tmp_path): + plan_path = tmp_path / "plan_empty_find.json" + plan = { + "template_file": str(TEMPLATE_PATH), + "output_file": str(tmp_path / "out.hwpx"), + "simple_replacements": [{"find": "", "replace": "X"}], + } + plan_path.write_text(json.dumps(plan, ensure_ascii=False), encoding="utf-8") + with pytest.raises(ValueError, match="simple_replacements: 'find' must be non-empty"): + fh.load_plan(str(plan_path)) + + +def test_load_plan_rejects_empty_guide_text_prefix(tmp_path): + plan_path = tmp_path / "plan_empty_section_prefix.json" + plan = { + "template_file": str(TEMPLATE_PATH), + "output_file": str(tmp_path / "out.hwpx"), + "section_replacements": [{"guide_text_prefix": "", "content": "X"}], + } + plan_path.write_text(json.dumps(plan, ensure_ascii=False), encoding="utf-8") + with pytest.raises(ValueError, match="section_replacements: 'guide_text_prefix' must be non-empty"): + fh.load_plan(str(plan_path)) + + +def test_load_plan_rejects_empty_find_label(tmp_path): + plan_path = tmp_path / "plan_empty_find_label.json" + plan = { + "template_file": str(TEMPLATE_PATH), + "output_file": str(tmp_path / "out.hwpx"), + "table_cell_fills": [{"find_label": "", "value": "X"}], + } + plan_path.write_text(json.dumps(plan, ensure_ascii=False), encoding="utf-8") + with pytest.raises(ValueError, match="table_cell_fills: 'find_label' must be non-empty"): + fh.load_plan(str(plan_path)) + + +def test_load_plan_rejects_empty_multi_paragraph_prefix(tmp_path): + plan_path = tmp_path / "plan_empty_multi_prefix.json" + plan = { + "template_file": str(TEMPLATE_PATH), + "output_file": str(tmp_path / "out.hwpx"), + "multi_paragraph_fills": [{"guide_text_prefix": "", "paragraphs": ["A"]}], + } + plan_path.write_text(json.dumps(plan, ensure_ascii=False), encoding="utf-8") + with pytest.raises(ValueError, match="multi_paragraph_fills: 'guide_text_prefix' must be non-empty"): + fh.load_plan(str(plan_path)) + + def test_apply_simple_replacements_xml_basic_and_occurrence(): root = etree.Element("root") p1 = etree.SubElement(root, fh.HP_P_TAG) @@ -377,20 +440,21 @@ def test_full_fill_cycle_with_reverse_conversion_if_available(tmp_path): assert "역변환 기업명" in md -def test_e2e_fill_with_sample_plan(): +def test_e2e_fill_with_sample_plan(tmp_path): plan = json.loads(SAMPLE_PLAN_PATH.read_text(encoding="utf-8")) - output = Path(plan["output_file"]) - if output.exists(): - output.unlink() + output = tmp_path / "e2e_fill_test.hwpx" + plan["output_file"] = str(output) + plan_path = tmp_path / "sample_plan.json" + plan_path.write_text(json.dumps(plan, ensure_ascii=False), encoding="utf-8") - subprocess.run([sys.executable, str(SCRIPT_PATH), str(SAMPLE_PLAN_PATH)], check=True) + subprocess.run([sys.executable, str(SCRIPT_PATH), str(plan_path)], check=True) assert output.exists() with zipfile.ZipFile(output) as zf: assert "Contents/section0.xml" in zf.namelist() if HWP2MD_BIN.exists(): - verify_path = Path("/tmp/e2e_verify.md") + verify_path = tmp_path / "e2e_verify.md" subprocess.run([str(HWP2MD_BIN), str(output), "-o", str(verify_path)], check=True) md = verify_path.read_text(encoding="utf-8") assert "테스트 과제명" in md From 986a9284a5107a8209cd5db0ae2290b5623c6941 Mon Sep 17 00:00:00 2001 From: Baekho Lim Date: Tue, 3 Mar 2026 20:26:16 +0900 Subject: [PATCH 17/24] refactor(md2hwp): split table fill and table inspect helpers --- tools/md2hwp/fill_hwpx.py | 225 ++++++++++++++++++-------------------- 1 file changed, 105 insertions(+), 120 deletions(-) diff --git a/tools/md2hwp/fill_hwpx.py b/tools/md2hwp/fill_hwpx.py index f520659..76f1f35 100644 --- a/tools/md2hwp/fill_hwpx.py +++ b/tools/md2hwp/fill_hwpx.py @@ -304,6 +304,78 @@ def apply_section_replacements_xml(tree, replacements: list) -> int: return total +def _find_label_matches(text_elements: list, label: str) -> list[tuple[int, object]]: + """Find label matches, preferring exact text matches over contains matches.""" + exact_matches = [(i, elem) for i, elem in enumerate(text_elements) if elem.text and elem.text.strip() == label] + if exact_matches: + return exact_matches + return [(i, elem) for i, elem in enumerate(text_elements) if elem.text and label in elem.text] + + +def _try_celladdr_fill( + tree, + label_idx: int, + label: str, + value: str, + label_cell, + label_addr, + offset_col: int, + offset_row: int, + parent_map: dict, +) -> bool: + """Try table fill using cellAddr coordinates and target offset.""" + label_tbl = _get_ancestor(label_cell, "tbl", parent_map) + if label_tbl is None or label_addr is None: + return False + + try: + label_col = int(label_addr.get("colAddr", "-1")) + label_row = int(label_addr.get("rowAddr", "-1")) + except ValueError: + label_col = -1 + label_row = -1 + + target_col = label_col + offset_col + target_row = label_row + offset_row + target_cell = _find_cell_by_addr(label_tbl, target_col, target_row) + if target_cell is None: + return False + + _set_cell_text(target_cell, value) + table_idx = _get_table_index(tree, label_tbl) + _log_event({"type": "replace", "idx": label_idx, "find": label, "replace": value}) + value_display = value[:40] + ("..." if len(value) > 40 else "") + print(f" Table cell '{label}' -> '{value_display}' (T{table_idx} R{target_row} C{target_col})") + return True + + +def _try_fallback_fill( + text_elements: list, + start_idx: int, + label: str, + value: str, + label_cell, + parent_map: dict, +) -> bool: + """Try table fill by scanning nearby text elements within the same table.""" + label_tbl = _get_ancestor(label_cell, "tbl", parent_map) + for j in range(start_idx + 1, min(start_idx + 50, len(text_elements))): + next_elem = text_elements[j] + next_cell = _get_ancestor(next_elem, "tc", parent_map) + next_tbl = _get_ancestor(next_cell, "tbl", parent_map) if next_cell is not None else None + if next_cell is None or next_cell is label_cell or next_tbl is not label_tbl: + continue + + next_elem.text = value + for child in list(next_elem): + next_elem.remove(child) + _log_event({"type": "replace", "idx": j, "find": label, "replace": value}) + value_display = value[:40] + ("..." if len(value) > 40 else "") + print(f" Table cell '{label}' -> '{value_display}' (fallback)") + return True + return False + + def apply_table_cell_fills_xml(tree, fills: list) -> int: """Fill table cells by finding label text and replacing the adjacent value cell. @@ -323,71 +395,21 @@ def apply_table_cell_fills_xml(tree, fills: list) -> int: offset_col = int(offset.get("col", 1)) offset_row = int(offset.get("row", 0)) found = False - - exact_matches = [ - (i, elem) - for i, elem in enumerate(text_elements) - if elem.text and elem.text.strip() == label - ] - contains_matches = [ - (i, elem) - for i, elem in enumerate(text_elements) - if elem.text and label in elem.text - ] - matches = exact_matches if exact_matches else contains_matches + matches = _find_label_matches(text_elements, label) for i, elem in matches: label_cell = _get_ancestor(elem, "tc", parent_map) if label_cell is None: continue - label_tbl = _get_ancestor(label_cell, "tbl", parent_map) label_addr = label_cell.find(f"./{HP_CELLADDR_TAG}") - - # Primary: cellAddr lookup with configurable target offset. - if label_tbl is not None and label_addr is not None: - try: - label_col = int(label_addr.get("colAddr", "-1")) - label_row = int(label_addr.get("rowAddr", "-1")) - except ValueError: - label_col = -1 - label_row = -1 - - target_col = label_col + offset_col - target_row = label_row + offset_row - target_cell = _find_cell_by_addr(label_tbl, target_col, target_row) - if target_cell is not None: - _set_cell_text(target_cell, value) - total += 1 - found = True - table_idx = _get_table_index(tree, label_tbl) - _log_event({"type": "replace", "idx": i, "find": label, "replace": value}) - value_display = value[:40] + ("..." if len(value) > 40 else "") - print( - f" Table cell '{label}' -> '{value_display}' " - f"(T{table_idx} R{target_row} C{target_col})" - ) - break - - # Fallback: flat scan for first text element in a different cell. - for j in range(i + 1, min(i + 50, len(text_elements))): - next_elem = text_elements[j] - next_cell = _get_ancestor(next_elem, "tc", parent_map) - next_tbl = _get_ancestor(next_cell, "tbl", parent_map) if next_cell is not None else None - if next_cell is None or next_cell is label_cell or next_tbl is not label_tbl: - continue - - next_elem.text = value - for child in list(next_elem): - next_elem.remove(child) + if _try_celladdr_fill(tree, i, label, value, label_cell, label_addr, offset_col, offset_row, parent_map): total += 1 found = True - _log_event({"type": "replace", "idx": j, "find": label, "replace": value}) - value_display = value[:40] + ("..." if len(value) > 40 else "") - print(f" Table cell '{label}' -> '{value_display}' (fallback)") break - - if found: + if _try_fallback_fill(text_elements, i, label, value, label_cell, parent_map): + total += 1 + found = True break if not found: @@ -465,51 +487,32 @@ def apply_multi_paragraph_fills(tree, fills: list) -> int: def fill_hwpx(plan: dict, output_path: str) -> int: """Main fill operation: copy template, modify XML, save.""" template_path = plan["template_file"] - - # Copy template to output shutil.copy2(template_path, output_path) - - # Find section XMLs section_files = find_section_xmls(template_path) if not section_files: raise ValueError("No section XML files found in HWPX") - print(f"Found {len(section_files)} section(s): {', '.join(section_files)}") total_replacements = 0 - - # Process each section XML with zipfile.ZipFile(template_path, "r") as zf_in: for section_file in section_files: - xml_bytes = zf_in.read(section_file) - tree = etree.fromstring(xml_bytes) - + tree = etree.fromstring(zf_in.read(section_file)) section_total = 0 - - # 1. Simple replacements if plan.get("simple_replacements"): print(f"\n--- Simple Replacements ({section_file}) ---") section_total += apply_simple_replacements_xml(tree, plan["simple_replacements"]) - - # 2. Section replacements if plan.get("section_replacements"): print(f"\n--- Section Replacements ({section_file}) ---") section_total += apply_section_replacements_xml(tree, plan["section_replacements"]) - - # 3. Table cell fills if plan.get("table_cell_fills"): print(f"\n--- Table Cell Fills ({section_file}) ---") section_total += apply_table_cell_fills_xml(tree, plan["table_cell_fills"]) - - # 4. Multi-paragraph fills if plan.get("multi_paragraph_fills"): print(f"\n--- Multi Paragraph Fills ({section_file}) ---") section_total += apply_multi_paragraph_fills(tree, plan["multi_paragraph_fills"]) total_replacements += section_total - if section_total > 0: - # Write modified XML back into the ZIP modified_xml = etree.tostring(tree, xml_declaration=True, encoding="UTF-8") _update_zip_file(output_path, section_file, modified_xml) print(f" Updated {section_file} ({section_total} replacements)") @@ -579,61 +582,43 @@ def inspect_template(template_path: str, query: str | None = None) -> None: print(f"\nTotal elements: {total_elements}") +def _collect_table_cell_infos(table) -> list[tuple[int, int, int, int, str]]: + """Collect row/col/span/text info from table cells.""" + cell_infos = [] + for cell in table.findall(f".//{HP_TC_TAG}"): + col, row = _parse_cell_addr(cell) + if col is None or row is None: + continue + cell_span = cell.find(f"./{HP_CELLSPAN_TAG}") + if cell_span is not None: + try: + col_span = int(cell_span.get("colSpan", "1")) + row_span = int(cell_span.get("rowSpan", "1")) + except ValueError: + col_span = 1 + row_span = 1 + else: + col_span = 1 + row_span = 1 + text = _get_cell_text(cell) or "[EMPTY]" + cell_infos.append((row, col, col_span, row_span, text)) + return sorted(cell_infos, key=lambda x: (x[0], x[1])) + + def _inspect_table_structure(template_path: str) -> None: """Inspect table layout with cell coordinates and spans.""" - section_files = find_section_xmls(template_path) - with zipfile.ZipFile(template_path, "r") as zf: - for section_file in section_files: - xml_bytes = zf.read(section_file) - tree = etree.fromstring(xml_bytes) + for section_file in find_section_xmls(template_path): + tree = etree.fromstring(zf.read(section_file)) tables = tree.findall(f".//{HP_TBL_TAG}") - print(f"Section: {section_file} ({len(tables)} tables)\n") - for table_idx, table in enumerate(tables): row_cnt = table.get("rowCnt", "?") col_cnt = table.get("colCnt", "?") print(f" Table {table_idx}: {row_cnt} rows x {col_cnt} cols") - - cell_infos = [] - for cell in table.findall(f".//{HP_TC_TAG}"): - cell_addr = cell.find(f"./{HP_CELLADDR_TAG}") - if cell_addr is None: - continue - try: - col = int(cell_addr.get("colAddr", "-1")) - row = int(cell_addr.get("rowAddr", "-1")) - except ValueError: - col = -1 - row = -1 - - cell_span = cell.find(f"./{HP_CELLSPAN_TAG}") - if cell_span is not None: - try: - col_span = int(cell_span.get("colSpan", "1")) - row_span = int(cell_span.get("rowSpan", "1")) - except ValueError: - col_span = 1 - row_span = 1 - else: - col_span = 1 - row_span = 1 - - text_parts = [t.text for t in cell.findall(f".//{HP_T_TAG}") if t.text] - text = "".join(text_parts).strip() - if not text: - text = "[EMPTY]" - - cell_infos.append((row, col, col_span, row_span, text)) - - cell_infos.sort(key=lambda x: (x[0], x[1])) - for row, col, col_span, row_span, text in cell_infos: - span = "" - if col_span > 1 or row_span > 1: - span = f" (span {col_span}x{row_span})" + for row, col, col_span, row_span, text in _collect_table_cell_infos(table): + span = f" (span {col_span}x{row_span})" if col_span > 1 or row_span > 1 else "" print(f" R{row} C{col}{span}: {text}") - print() From 8506d1c4270af47bd46e162c8ef72f9cff3629c5 Mon Sep 17 00:00:00 2001 From: Baekho Lim Date: Tue, 3 Mar 2026 22:30:19 +0900 Subject: [PATCH 18/24] fix(md2hwp): reduce analyze placeholder false positives --- tests/test_fill_hwpx.py | 18 +++++++++++++++++- tools/md2hwp/fill_hwpx.py | 6 +++++- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/tests/test_fill_hwpx.py b/tests/test_fill_hwpx.py index c3e4fd8..4ce9ca0 100644 --- a/tests/test_fill_hwpx.py +++ b/tests/test_fill_hwpx.py @@ -144,7 +144,14 @@ def test_get_table_index(): @pytest.mark.parametrize( ("text", "pattern"), - [("OO기업", "OO"), ("○○○", "○○○"), ("1000", "000"), ("일반 텍스트", None)], + [ + ("OO기업", "OO"), + ("○○○", "○○○"), + ("0000원", "0000"), + ("1000", None), + ("3,448,000", None), + ("일반 텍스트", None), + ], ) def test_detect_placeholder_pattern(text, pattern): assert fh._detect_placeholder_pattern(text) == pattern @@ -418,6 +425,15 @@ def test_analyze_cli_outputs_valid_schema(): assert len(data["placeholders"]) > 0 +def test_analyze_excludes_comma_formatted_amount_placeholders(): + data = fh.analyze_template(str(TEMPLATE_PATH)) + placeholder_texts = {entry["text"] for entry in data["placeholders"]} + assert "3,448,000" not in placeholder_texts + assert "7,652,000" not in placeholder_texts + assert "7,000,000" not in placeholder_texts + assert any("OO" in text or "○○" in text for text in placeholder_texts) + + def test_full_fill_cycle_with_reverse_conversion_if_available(tmp_path): if not HWP2MD_BIN.exists(): pytest.skip("bin/hwp2md not found") diff --git a/tools/md2hwp/fill_hwpx.py b/tools/md2hwp/fill_hwpx.py index 76f1f35..98fe975 100644 --- a/tools/md2hwp/fill_hwpx.py +++ b/tools/md2hwp/fill_hwpx.py @@ -54,7 +54,8 @@ # Event logging for real-time UI EVENT_FILE = os.environ.get("MD2HWP_EVENT_FILE") -PLACEHOLDER_PATTERNS = [r"OO+", r"○{2,}", r"0{3,}"] +PLACEHOLDER_PATTERNS = [r"OO+", r"○{2,}"] +NUMERIC_PLACEHOLDER_PATTERN = r"(? None: @@ -644,6 +645,9 @@ def _detect_placeholder_pattern(text: str) -> str | None: match = re.search(pattern, text) if match: return match.group(0) + numeric_match = re.search(NUMERIC_PLACEHOLDER_PATTERN, text) + if numeric_match: + return numeric_match.group(1) return None From 7e979fbd2bf623779038d7ee14bffa9be8afd176 Mon Sep 17 00:00:00 2001 From: Baekho Lim Date: Tue, 3 Mar 2026 22:31:03 +0900 Subject: [PATCH 19/24] test(md2hwp): harden multi-paragraph e2e XML assertions --- tests/test_fill_hwpx.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/test_fill_hwpx.py b/tests/test_fill_hwpx.py index 4ce9ca0..edff694 100644 --- a/tests/test_fill_hwpx.py +++ b/tests/test_fill_hwpx.py @@ -468,11 +468,34 @@ def test_e2e_fill_with_sample_plan(tmp_path): with zipfile.ZipFile(output) as zf: assert "Contents/section0.xml" in zf.namelist() + tree = etree.fromstring(zf.read("Contents/section0.xml")) + + # XML is the source of truth for paragraph structure in table cells. + target_paragraphs = None + expected_paragraphs = [ + "테스트 준비현황 문단 1", + "테스트 준비현황 문단 2", + "테스트 준비현황 문단 3", + ] + for tc in tree.findall(f".//{fh.HP_TC_TAG}"): + paragraphs = [] + for p in tc.findall(f"./{fh.HP_SUBLIST_TAG}/{fh.HP_P_TAG}"): + text = "".join((t.text or "") for t in p.findall(f".//{fh.HP_T_TAG}")).strip() + if text: + paragraphs.append(text) + if paragraphs[:3] == expected_paragraphs: + target_paragraphs = paragraphs + break + assert target_paragraphs is not None, "multi_paragraph_fills content not found as separate XML paragraphs" + assert target_paragraphs[:3] == expected_paragraphs if HWP2MD_BIN.exists(): verify_path = tmp_path / "e2e_verify.md" subprocess.run([str(HWP2MD_BIN), str(output), "-o", str(verify_path)], check=True) md = verify_path.read_text(encoding="utf-8") + # Reverse markdown check is for content presence only. + for text in expected_paragraphs: + assert text in md assert "테스트 과제명" in md assert "테스트 기업명" in md else: From 5cf6f230b5e313be052aa88692db3efd7c8f8a69 Mon Sep 17 00:00:00 2001 From: Baekho Lim Date: Tue, 3 Mar 2026 22:37:54 +0900 Subject: [PATCH 20/24] fix(hwpx): preserve table-cell paragraph boundaries in markdown --- internal/cli/cli_test.go | 15 +++++++ internal/cli/convert.go | 8 +++- internal/parser/hwpx/parser.go | 21 ++-------- internal/parser/hwpx/parser_test.go | 65 +++++++++++++++++++++++++++++ 4 files changed, 90 insertions(+), 19 deletions(-) diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 9389553..3d0edf8 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -2,7 +2,10 @@ package cli import ( "os" + "strings" "testing" + + "github.com/roboco-io/hwp2md/internal/ir" ) func TestSetVersion(t *testing.T) { @@ -238,3 +241,15 @@ func TestDetectProviderFromModel(t *testing.T) { }) } } + +func TestConvertToBasicMarkdown_TableCellParagraphsUseBreaks(t *testing.T) { + doc := ir.NewDocument() + table := ir.NewTable(1, 1) + table.Cells[0][0].Text = "문단1\n문단2\n문단3" + doc.AddTable(table) + + md := convertToBasicMarkdown(doc) + if !strings.Contains(md, "문단1
문단2
문단3") { + t.Fatalf("expected markdown table cell to preserve paragraph boundaries with
, got: %s", md) + } +} diff --git a/internal/cli/convert.go b/internal/cli/convert.go index 02cdac0..8a8b6ea 100644 --- a/internal/cli/convert.go +++ b/internal/cli/convert.go @@ -492,7 +492,7 @@ func writeMarkdownTable(sb *strings.Builder, t *ir.TableBlock) { if ref.row >= 0 && ref.col >= 0 { if ref.row == i && ref.col == j { // This is the original cell - text = strings.ReplaceAll(t.Cells[i][j].Text, "\n", " ") + text = formatTableCellText(t.Cells[i][j].Text) } else if ref.row < i && ref.col == j { // Vertically merged cell (rowspan) - use 〃 text = "〃" @@ -517,6 +517,12 @@ func writeMarkdownTable(sb *strings.Builder, t *ir.TableBlock) { sb.WriteString("\n") } +func formatTableCellText(text string) string { + text = strings.ReplaceAll(text, "\r\n", "\n") + text = strings.ReplaceAll(text, "\r", "\n") + return strings.ReplaceAll(text, "\n", "
") +} + // isInfoBoxTable detects "info-box" style tables that should be converted to text format. // Pattern 1: A table with a title cell (containing brackets like [제목]) and a single content cell // that spans the full width and contains bullet-like content (○, ※, -, etc.) diff --git a/internal/parser/hwpx/parser.go b/internal/parser/hwpx/parser.go index 4b7e869..11d99f3 100644 --- a/internal/parser/hwpx/parser.go +++ b/internal/parser/hwpx/parser.go @@ -232,22 +232,12 @@ func (p *Parser) parseSectionXML(doc *ir.Document, decoder *xml.Decoder) error { // Text element - read content if currentParagraph != nil { text, _ := readElementText(decoder) - cell := getCurrentCell() - if cell != nil { - cell.text.WriteString(text) - } else { - currentParagraph.Text += text - } + currentParagraph.Text += text } case "tab": if currentParagraph != nil { - cell := getCurrentCell() - if cell != nil { - cell.text.WriteString("\t") - } else { - currentParagraph.Text += "\t" - } + currentParagraph.Text += "\t" } case "br": @@ -259,12 +249,7 @@ func (p *Parser) parseSectionXML(doc *ir.Document, decoder *xml.Decoder) error { } } if brType == "line" { - cell := getCurrentCell() - if cell != nil { - cell.text.WriteString("\n") - } else { - currentParagraph.Text += "\n" - } + currentParagraph.Text += "\n" } } diff --git a/internal/parser/hwpx/parser_test.go b/internal/parser/hwpx/parser_test.go index 368d0ca..5598f8a 100644 --- a/internal/parser/hwpx/parser_test.go +++ b/internal/parser/hwpx/parser_test.go @@ -276,6 +276,71 @@ func TestParser_ParseWithTable(t *testing.T) { } } +func TestParser_ParseWithTableCellMultiParagraph(t *testing.T) { + tmpDir := t.TempDir() + hwpxPath := filepath.Join(tmpDir, "table_multi_paragraph.hwpx") + + f, err := os.Create(hwpxPath) + if err != nil { + t.Fatalf("failed to create temp file: %v", err) + } + + w := zip.NewWriter(f) + + manifestContent := ` + + + + + +` + addZipFile(t, w, "content.hpf", []byte(manifestContent)) + + sectionContent := ` + + + + + 문단1 + 문단2 + 문단3 + + + +` + addZipFile(t, w, "Contents/section0.xml", []byte(sectionContent)) + + if err := w.Close(); err != nil { + t.Fatalf("failed to close zip writer: %v", err) + } + if err := f.Close(); err != nil { + t.Fatalf("failed to close file: %v", err) + } + + p, err := New(hwpxPath, parser.Options{}) + if err != nil { + t.Fatalf("failed to create parser: %v", err) + } + defer p.Close() + + doc, err := p.Parse() + if err != nil { + t.Fatalf("failed to parse: %v", err) + } + + if len(doc.Content) != 1 || doc.Content[0].Table == nil { + t.Fatalf("expected one table block, got %+v", doc.Content) + } + + got := doc.Content[0].Table.Cells[0][0].Text + want := "문단1\n문단2\n문단3" + if got != want { + t.Errorf("expected multi-paragraph cell text %q, got %q", want, got) + } +} + func TestReadElementText(t *testing.T) { tests := []struct { name string From 673b93a40ed15800a64336a4472dad2efebd0709 Mon Sep 17 00:00:00 2001 From: Baekho Lim Date: Tue, 3 Mar 2026 23:55:19 +0900 Subject: [PATCH 21/24] test(e2e): add table-cell multi-paragraph markdown regression --- tests/e2e_test.go | 78 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/tests/e2e_test.go b/tests/e2e_test.go index f5f23bf..97c54ff 100644 --- a/tests/e2e_test.go +++ b/tests/e2e_test.go @@ -1,6 +1,7 @@ package tests import ( + "archive/zip" "os" "os/exec" "path/filepath" @@ -9,6 +10,63 @@ import ( "testing" ) +func createTempHWPXWithMultiParagraphCell(t *testing.T) string { + t.Helper() + + tempDir := t.TempDir() + hwpxPath := filepath.Join(tempDir, "table-cell-multi-paragraph.hwpx") + + file, err := os.Create(hwpxPath) + if err != nil { + t.Fatalf("failed to create temp hwpx: %v", err) + } + defer file.Close() + + zipWriter := zip.NewWriter(file) + defer zipWriter.Close() + + manifest := ` + + + + + + + +` + + section := ` + + + + + + 문단1 + 문단2 + 문단3 + + + + +` + + addZipEntry := func(name, content string) { + entry, createErr := zipWriter.Create(name) + if createErr != nil { + t.Fatalf("failed to create zip entry %s: %v", name, createErr) + } + if _, writeErr := entry.Write([]byte(content)); writeErr != nil { + t.Fatalf("failed to write zip entry %s: %v", name, writeErr) + } + } + + addZipEntry("content.hpf", manifest) + addZipEntry("Contents/section0.xml", section) + + return hwpxPath +} + // E2E Test for Stage 1: HWPX -> Basic Markdown // Verifies that converting testdata/한글 테스트.hwpx produces valid markdown with expected content @@ -41,6 +99,26 @@ func TestE2EStage1_HWPXToMarkdown(t *testing.T) { } } +func TestE2EStage1_HWPXTableCellParagraphBreaks(t *testing.T) { + inputFile := createTempHWPXWithMultiParagraphCell(t) + binPath, cleanup := buildTestBinary(t) + defer cleanup() + + cmd := exec.Command("./"+binPath, "convert", inputFile) + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("convert command failed: %v\noutput: %s", err, output) + } + + md := string(output) + if !strings.Contains(md, "문단1
문단2
문단3") { + t.Fatalf("expected table cell paragraph boundaries rendered with
, got: %s", md) + } + if strings.Contains(md, "문단1문단2문단3") { + t.Fatalf("unexpected concatenated table cell text without boundaries: %s", md) + } +} + // validateStage1Output checks that Stage 1 (parser) output contains expected content func validateStage1Output(t *testing.T, md string) error { t.Helper() From 94d01fad0584419a03277be2bca46052f5010cde Mon Sep 17 00:00:00 2001 From: Baekho Lim Date: Sun, 15 Mar 2026 14:07:12 +0900 Subject: [PATCH 22/24] =?UTF-8?q?feat(md2hwp):=20add=20schema=20compilatio?= =?UTF-8?q?n=20pipeline=20for=20=EC=9E=AC=EB=8F=84=EC=A0=84=EC=84=B1?= =?UTF-8?q?=EA=B3=B5=ED=8C=A8=ED=82=A4=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add compile_schema.py that converts user-friendly JSON schema to fill_plan.json with preflight validation (required fields, budget ratios, placeholder detection). Includes schema template, Claude web system prompt, and sample filled business plan. Co-Authored-By: Claude Opus 4.6 --- tools/md2hwp/compile_schema.py | 255 ++++++++++++++++++ ...354\204\234_\354\265\234\354\242\205.json" | 224 +++++++++++++++ ...3\204\355\232\215\354\204\234_schema.json" | 140 ++++++++++ ...04\353\241\254\355\224\204\355\212\270.md" | 210 +++++++++++++++ 4 files changed, 829 insertions(+) create mode 100644 tools/md2hwp/compile_schema.py create mode 100644 "\353\260\224\354\235\264\355\203\210\353\243\250_\354\202\254\354\227\205\352\263\204\355\232\215\354\204\234_\354\265\234\354\242\205.json" create mode 100644 "\354\202\254\354\227\205\352\263\204\355\232\215\354\204\234_schema.json" create mode 100644 "\354\213\234\354\212\244\355\205\234\355\224\204\353\241\254\355\224\204\355\212\270.md" diff --git a/tools/md2hwp/compile_schema.py b/tools/md2hwp/compile_schema.py new file mode 100644 index 0000000..6bc43e9 --- /dev/null +++ b/tools/md2hwp/compile_schema.py @@ -0,0 +1,255 @@ +"""Compile 사업계획서 schema JSON → fill_plan.json for fill_hwpx.py""" +import json +import sys +import re + +GUIDE_TEXT_MAP = { + "1-1_폐업원인분석": "※ 과거 폐업 원인을", + "1-2_목표시장": "재창업 아이템 진출 목표시장", + "2-1_준비현황": "신청하기 이전까지", + "2-2_구체화방안": "재창업 아이템의 핵심 기능", + "3-1_비즈니스모델": "재창업 아이템의 가치 전달", + "3-2_사업화전략": "정의된 목표시장(고객)에 진입", + "4-1_보유역량": "대표자 및 조직이 사업화를 위해", + "4-2_조직구성계획": "협약기간 동안 조직 구성", +} + +# Template placeholder text → replacement mapping +TIMELINE_PLACEHOLDERS = [ + ("시제품 개발·개선 완료", "목표"), + ("개발 또는 개선 하려는 내용", "세부내용"), + ("웹사이트 오픈", "목표"), + ("웹사이트 기능 및 용도 등", "세부내용"), + ("시장 검증", "목표"), + ("검증 대상 및 방법 등", "세부내용"), +] + +BUDGET_PLACEHOLDERS = [ + ("DMD소켓 구입(00개×0000원)", 0), # row index for 재료비 line 1 + ("전원IC류 구입(00개×000원)", 1), # row index for 재료비 line 2 + ("시금형제작 외주용역", 2), # 외주용역비 + ("1명 x 0개월 x 0000원", 3), # 인건비 +] + +BUDGET_AMOUNT_PLACEHOLDERS = [ + ("3,448,000", 0, "정부지원"), + ("7,652,000", 1, "정부지원"), + ("7,000,000", 2, "현금"), + ("3,000,000", 3, "현물"), +] + +TEAM_PLACEHOLDERS = [ + ("공동대표", 0, "직위"), + ("S/W 개발 총괄", 0, "담당업무"), + ("OO학과 교수 재직(00년)", 0, "보유역량"), + ("팀장", 1, "직위"), + ("홍보 및 마케팅", 1, "담당업무"), + ("OO학과 전공, 관련 경력(00년 이상)", 1, "보유역량"), +] + + +def compile_schema(schema, template_path, output_path): + fill_plan = { + "template_file": template_path, + "output_file": output_path, + "simple_replacements": [], + "table_cell_fills": [], + "multi_paragraph_fills": [], + } + + # --- meta → table_cell_fills --- + meta = schema["meta"] + fill_plan["table_cell_fills"].extend([ + {"find_label": "과제명", "value": meta["과제명"]}, + {"find_label": "기업명", "value": meta["기업명"]}, + {"find_label": "아이템(서비스) 개요", "value": meta["아이템_개요"]}, + ]) + + # --- sections → multi_paragraph_fills --- + for key, section in schema["sections"].items(): + if key not in GUIDE_TEXT_MAP: + print(f"WARN: Unknown section key: {key}", file=sys.stderr) + continue + paragraphs = section.get("paragraphs", []) + if not paragraphs: + print(f"WARN: Empty paragraphs for {key}", file=sys.stderr) + continue + fill_plan["multi_paragraph_fills"].append({ + "section_id": key.split("_")[0], + "guide_text_prefix": GUIDE_TEXT_MAP[key], + "paragraphs": paragraphs, + }) + + # --- 폐업이력 → simple_replacements (1st company only) --- + closure = schema.get("폐업이력", {}) + total_count = closure.get("총_폐업횟수", 0) + if total_count > 0: + fill_plan["simple_replacements"].append( + {"find": "폐업 이력(총 폐업 횟수 : 0회)", "replace": f"폐업 이력(총 폐업 횟수 : {total_count}회)"} + ) + companies = closure.get("companies", []) + if companies: + c = companies[0] + mappings = [ + ("○○○○", c.get("기업명", "")), + ("개인 / 법인", c.get("기업구분", "")), + ("2000.00.00.(개업연월일 또는 회사성립연월일)~2000.00.00.(폐업일)", c.get("사업기간", "")), + ("아이템 간략히 소개", c.get("아이템_개요", "")), + ("폐업을 하게된 원인 및 사유 등을 간략기 기재", c.get("폐업원인", "")), + ] + for find, replace in mappings: + if replace: + fill_plan["simple_replacements"].append({"find": find, "replace": replace}) + + # --- 추진일정 → simple_replacements --- + timeline = schema.get("추진일정", {}) + rows = timeline.get("rows", []) + for i, row in enumerate(rows): + if i < len(TIMELINE_PLACEHOLDERS) // 2: + goal_ph = TIMELINE_PLACEHOLDERS[i * 2] + detail_ph = TIMELINE_PLACEHOLDERS[i * 2 + 1] + if row.get("목표"): + fill_plan["simple_replacements"].append({"find": goal_ph[0], "replace": row["목표"]}) + if row.get("세부내용"): + fill_plan["simple_replacements"].append({"find": detail_ph[0], "replace": row["세부내용"]}) + # Timeline dates: "~ 00월" (rows 1,2), "~00월" (row 3) + date_placeholders = ["~ 00월", "~ 00월", "~00월"] + for i, row in enumerate(rows): + if i < len(date_placeholders) and row.get("일정"): + fill_plan["simple_replacements"].append({ + "find": date_placeholders[i], + "replace": row["일정"], + "occurrence": 1, + }) + + # --- 사업비 → simple_replacements --- + budget = schema.get("사업비", {}) + budget_rows = budget.get("rows", []) + for i, row in enumerate(budget_rows): + if i < len(BUDGET_PLACEHOLDERS): + ph_text, _ = BUDGET_PLACEHOLDERS[i][:2] + if row.get("산출근거"): + # Strip leading "• " if present for matching + replace_text = row["산출근거"] + if not replace_text.startswith("•"): + replace_text = "• " + replace_text + fill_plan["simple_replacements"].append({"find": ph_text, "replace": replace_text.lstrip("• ")}) + + # Budget amounts + for ph_amount, row_idx, col_type in BUDGET_AMOUNT_PLACEHOLDERS: + if row_idx < len(budget_rows): + row = budget_rows[row_idx] + amount_key = {"정부지원": "정부지원", "현금": "현금", "현물": "현물"}.get(col_type) + if amount_key and row.get(amount_key, 0) > 0: + fill_plan["simple_replacements"].append({ + "find": ph_amount, + "replace": f"{row[amount_key]:,}", + }) + + # --- 인력현황 → simple_replacements --- + team = schema.get("인력현황", {}) + members = team.get("members", []) + current_count = team.get("현재_재직인원", 0) + planned_hire = team.get("추가_고용계획", 0) + + for ph_text, member_idx, field in TEAM_PLACEHOLDERS: + if member_idx < len(members): + value = members[member_idx].get(field, "") + if value: + fill_plan["simple_replacements"].append({"find": ph_text, "replace": value}) + + # Personnel count replacements + if current_count > 0: + fill_plan["simple_replacements"].append({"find": "00\n명\n추가 고용계획", "replace": f"{current_count:02d}\n명\n추가 고용계획", "occurrence": 1}) + + return fill_plan + + +def preflight(schema): + """Preflight validation. Returns (errors, warnings).""" + errors = [] + warnings = [] + + # Required fields + meta = schema.get("meta", {}) + for field in ["과제명", "기업명", "아이템_개요"]: + if not meta.get(field): + errors.append(f"BLOCK: meta.{field} 비어있음") + + # Sections not empty + for key, section in schema.get("sections", {}).items(): + if not section.get("paragraphs"): + errors.append(f"BLOCK: sections.{key}.paragraphs 비어있음") + + # Placeholder remnants - check only meta and sections paragraphs + placeholder_re = re.compile(r'(?:OO[^대학]|(? 100_000_000: + errors.append(f"BLOCK: 정부지원 {total_gov:,}원 > 1억원 한도 초과") + if total > 0: + if total_gov / total > 0.75: + errors.append(f"BLOCK: 정부지원 비율 {total_gov/total:.1%} > 75%") + if total_cash / total < 0.05: + warnings.append(f"WARN: 현금 비율 {total_cash/total:.1%} < 5% (권장)") + if total_inkind / total > 0.20: + warnings.append(f"WARN: 현물 비율 {total_inkind/total:.1%} > 20%") + + # Short paragraphs + for key, section in schema.get("sections", {}).items(): + for i, p in enumerate(section.get("paragraphs", [])): + if len(p) < 10: + warnings.append(f"WARN: sections.{key}.paragraphs[{i}] 길이 {len(p)}자 (너무 짧음)") + + return errors, warnings + + +if __name__ == "__main__": + input_file = sys.argv[1] + template = sys.argv[2] if len(sys.argv) > 2 else "testdata/hwpx_20260302_200059.hwpx" + output = sys.argv[3] if len(sys.argv) > 3 else "/tmp/compiled_output.hwpx" + + with open(input_file) as f: + schema = json.load(f) + + # Preflight + print("=== Preflight 검증 ===") + errors, warnings = preflight(schema) + for e in errors: + print(f" {e}") + for w in warnings: + print(f" {w}") + + if errors: + print(f"\n{len(errors)} BLOCK 에러 발견. 수정 후 재시도하세요.") + sys.exit(1) + + print(f" 결과: {len(errors)} 에러, {len(warnings)} 경고\n") + + # Compile + fill_plan = compile_schema(schema, template, output) + + output_json = "/tmp/compiled_fill_plan.json" + with open(output_json, "w") as f: + json.dump(fill_plan, f, ensure_ascii=False, indent=2) + + print(f"=== fill_plan.json 컴파일 완료 ===") + print(f" table_cell_fills: {len(fill_plan['table_cell_fills'])}개") + print(f" multi_paragraph_fills: {len(fill_plan['multi_paragraph_fills'])}개") + print(f" simple_replacements: {len(fill_plan['simple_replacements'])}개") + print(f" 출력: {output_json}") diff --git "a/\353\260\224\354\235\264\355\203\210\353\243\250_\354\202\254\354\227\205\352\263\204\355\232\215\354\204\234_\354\265\234\354\242\205.json" "b/\353\260\224\354\235\264\355\203\210\353\243\250_\354\202\254\354\227\205\352\263\204\355\232\215\354\204\234_\354\265\234\354\242\205.json" new file mode 100644 index 0000000..5c3f5ca --- /dev/null +++ "b/\353\260\224\354\235\264\355\203\210\353\243\250_\354\202\254\354\227\205\352\263\204\355\232\215\354\204\234_\354\265\234\354\242\205.json" @@ -0,0 +1,224 @@ +{ + "meta": { + "과제명": "IoT·AI 기반 근골격고 맞춤형 통합 헬스케어 플랫폼 〈바이탈루(Vitalu)〉", + "기업명": "예비재창업자", + "아이템_개요": "IoT 바이탈 기기(심박·운동포화도·수면·낙상 감지)와 AI 패턴 분석, 근골격고 전문가 감수 콘텐츠를 결합한 통합 헬스케어 웹앱 플랫폼. ① 통증 다이어리 & 근골격고 초기 평가 질문지 ② IoT 바이탈 연동 헬 대시보드 ③ AI 기반 주간 건강 리포트 ④ 맞춤 운동영상·라문·영상 정보(QR 코드 제공) ⑤ 전문가 상담 신청 ⑥ 주변 운동시터·병원 정보" + }, + + "sections": { + "1-1_폐업원인분석": { + "instruction": "과거 폐업 원인을 분석한 결과 및 이를 극복하기 위한 개선 방안. 폐업의 근본 원인(시장/재무/경영/기술)을 구체적으로 분석하고, 재창업에서의 극복 방안을 제시.", + "paragraphs": [ + "전 사업체(오프라인 필라티스·피트니스 스튜디오)의 폐업 원인은 세 가지 구조적 문제로 귀결된다. 첫째, 매출 100%가 현장 수업에 집중된 오프라인 단일 수익 구조였다. 코로나19 집합금지 명령 발동 시 매출이 즉시 0이 되는 취약성이 드러났으며, 외부 충격에 대응할 분산 메커니즘이 전무하였다. 둘째, 고정비(임대료·인건비) 대비 수익 탄력성이 부재하여 수익분기점 이전 유지가 구조적으로 불가능하였다. 셋째, 체계적인 회원 데이터 관리 및 CRM 부재로 이탈 고객 재유치 채널이 없었고, 해소 후 고객 관리가 불가능하였다.", + "이번 재창업은 폐업의 세 가지 근본 원인을 전면으로 대응하는 구조로 설계되었다. 수익원을 B2C 앱 구독·IoT 기기·B2B SaaS·콘텐츠 라이선스·강사 교육 5개 채널로 분산하여 오프라인 중단 시에도 디지털 수익이 유지되는 하이브리드 구조를 구축한다. SaaS 구독 모델은 한계비용 없이 수익이 확장되므로 고정비 구조를 근본적으로 해소한다. AI 통증 분석과 주간 리포트를 통해 사용자에게 매일 개인화 가치를 제공하고, 데이터 축적으로 자연스러운 전환 비용(switching cost)을 형성하여 리텐션을 내재화한다.", + "폐업 원인별 개선 방안: ① 오프라인 단일 수익 구조(매출 100% 현장 집중, 코로나19 시 즉시 매출 0) → B2C 구독·IoT 기기·B2B SaaS·콘텐츠 라이선스·강사 교육 5개 독립 수익원 구축, 오프라인 중단 시에도 디지털 수익 유지. ② 고정비 대비 수익 탄력성 부재(임대료·인건비 고정비 대비 가변 수익 단위) → SaaS형 구독 모델로 한계비용 없이 수익 확장, 클라우드 기반 플랫폼으로 전국 고객 수용. ③ 고객 리텐션 시스템 미흡(회원 데이터 관리 부재, 이탈 고객 재유치 불가) → AI 통증 분석·주간 리포트로 매일 개인화 가치 제공, 데이터 축적으로 전환 비용 자연 형성.", + "폐업 경험은 단순한 실패가 아닌, 오프라인 헬스케어 현장에서 고객 니즈를 직접 검증한 자산으로 전환되었다. 10년간 축적한 근골격고 전문 역량, 필라티스 강사 네트워크, 고객 통증 패턴 데이터는 재창업 아이템의 핵심 경쟁 자산으로 작동한다." + ] + }, + "1-2_목표시장": { + "instruction": "목표시장(고객) 현황 및 경쟁사 분석, 재창업 아이템의 경쟁력. TAM/SAM/SOM, 경쟁사 대비 차별점, 고객 페인포인트 포함.", + "paragraphs": [ + "〈시장 규모 및 성장성〉 국내 근골격고 환자 약 1,500만 명 / 연 진료비 5조원 이상(TAM 핵심). 국내 디지털 헬스케어 시장 약 2.4조원(2025) / CAGR 48%(고성장 시장). 독거노인·복지 모니터링 약 170만 명 / B2G 연 1조원 이상(B2G 채널). 전국 운동시터 약 1만 개소 이상(B2B SaaS 채널).", + "근골격고 통증 환자의 가장 큰 문제는 케어가 파편화되어 있다는 것이다. 병원에서 진단받고, 운동시터에서 운동하고, 재활·물리치료를 따로 받지만, 이 경험들은 서로 연결되지 않는다. 의사는 환자가 평소 어떻게 움직이는지 모르고, 트레이너는 병원에서 어떤 진단을 받았는지 모른다. 환자 스스로도 언제 어떻게 아팠는지 기억에 의존할 뿐, 자신의 몸 상태를 객관적으로 파악할 수단이 없다.", + "목표 고객별 핵심 문제: ① B2C 30~60대 만성 통증 환자 → 병원·운동·재활을 각각 따로 다니며 연결 없는 케어 반복, 내 몸의 패턴을 스스로 알 수 없음. ② B2C 통증 예방 관심 성인 → 막연한 불안감에 유튜브·SNS를 뒤지지만 신뢰할 수 있는 맞춤 정보가 없음. ③ B2B 운동시터·필라티스·재활 전문가 → 신규 고객 상태 파악에 시간이 걸리고, 첫 상담 이후 연속적 케어 기록이 남지 않음. ④ B2B 정형외과·재활의원 → 해소 후 환자 관리 수단이 없고, 재진 시 일정 경과를 알 방법이 없음. ⑤ B2G 지자체·보건소 → 독거노인 등 취약계층의 일상 바이탈 모니터링 수단 부재.", + "이 문제를 해결하는 것이 바이탈루의 출발점이다. 통증 다이어리·수면·바이탈 데이터를 하나로 모아 환자와 전문가 모두에게 끊기지 않는 케어 흐름을 제공한다. 바이탈루는 IoT 제조사가 아닌 데이터 플랫폼이다. 사용자가 이미 보유한 스마트워치(애플워치·갤럭시워치·오스미 등)를 멀티 게이트웨이로 연동하며, 기기가 없는 사용자에게도 저가 OEM 기기(원가 5,000~8,000원)를 최소 마진으로 보급한다.", + "〈경쟁사 분석 및 차별화〉 병원 치료는 통원 중에만 관리 가능하며 해소 후 관리가 전무하다. 유튜브·SNS는 무료이나 전문성이 없고 개인화가 불가능하다. 일반 헬스앱은 근골격고 특화가 없고 AI 분석이 미흡하다. 바이탈루는 IoT 멀티 게이트웨이 연동, 전문가 감수 콘텐츠, LLM 기반 통증 패턴 AI, 해소 후 지속 관리, 앱 내 채팅·즉시 예약을 월 9,900원부터 제공한다. 핵심 경쟁 우위: 근골격고 전문가 자격을 보유한 창업자가 직접 콘텐츠를 감수하고, 전국 필라티스 강사 네트워크(케어클래시아카데미)를 즉시 유통 채널로 활용할 수 있는 진입장벽은 경쟁사 복제 불가하다." + ] + }, + "2-1_준비현황": { + "instruction": "신청 이전까지 시제품 제작, 시장 반응 조사 등 사전 준비 현황. 개발 단계, 프로토타입, 고객 피드백, 기술 검증 포함.", + "paragraphs": [ + "바이탈루는 아이디어 단계가 아니다. 오프라인 사업 이전·MVP 개발·클로즈 베타·학원 설립·파트너사 확보까지 단계별 실행이 이미 진행 중이며, 실제 매출과 고객이 존재한다.", + "준비 현황 및 검증 내용: ① MVP 개발 완료 → 통증 다이어리 및 초기 평가 핵심 기능 구축 완료. 기존 운영 필라티스 센터 및 학원사 환자 대상 클로즈 베타 운영 중. ② 실매출 오프라인 센터 → 오프라인 필라티스 센터 운영 중, 연 매출 1억원 이상 달성, 실제 고객 기반 및 통증 케이스 노하우 축적. ③ 전문 강사 양성 학원 설립 → 근골격고 전문 강사 양성 학원 설립 및 콘텐츠 제작 진행 중, 학원를 통한 플랫폼 파트너사 1호 계약 완료. ④ 콘텐츠 파이프라인 → 근골격고 운동 영상 기획안 30개 작성, 정형외과 1개 클리닉 QR 파트너십 사전 협의 중. ⑤ IoT 멀티 게이트웨이 → Apple HealthKit·Samsung Health SDK·Google Health Connect·Zepp API 연동 기술 검토 및 아키텍처 방향 수립 완료. ⑥ 팀 구성 → CTO 내부 합류 완료, GMV 3,000억 커머스 사업개발 출신 PM·기획·개발 15년 통합 경력자.", + "협약 시점에 이미 ① 검증된 오프라인 매출, ② 클로즈 베타 사용 고객, ③ 플랫폼 파트너사 1호를 확보한 상태로, 협약 기간은 검증된 기반 위에서 디지털 전환과 스케일업을 실행하는 단계이다." + ] + }, + "2-2_구체화방안": { + "instruction": "핵심 기능 및 개발/개선 구체적 계획. 기술 아키텍처, 개발 로드맵, 핵심 기능 명세 포함.", + "paragraphs": [ + "근골격고 통증으로 병원·운동·재활을 따로따로 다니는 30~60대를 위해, 바이탈루는 통증 일기·수면·바이탈 데이터를 하나로 연결해 AI가 맞춤 운동을 처방하고 가까운 전문가를 즉시 연결한다. 흩어진 케어 경험을 하나의 앱으로 이어붙여, 환자는 '내 몸의 흐름'을 처음으로 하나로 보게 되고 전문가는 첫 만남부터 고객 상태를 정확히 파악할 수 있다.", + "〈6개 메뉴 → 파편화된 케어를 하나로 잇는 구조〉 ① 초기 평가(완료): '내 몸 상태를 객관적으로 모른다' 문제 해결 → 근골격고 질문지 6항목으로 신경·근즈·구조적 문제 감별 추적, 진단 이력 입력. ② 통증 다이어리(MVP 완료·클로즈 베타 운영 중): '언제 어떻게 아팠는지 기억에만 의존한다' 문제 해결 → 부위·강도·증상 태그·영상 메모 입력, AI가 반복 패턴 추출. ③ 헬 대시보드(협약 후 3개월): '수면·활동·통증의 연관성을 모른다' 문제 해결 → 스마트워치 바이탈 자동 연동, AI 주간 인사이트 리포트 발송. ④ 운동·건강 정보(협약 후 4개월): '믿을 수 있는 맞춤 운동 정보가 없다' 문제 해결 → 전문가 감수 운동영상·라문·영상정보, QR코드로 병원 대기실 영상·처방 URL 발송. ⑤ 전문가 상담(협약 후 5개월): '전문가 찾기가 번거롭고 비싸다' 문제 해결 → 필라티스 강사·물리치료사·운동처방사 즉시 예약·앱 내 채팅, 의사·한의사도 예약 연결 예정 운영. ⑥ 주변 정보(협약 후 5개월): '내 상태에 맞는 가까운 전문가를 모른다' 문제 해결 → GPS 기반 파트너 센터 지도, 모티피직스 설치 센터 우선 노출, 무료 3D 체형분석 예약.", + "〈오프라인 연결 전략 → 디지털-현장 순환 생태계〉 앱만으로는 파편화 문제를 완전히 해결할 수 없다. 바이탈루는 두 가지 오프라인 파트너 채널로 앱과 현장을 연결한다. 첫째, 모티피직스 파트너 센터: 전국 700개 이상 운동시터·필라티스에 영업 중인 3D 체형분석 솔루션 모티피직스을 유통 파트너로 협력한다. 앱 사용자는 인근 설치 센터에서 무료 체형분석을 예약하고, 센터는 바이탈루 앱의 통증 다이어리를 공유받아 첫 만남부터 맞춤 케어를 제공한다. 둘째, 병원 처방 콘텐츠 솔루션: 정형외과·재활의원에서 근골격고 운동 영상을 메타한다. 대기실 영상으로 브랜드를 노출하고, 진료 후 의사가 QR/URL로 맞춤 처방 운동을 환자에게 즉시 전달한다. 1년 무료 제공 후 연간 유료 구독(기관별 월 30만원~)으로 전환한다.", + "〈장기 비전 → 케어 인프라로의 확장〉 사용자 데이터가 쌓일수록 AI 처방 정밀도는 높아지고, 전문가들은 바이탈루 앱 하나로 고객의 케어 전 과정을 파악하게 된다. 장기적으로는 병원 CRM·예약·사무 시스템과 연계하여 진료 기록과 처방을 자동화하는 헬스케어 인프라로 확장하는 것이 바이탈루의 비전이다." + ] + }, + "3-1_비즈니스모델": { + "instruction": "가치 전달 체계 및 수익 창출 방법. 수익 모델(구독/수수료/라이선스 등), 가격 전략, 핵심 파트너, 비용 구조 포함.", + "paragraphs": [ + "〈수익 모델〉 ① B2C 앱 구독(개인 사용자): 베이직 월 9,900원 / 케어 월 19,900원, 협약기간 목표 유료 전환 50명, IoT 연동·AI 분석·전문가 상담 포함. ② IoT 기기 판매(개인): 39,000원, 목표 100개 판매, 최소 마진 운영으로 앱 구독 전환 유도. ③ B2B SaaS(운동시터·스튜디오): 월 30~50만원, 목표 파트너 10개소, 매니저 대시보드·환자 데이터 포함. ④ 콘텐츠 라이선스-병원 처방 솔루션(정형외과·재활센터): 기관별 월 30만원 이상, 목표 계약 5개소, 1년 무료 후 유료 전환·진료 후 처방 URL 발송 기능 포함. ⑤ 강사 교육(케어클래시아카데미 수강생): 198만원/과정, 목표 2기 이상, 온·오프 하이브리드 기존 비용 43% 절감.", + "바이탈루의 본질적 경쟁력은 기기 판매 마진이 아닌 데이터 축적과 사용자 경험에 있다. 사용자가 기존 스마트워치나 저가 OEM 기기로 데이터를 쌓을수록 AI 분석 정밀도가 높아지고 전환 비용(switching cost)이 자연 형성된다. 장기적으로 축적된 한국형 근골격고 AI 데이터는 제약·보험·공공 분야의 B2B2G 데이터 자산으로 전환되는 경로를 만든다." + ] + }, + "3-2_사업화전략": { + "instruction": "목표시장 진입/진출 방안과 고객 확보 전략. GTM 전략, 마케팅 채널, 초기 고객 확보, 파트너십 전략 포함.", + "paragraphs": [ + "모티피직스 파트너 센터 네트워크: 바이탈루가 모티피직스(3D 체형분석기, 전국 700개 이상 센터 영업) 유통 파트너로 협력한다. 신규 파트너 센터(운동시터·필라티스·재활센터·정형외과)에 모티피직스 도입을 지원하고, 바이탈루 앱 사용자는 '내 주변 모티피직스 설치 센터'에서 무료 3D 체형분석·상담을 예약 받는다. 오프라인 체험이 앱 구독 전환의 핵심 트리거로 작동한다.", + "학원 네트워크 직접: 케어클래시아카데미 등록 강사(전국) 대상 우선 배포. 강사가 수강생에게 앱 추천 시 제로비용 고객 유입 구조가 작동한다. 자격증 취득자에게 앱 구독 1개월 무료 제공 패키지를 운영한다.", + "병원·클리닉 콘텐츠 솔루션: 정형외과·재활의원 대상 근골격고 질환별 올바른 자세·운동 영상 콘텐츠를 메타한다. 대기실 영상과 진료 후 의사의 QR/URL 링크 환자 맞춤 처방 전달로 활용된다. 1년 무료 제공 후 이후 연간 유료 구독 전환하며, 환자가 링크 클릭 시 바이탈루 앱으로 자연 유입된다.", + "디지털 콘텐츠 마케팅: 유튜브·인스타그램 근골격고 60초 일상 팁 주 3개 발행, 목표 구독 1만 명. 라문 기반 주간 뉴스레터 구독 2,000명 목표. 콘텐츠 말미 앱 CTA 삽입으로 유기적 유입 구조를 구축한다. 지자체 B2G 접근: 보건소·복지부 디지털헬스케어 실증 공모 연계, 시범사업 후 본 계약 구조로 진행한다." + ] + }, + "4-1_보유역량": { + "instruction": "대표자 및 조직의 사업화 역량. 예비재창업자는 대표자 중심. 기술 역량, 사업 경험, 도메인 전문성 포함.", + "paragraphs": [ + "〈대표자 핵심 역량〉 ① 헬스케어·건강기능식품 실무: 2015년 테팔러스 헬스플러스 건강기능식품 전문 매장 설립 프로젝트 참여. 전국 36개 지점 직원교육·고객 상담 담당. 인바디 기반 개인 맞춤 제품 제안 경험으로 AI 처방 설계의 현장 근거 확보. ② 근골격고 지도·교육 전문성: 10년 이상 필라티스 지도 및 근골격고 관리 교육 수행. 다양한 통증 사례 직접 지도를 통해 통증 패턴·생활습관·운동 습관 간 상관관계 현장 데이터 축적. ③ 케어클래시아카데미장: 전국 필라티스 강사 자격증 발급 권한 보유. 학원 등록 강사 전체가 즉시 플랫폼 파트너·유통 채널로 연결되어 제로 비용 초기 사용자 확보 구조 형성. ④ 전문가 네트워크 구축: 학원 운영을 통해 강사 교육 과정 및 전문가 네트워크 보유, 콘텐츠 개발·전문가 연계 서비스에 즉시 활용 가능한 파이프라인. ⑤ 명상·스포츠 전문성: 태슬리핀·필라테스 전임 출신 및 명상 전문가로서 근골격고 통증-스트레스 연관 콘텐츠와 만성통증 관리 프로그램 독자 설계 가능.", + "〈CTO 핵심 역량〉 GMV 3,000억 규모 커머스 기업의 사업개발 담당자 출신으로 15년 경력의 PM·기획·개발 통합형 전문가다. 대규모 트래픽 환경에서의 서비스 기획, 프로덕트 로드맵 수립, 테스크 개발 실행을 동시에 수행한 검증된 역량을 보유하며 이미 팀에 합류하였다. 기획과 개발을 단일 리더십 하에 통합하여 의사결정 속도를 높이고, MVP 개발에서 흔히 발생하는 기획-개발 간 적합성 문제를 구조적으로 해소한다. IoT 게이트웨이 아키텍처 설계, LLM 기반 AI 분석 모듈 구현, 데이터 파이프라인 구축까지 핵심 기술 과제를 직접 리드하며, 대형 커머스 플랫폼 운영 경험에서 축적된 사용자 경험 설계 노하우를 헬스케어 도메인에 이식한다.", + "〈기대 효과 및 사회적 가치〉 ① 근골격고 환자 1,500만 명의 파편화된 케어 경험 통합 → 병원·운동·재활·물리치료를 잇는 연속적 케어 루트 제공. ② 해소 후 관리 공백 해소 → 전문가가 통증 다이어리 하나로 고객 상태를 즉시 파악·연계. ③ 전국 필라티스 강사 교육 비용 43% 절감 → 온라인화로 지역 격차 해소. ④ 한국형 근골격고 AI 데이터 구축 → 병원 CRM·사무 자동화 연계로 헬스케어 인프라 확장 비전." + ] + }, + "4-2_조직구성계획": { + "instruction": "협약기간 조직 구성 계획. 필요 직무, 보완 역량, 채용 전략 포함.", + "paragraphs": [ + "협약기간 내 2명 추가 채용 계획: ① 콘텐츠 PD/마케터(협약 후 3개월) → SNS 채널 운영, 운동 영상 제작, B2B 파트너십 자료 제작 담당. 영상 편집·디지털 마케팅 경험 보유자, 건강·피트니스 분야 연관. ② 정형외과 의료 자문(협약 후 1개월, 파트너십 형태) → 콘텐츠 의학적 검토, 병원 파트너십 신뢰도 강화 담당. 정형외과 전공의 이상, 디지털 헬스케어 관심자.", + "중장기적으로는 데이터 사이언티스트 1명을 추가 채용하여 LLM 의존 단계에서 자체 경량 ML 모델 전환을 가속화할 계획이다. 조직 전체가 '데이터 회사'로서의 정체성을 공유하며, 모든 기능 개발과 콘텐츠 기획에서 사용자 데이터 축적과 분석 가능성을 최우선 기준으로 삼는 문화를 구축한다." + ] + } + }, + + "폐업이력": { + "instruction": "과거 폐업기업 개요. 최대 3개 기업, 최근순. 1개 기업만 있으면 companies 배열에 1개만.", + "총_폐업횟수": 1, + "companies": [ + { + "기업명": "○○○ 피트니스 스튜디오", + "기업구분": "개인", + "사업기간": "20○○.○○.○○.~20○○.○○.○○.", + "아이템_개요": "오프라인 필라티스·피트니스 스튜디오 운영 (1:1 개인 트레이닝 및 그룹 수업)", + "폐업원인": "코로나19 장기화에 따른 집합 금지 명령·운영 중단 반복으로 매출 급감. 임대료·인건비 등 고정비 부담이 지속되어 현금흐름이 악화됨. 오프라인 단일 수익 구조의 한계로 외부 충격에 대응하지 못하고 폐업에 이름." + } + ] + }, + + "추진일정": { + "instruction": "협약기간('26.4~10월) 목표 및 추진 일정. 3~5개 행.", + "rows": [ + { + "순번": 1, + "목표": "MVP 고도화 및 IoT 게이트웨이 1차 연동", + "세부내용": "헬 대시보드·운동 건강 정보 메뉴 개발 완료. Apple HealthKit·Samsung Health SDK 연동 게이트웨이 구축. 모티피직스 파트너십 계약 및 콘텐츠 관리 체계 설정.", + "일정": "~ 6월" + }, + { + "순번": 2, + "목표": "웹앱 전체 6개 메뉴 베타 론칭 및 IoT 기기 1차 보급", + "세부내용": "전문가 상담·주변 정보 메뉴 추가 개발. LLM 기반 AI 주간 리포트·크로노 알림 시스템 개발. OEM IoT 기기 100개 보급. B2B 정형외과 파트너십 10개소 계약.", + "일정": "~ 8월" + }, + { + "순번": 3, + "목표": "베타 유저 1,000명 확보 및 시장 검증", + "세부내용": "앱 리텐션 D7 50% 이상, NPS 40+ 달성. B2B SaaS 운동시터 10개소 유료 전환. 콘텐츠 라이선스 5개소 유료 계약. SNS 구독자 1만명·뉴스레터 2,000명 달성.", + "일정": "~ 9월" + }, + { + "순번": 4, + "목표": "통합 케어 경험 완성 및 파트너 생태계 확장", + "세부내용": "통증 다이어리·수면·바이탈 데이터를 통합하여 병원·운동시터·재활·물리치료 전문가가 앱 하나로 고객 상태를 연속적으로 확인하는 케어 루트 완성. 파트너 센터·병원 대상 데이터 공유 기능 고도화. 병원 CRM·사무 자동화 연계 비전 수립 및 후속 투자 유치 준비.", + "일정": "~ 10월" + } + ] + }, + + "사업비": { + "instruction": "사업비 구성. 비목별 산출근거와 금액.", + "constraints": { + "정부지원_한도": "최대 1억원", + "정부지원_비율": "총사업비의 75% 이하", + "현금_비율": "총사업비의 5% 이상", + "현물_비율": "총사업비의 20% 이하" + }, + "비목_options": ["재료비", "외주용역비", "인건비", "지식재산권 등 무형자산 취득비", "여비", "기타"], + "rows": [ + { + "비목": "인건비", + "산출근거": "CTO 350만원×7개월 / 대표자 200만원×7개월(현물)", + "정부지원": 24500000, + "현금": 0, + "현물": 14000000 + }, + { + "비목": "재료비", + "산출근거": "IoT 게이트웨이 테스트 장비·OEM 샘플·개발 장비", + "정부지원": 10000000, + "현금": 0, + "현물": 0 + }, + { + "비목": "외주용역비", + "산출근거": "모티피직스 도입·커스터마이징 / 운동영상 30개·인터뷰 편집", + "정부지원": 10000000, + "현금": 2000000, + "현물": 0 + }, + { + "비목": "지식재산권 등 무형자산 취득비", + "산출근거": "LLM API 크레딧(7개월) / 클라우드·AI 파이프라인 R&D 구축", + "정부지원": 11500000, + "현금": 0, + "현물": 0 + }, + { + "비목": "기타", + "산출근거": "SNS 광고비 / B2B 영업수당 / 법인설립·법무", + "정부지원": 4000000, + "현금": 4000000, + "현물": 0 + } + ] + }, + + "인력현황": { + "instruction": "재직 인력 고용현황. 채용 완료 인력만. 개인정보 마스킹 필수.", + "현재_재직인원": 2, + "추가_고용계획": 2, + "members": [ + { + "순번": 1, + "직위": "대표 (예비재창업자)", + "담당업무": "사업 총괄, 콘텐츠 기획·제작, B2B 영업, AI 처방 감수", + "보유역량": "근골격고 국제 건강관리사 / 케어클래시아카데미장 / 테팔러스 헬스케어 실무(전국 36개 지점) / 필라티스 지도 10년 이상 / 명상·스포츠 전문가" + }, + { + "순번": 2, + "직위": "CTO", + "담당업무": "서비스 기획·개발 총괄, IoT 게이트웨이 설계, LLM AI 모듈 구현, 데이터 파이프라인 구축", + "보유역량": "PM·기획·개발 통합 15년 / GMV 3,000억 ○○○ 커머스 사업개발 출신 / 플랫폼 기획~개발 단독 수행" + } + ] + }, + + "가점": { + "instruction": "해당 항목만 true로 변경. 해당 없으면 false 유지.", + "서류평가가점": { + "특별지원지역_소재": false, + "노란우산공제_가입": false, + "3년이상_업력": false, + "재도전_사례공모전_수상": false + }, + "발표평가가점": { + "유공포상_수상": false, + "타사업자등록_없음": false + }, + "서류평가면제": { + "K스타트업_왕중왕전_진출": false, + "중진공_심층평가_통과": false, + "중진공_특화교육_우수": false, + "유공포상_수상": false, + "TIPS_선정이력": false + }, + "우선선정": { + "K스타트업_왕중왕전_대상": false + } + }, + + "_manual_only": { + "_note": "아래 항목만 한글에서 직접 수정 필요", + "items": [ + "폐업이력 사업기간 실제 날짜 입력 (20○○.○○.○○. → 실제 날짜)", + "가점 해당 항목 true로 변경 확인", + "파란색 안내문구 삭제", + "붙임: 증빙서류 이미지 삽입", + "전체 7페이지 이내 확인" + ] + } +} diff --git "a/\354\202\254\354\227\205\352\263\204\355\232\215\354\204\234_schema.json" "b/\354\202\254\354\227\205\352\263\204\355\232\215\354\204\234_schema.json" new file mode 100644 index 0000000..35d85fe --- /dev/null +++ "b/\354\202\254\354\227\205\352\263\204\355\232\215\354\204\234_schema.json" @@ -0,0 +1,140 @@ +{ + "_instructions": { + "purpose": "2026년 재도전성공패키지 (예비)재창업기업 사업계획서 작성용 스키마", + "how_to_use": [ + "1. 모든 빈 문자열(\"\")과 빈 배열([])을 실제 내용으로 채워주세요", + "2. instruction 필드는 작성 지침이므로 수정하지 마세요", + "3. sections의 paragraphs 배열: 각 원소가 한글 파일에서 독립 문단이 됩니다", + "4. 완성된 JSON을 Claude Code에 전달하면 자동으로 HWPX 파일을 생성합니다" + ], + "constraints": [ + "사업계획서 본문은 목차 제외 7페이지 이내", + "개인정보는 반드시 마스킹 (성명→○○○, 대학→○○대, 생년→YYYY년 등)", + "과제명은 K-Startup 신청 시 입력한 과제명과 동일하게 기재" + ] + }, + + "meta": { + "과제명": "", + "기업명": "", + "아이템_개요": "" + }, + + "sections": { + "1-1_폐업원인분석": { + "instruction": "과거 폐업 원인을 분석한 결과 및 이를 극복하기 위한 개선 방안. 폐업의 근본 원인(시장/재무/경영/기술)을 구체적으로 분석하고, 재창업에서의 극복 방안을 제시.", + "paragraphs": [] + }, + "1-2_목표시장": { + "instruction": "목표시장(고객) 현황 및 경쟁사 분석, 재창업 아이템의 경쟁력. TAM/SAM/SOM, 경쟁사 대비 차별점, 고객 페인포인트 포함.", + "paragraphs": [] + }, + "2-1_준비현황": { + "instruction": "신청 이전까지 시제품 제작, 시장 반응 조사 등 사전 준비 현황. 개발 단계, 프로토타입, 고객 피드백, 기술 검증 포함.", + "paragraphs": [] + }, + "2-2_구체화방안": { + "instruction": "핵심 기능 및 개발/개선 구체적 계획. 기술 아키텍처, 개발 로드맵, 핵심 기능 명세 포함.", + "paragraphs": [] + }, + "3-1_비즈니스모델": { + "instruction": "가치 전달 체계 및 수익 창출 방법. 수익 모델(구독/수수료/라이선스 등), 가격 전략, 핵심 파트너, 비용 구조 포함.", + "paragraphs": [] + }, + "3-2_사업화전략": { + "instruction": "목표시장 진입/진출 방안과 고객 확보 전략. GTM 전략, 마케팅 채널, 초기 고객 확보, 파트너십 전략 포함.", + "paragraphs": [] + }, + "4-1_보유역량": { + "instruction": "대표자 및 조직의 사업화 역량. 예비재창업자는 대표자 중심. 기술 역량, 사업 경험, 도메인 전문성 포함.", + "paragraphs": [] + }, + "4-2_조직구성계획": { + "instruction": "협약기간 조직 구성 계획. 필요 직무, 보완 역량, 채용 전략 포함.", + "paragraphs": [] + } + }, + + "폐업이력": { + "instruction": "과거 폐업기업 개요. 최대 3개 기업, 최근순. 1개 기업만 있으면 companies 배열에 1개만.", + "총_폐업횟수": 0, + "companies": [ + { + "기업명": "", + "기업구분": "", + "사업기간": "", + "아이템_개요": "", + "폐업원인": "" + } + ] + }, + + "추진일정": { + "instruction": "협약기간('26.4~10월) 목표 및 추진 일정. 3~5개 행.", + "rows": [ + {"순번": 1, "목표": "", "세부내용": "", "일정": ""}, + {"순번": 2, "목표": "", "세부내용": "", "일정": ""}, + {"순번": 3, "목표": "", "세부내용": "", "일정": ""} + ] + }, + + "사업비": { + "instruction": "사업비 구성. 비목별 산출근거와 금액.", + "constraints": { + "정부지원_한도": "최대 1억원", + "정부지원_비율": "총사업비의 75% 이하", + "현금_비율": "총사업비의 5% 이상", + "현물_비율": "총사업비의 20% 이하" + }, + "비목_options": ["재료비", "외주용역비", "인건비", "지식재산권 등 무형자산 취득비", "여비", "기타"], + "rows": [ + {"비목": "재료비", "산출근거": "", "정부지원": 0, "현금": 0, "현물": 0}, + {"비목": "외주용역비", "산출근거": "", "정부지원": 0, "현금": 0, "현물": 0}, + {"비목": "인건비", "산출근거": "", "정부지원": 0, "현금": 0, "현물": 0} + ] + }, + + "인력현황": { + "instruction": "재직 인력 고용현황. 채용 완료 인력만. 개인정보 마스킹 필수.", + "현재_재직인원": 0, + "추가_고용계획": 0, + "members": [ + {"순번": 1, "직위": "", "담당업무": "", "보유역량": ""}, + {"순번": 2, "직위": "", "담당업무": "", "보유역량": ""} + ] + }, + + "가점": { + "instruction": "해당 항목만 true로 변경. 해당 없으면 false 유지.", + "서류평가가점": { + "특별지원지역_소재": false, + "노란우산공제_가입": false, + "3년이상_업력": false, + "재도전_사례공모전_수상": false + }, + "발표평가가점": { + "유공포상_수상": false, + "타사업자등록_없음": false + }, + "서류평가면제": { + "K스타트업_왕중왕전_진출": false, + "중진공_심층평가_통과": false, + "중진공_특화교육_우수": false, + "유공포상_수상": false, + "TIPS_선정이력": false + }, + "우선선정": { + "K스타트업_왕중왕전_대상": false + } + }, + + "_manual_only": { + "_note": "아래 항목만 한글에서 직접 수정 필요", + "items": [ + "폐업이력 2~3번째 기업 (빈 셀 직접 입력)", + "파란색 안내문구 삭제", + "붙임: 증빙서류 이미지 삽입", + "전체 7페이지 이내 확인" + ] + } +} diff --git "a/\354\213\234\354\212\244\355\205\234\355\224\204\353\241\254\355\224\204\355\212\270.md" "b/\354\213\234\354\212\244\355\205\234\355\224\204\353\241\254\355\224\204\355\212\270.md" new file mode 100644 index 0000000..35c93ee --- /dev/null +++ "b/\354\213\234\354\212\244\355\205\234\355\224\204\353\241\254\355\224\204\355\212\270.md" @@ -0,0 +1,210 @@ +# 재도전성공패키지 사업계획서 작성 어시스턴트 v2 + +## 역할 + +당신은 중소벤처기업부 정부지원사업 사업계획서 전문 컨설턴트입니다. 사용자와 대화하며 2026년 재도전성공패키지 (예비)재창업기업 사업계획서를 완성합니다. + +## 핵심 규칙 + +1. **프로젝트에 업로드된 `사업계획서_schema.json`이 마스터 양식입니다.** 섹션 구조와 필드를 숙지하세요. +2. **초안 작성·토론·수정 단계에서는 반드시 마크다운으로 작업합니다.** JSON 변환은 사용자가 "최종 확정" 또는 "JSON 변환"을 명시적으로 요청할 때만 수행합니다. +3. **사용자가 최종 승인하기 전까지 JSON으로 변환하지 마세요.** 어떤 경우에도 사용자의 명시적 승인 없이 JSON을 출력하지 않습니다. + +--- + +## 작업 흐름 + +### Phase 1: 컨텍스트 수집 + +사용자에게 다음을 질문하세요 (이미 알고 있는 것은 건너뛰기): +- 사업 아이템 개요 (무엇을 만드는가?) +- 이전 폐업 경험 (몇 회? 원인?) +- 현재 준비 상태 (MVP, 프로토타입, 팀 구성) +- 목표 시장과 경쟁 환경 +- 수익 모델 +- 예산 규모 (정부지원 희망 금액) + +### Phase 2: 마크다운 초안 작성 + 토론 + +**이 단계에서 모든 내용은 마크다운으로 작성합니다.** + +#### 마크다운 포맷 + +```markdown +# 재도전성공패키지 사업계획서 + +## 과제 개요 +- **과제명**: (내용) +- **기업명**: (내용) +- **아이템 개요**: (내용) + +## 폐업 이력 (총 N회) + +### 1번째 기업 +- 기업명: / 기업구분: 개인·법인 +- 사업기간: YYYY.MM.DD.~YYYY.MM.DD. +- 아이템 개요: +- 폐업 원인: + +## 1-1. 폐업 원인 분석 및 개선 방안 + +(여러 단락으로 자유롭게 작성. 각 단락 사이에 빈 줄.) + +## 1-2. 목표시장(고객) 현황 및 필요성 + +(내용) + +## 2-1. 재창업 아이템 준비 현황 + +(내용) + +## 2-2. 재창업 아이템 실현 및 구체화 방안 + +(내용) + +## 3-1. 비즈니스 모델 + +(내용) + +## 3-2. 사업화 추진 전략 + +(내용) + +## 3-3. 추진 일정 및 자금 운용 계획 + +### 추진 일정 ('26.4~10월) + +| 순번 | 목표 | 세부 내용 | 일정 | +|------|------|----------|------| +| 1 | ... | ... | ~ 6월 | +| 2 | ... | ... | ~ 8월 | +| 3 | ... | ... | ~ 10월 | + +### 사업비 + +| 비목 | 산출근거 | 정부지원 | 현금 | 현물 | 합계 | +|------|---------|---------|------|------|------| +| 재료비 | • ... | 0 | 0 | 0 | 0 | +| ... | | | | | | +| **합계** | | **0** | **0** | **0** | **0** | + +## 4-1. 조직 구성 및 보유 역량 + +(내용) + +### 인력 현황 + +| 순번 | 직위 | 담당 업무 | 보유역량 | +|------|------|----------|---------| +| 1 | ... | ... | ... | + +- 현재 재직 인원: N명 +- 추가 고용 계획: N명 + +## 4-2. 조직 구성 계획 + +(내용) + +## 가점/면제 해당 사항 + +- [ ] 서류평가 가점 ①~④: (해당 항목 체크) +- [ ] 발표평가 가점 ①~②: (해당 항목 체크) +- [ ] 서류평가 면제 ①~⑤: (해당 항목 체크) +- [ ] 우선선정: (해당 시) +``` + +#### 토론 진행 방식 + +1. **섹션 단위로 작성** → 사용자 피드백 → 수정 반복 +2. 사용자가 "OK", "다음", "넘어가" 등으로 승인하면 다음 섹션으로 이동 +3. 사용자가 수정 요청하면 해당 섹션만 마크다운으로 재작성 +4. 전체 초안 완성 후, **전문을 한번에 마크다운으로 보여주고** 최종 리뷰 요청 + +### Phase 3: 최종 확정 → JSON 변환 + +**트리거**: 사용자가 다음 중 하나를 말할 때만 JSON 변환을 수행합니다: +- "최종 확정" +- "JSON 변환해줘" +- "확정이야" +- "이대로 진행" + +**변환 시 수행할 작업:** + +1. 확정된 마크다운 내용을 `사업계획서_schema.json` 구조에 맞춰 JSON으로 변환 +2. 마크다운의 각 `##` 섹션의 단락들 → `paragraphs` 배열로 매핑 (빈 줄 기준으로 단락 분리) +3. 테이블 데이터 → 해당 JSON 필드로 매핑 +4. 출력 전 자체 검증 (체크리스트 참조) +5. **완성된 JSON 전체를 코드 블록으로 출력** + +--- + +## 작성 가이드라인 + +### 서술 섹션 (1-1 ~ 4-2) +- 각 섹션 2~4단락 (마크다운에서 빈 줄로 구분) +- 한 단락 3~5문장 +- 구체적 숫자, 데이터, 근거 포함 +- "~할 것이다" → "~를 통해 ~를 달성한다" 식의 구체적 서술 +- 전문 용어는 심사위원이 이해 가능한 수준으로 + +### 폐업이력 +- 최대 3개 기업, 최근순 +- 기업구분: "개인" 또는 "법인" +- 사업기간: "YYYY.MM.DD.~YYYY.MM.DD." 형식 + +### 추진일정 +- 3~5개 행, 협약기간 '26.4월~10월 +- 일정: "~ 6월", "~ 8월" 형식 + +### 사업비 +- 정부지원 합계 ≤ 1억원 +- 정부지원 ≤ 총사업비의 75% +- 현금 ≥ 총사업비의 5% +- 현물 ≤ 총사업비의 20% +- 산출근거: "• 항목명(수량×단가)" 형식 +- 금액 단위: 원 + +### 인력현황 +- **개인정보 마스킹 필수**: 성명→○○○, 대학→○○대 +- 보유역량: 학력과 경력 중심 (마스킹 형태) +- 채용 완료 인력만 기재 +- 예비재창업자는 작성 불필요 + +### 가점 +- 해당 항목만 체크 + +## 제약사항 + +- 본문 7페이지 이내 (목차·붙임 제외) +- 개인정보(성명, 성별, 생년월일, 대학명, 소재지, 직장명) 반드시 마스킹 +- 과제명은 K-Startup 신청 과제명과 동일 + +--- + +## JSON 변환 시 체크리스트 + +출력 전 자체 검증: +- [ ] meta 3개 필드 비어있지 않은가? +- [ ] 8개 섹션 모두 paragraphs가 채워져 있는가? +- [ ] 폐업이력 companies에 최소 1개 기업이 있는가? +- [ ] 추진일정 rows가 3개 이상인가? +- [ ] 사업비 정부지원 합계 ≤ 1억인가? +- [ ] 사업비 비율 조건을 충족하는가? (정부지원≤75%, 현금≥5%, 현물≤20%) +- [ ] 인력현황 members가 최소 1명 있는가? +- [ ] "OO", "00개", "0000원" 같은 플레이스홀더가 남아있지 않은가? +- [ ] 개인정보가 마스킹되어 있는가? +- [ ] `사업계획서_schema.json`의 모든 키가 빠짐없이 포함되어 있는가? + +## JSON 구조 매핑 참조 + +마크다운 → JSON 변환 시 참조: + +| 마크다운 섹션 | JSON 경로 | +|-------------|-----------| +| 과제명/기업명/아이템 개요 | `meta.과제명`, `meta.기업명`, `meta.아이템_개요` | +| ## 1-1 ~ ## 4-2 | `sections.{key}.paragraphs` (빈 줄 기준 단락 분리) | +| 폐업 이력 | `폐업이력.총_폐업횟수`, `폐업이력.companies[]` | +| 추진 일정 테이블 | `추진일정.rows[]` | +| 사업비 테이블 | `사업비.rows[]` (금액은 정수, 콤마 없이) | +| 인력 현황 테이블 | `인력현황.members[]`, `현재_재직인원`, `추가_고용계획` | +| 가점 체크 | `가점.서류평가가점.*`, `가점.발표평가가점.*` 등 (true/false) | From 64bcc20045376da7c171221851b946baf7ccb6eb Mon Sep 17 00:00:00 2001 From: Baekho Lim Date: Sun, 15 Mar 2026 14:09:08 +0900 Subject: [PATCH 23/24] chore: add __pycache__, .omx, md2hwp-outputs to .gitignore Co-Authored-By: Claude Opus 4.6 --- .gitignore | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.gitignore b/.gitignore index 11dd5e4..6425091 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,13 @@ Thumbs.db # Build artifacts *.log + +# Python +__pycache__/ +*.pyc + +# Editor +.omx/ + +# md2hwp outputs +testdata/md2hwp-outputs/ From ab2cad02ed6581074584a1e2383309a578a9b4a7 Mon Sep 17 00:00:00 2001 From: Baekho Lim Date: Tue, 17 Mar 2026 04:04:27 +0900 Subject: [PATCH 24/24] chore: restore testdata fixtures, exclude personal business plan from tracking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Restored 11 deleted testdata files to their original paths - Removed 바이탈루_사업계획서_최종.json from git tracking (public repo) - Added 바이탈루_*.json pattern to .gitignore Co-Authored-By: Claude Opus 4.6 --- .gitignore | 3 + ...354\204\234_\354\265\234\354\242\205.json" | 224 ------------------ 2 files changed, 3 insertions(+), 224 deletions(-) delete mode 100644 "\353\260\224\354\235\264\355\203\210\353\243\250_\354\202\254\354\227\205\352\263\204\355\232\215\354\204\234_\354\265\234\354\242\205.json" diff --git a/.gitignore b/.gitignore index 6425091..29f1cba 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,6 @@ __pycache__/ # md2hwp outputs testdata/md2hwp-outputs/ + +# Personal business plan data +바이탈루_*.json diff --git "a/\353\260\224\354\235\264\355\203\210\353\243\250_\354\202\254\354\227\205\352\263\204\355\232\215\354\204\234_\354\265\234\354\242\205.json" "b/\353\260\224\354\235\264\355\203\210\353\243\250_\354\202\254\354\227\205\352\263\204\355\232\215\354\204\234_\354\265\234\354\242\205.json" deleted file mode 100644 index 5c3f5ca..0000000 --- "a/\353\260\224\354\235\264\355\203\210\353\243\250_\354\202\254\354\227\205\352\263\204\355\232\215\354\204\234_\354\265\234\354\242\205.json" +++ /dev/null @@ -1,224 +0,0 @@ -{ - "meta": { - "과제명": "IoT·AI 기반 근골격고 맞춤형 통합 헬스케어 플랫폼 〈바이탈루(Vitalu)〉", - "기업명": "예비재창업자", - "아이템_개요": "IoT 바이탈 기기(심박·운동포화도·수면·낙상 감지)와 AI 패턴 분석, 근골격고 전문가 감수 콘텐츠를 결합한 통합 헬스케어 웹앱 플랫폼. ① 통증 다이어리 & 근골격고 초기 평가 질문지 ② IoT 바이탈 연동 헬 대시보드 ③ AI 기반 주간 건강 리포트 ④ 맞춤 운동영상·라문·영상 정보(QR 코드 제공) ⑤ 전문가 상담 신청 ⑥ 주변 운동시터·병원 정보" - }, - - "sections": { - "1-1_폐업원인분석": { - "instruction": "과거 폐업 원인을 분석한 결과 및 이를 극복하기 위한 개선 방안. 폐업의 근본 원인(시장/재무/경영/기술)을 구체적으로 분석하고, 재창업에서의 극복 방안을 제시.", - "paragraphs": [ - "전 사업체(오프라인 필라티스·피트니스 스튜디오)의 폐업 원인은 세 가지 구조적 문제로 귀결된다. 첫째, 매출 100%가 현장 수업에 집중된 오프라인 단일 수익 구조였다. 코로나19 집합금지 명령 발동 시 매출이 즉시 0이 되는 취약성이 드러났으며, 외부 충격에 대응할 분산 메커니즘이 전무하였다. 둘째, 고정비(임대료·인건비) 대비 수익 탄력성이 부재하여 수익분기점 이전 유지가 구조적으로 불가능하였다. 셋째, 체계적인 회원 데이터 관리 및 CRM 부재로 이탈 고객 재유치 채널이 없었고, 해소 후 고객 관리가 불가능하였다.", - "이번 재창업은 폐업의 세 가지 근본 원인을 전면으로 대응하는 구조로 설계되었다. 수익원을 B2C 앱 구독·IoT 기기·B2B SaaS·콘텐츠 라이선스·강사 교육 5개 채널로 분산하여 오프라인 중단 시에도 디지털 수익이 유지되는 하이브리드 구조를 구축한다. SaaS 구독 모델은 한계비용 없이 수익이 확장되므로 고정비 구조를 근본적으로 해소한다. AI 통증 분석과 주간 리포트를 통해 사용자에게 매일 개인화 가치를 제공하고, 데이터 축적으로 자연스러운 전환 비용(switching cost)을 형성하여 리텐션을 내재화한다.", - "폐업 원인별 개선 방안: ① 오프라인 단일 수익 구조(매출 100% 현장 집중, 코로나19 시 즉시 매출 0) → B2C 구독·IoT 기기·B2B SaaS·콘텐츠 라이선스·강사 교육 5개 독립 수익원 구축, 오프라인 중단 시에도 디지털 수익 유지. ② 고정비 대비 수익 탄력성 부재(임대료·인건비 고정비 대비 가변 수익 단위) → SaaS형 구독 모델로 한계비용 없이 수익 확장, 클라우드 기반 플랫폼으로 전국 고객 수용. ③ 고객 리텐션 시스템 미흡(회원 데이터 관리 부재, 이탈 고객 재유치 불가) → AI 통증 분석·주간 리포트로 매일 개인화 가치 제공, 데이터 축적으로 전환 비용 자연 형성.", - "폐업 경험은 단순한 실패가 아닌, 오프라인 헬스케어 현장에서 고객 니즈를 직접 검증한 자산으로 전환되었다. 10년간 축적한 근골격고 전문 역량, 필라티스 강사 네트워크, 고객 통증 패턴 데이터는 재창업 아이템의 핵심 경쟁 자산으로 작동한다." - ] - }, - "1-2_목표시장": { - "instruction": "목표시장(고객) 현황 및 경쟁사 분석, 재창업 아이템의 경쟁력. TAM/SAM/SOM, 경쟁사 대비 차별점, 고객 페인포인트 포함.", - "paragraphs": [ - "〈시장 규모 및 성장성〉 국내 근골격고 환자 약 1,500만 명 / 연 진료비 5조원 이상(TAM 핵심). 국내 디지털 헬스케어 시장 약 2.4조원(2025) / CAGR 48%(고성장 시장). 독거노인·복지 모니터링 약 170만 명 / B2G 연 1조원 이상(B2G 채널). 전국 운동시터 약 1만 개소 이상(B2B SaaS 채널).", - "근골격고 통증 환자의 가장 큰 문제는 케어가 파편화되어 있다는 것이다. 병원에서 진단받고, 운동시터에서 운동하고, 재활·물리치료를 따로 받지만, 이 경험들은 서로 연결되지 않는다. 의사는 환자가 평소 어떻게 움직이는지 모르고, 트레이너는 병원에서 어떤 진단을 받았는지 모른다. 환자 스스로도 언제 어떻게 아팠는지 기억에 의존할 뿐, 자신의 몸 상태를 객관적으로 파악할 수단이 없다.", - "목표 고객별 핵심 문제: ① B2C 30~60대 만성 통증 환자 → 병원·운동·재활을 각각 따로 다니며 연결 없는 케어 반복, 내 몸의 패턴을 스스로 알 수 없음. ② B2C 통증 예방 관심 성인 → 막연한 불안감에 유튜브·SNS를 뒤지지만 신뢰할 수 있는 맞춤 정보가 없음. ③ B2B 운동시터·필라티스·재활 전문가 → 신규 고객 상태 파악에 시간이 걸리고, 첫 상담 이후 연속적 케어 기록이 남지 않음. ④ B2B 정형외과·재활의원 → 해소 후 환자 관리 수단이 없고, 재진 시 일정 경과를 알 방법이 없음. ⑤ B2G 지자체·보건소 → 독거노인 등 취약계층의 일상 바이탈 모니터링 수단 부재.", - "이 문제를 해결하는 것이 바이탈루의 출발점이다. 통증 다이어리·수면·바이탈 데이터를 하나로 모아 환자와 전문가 모두에게 끊기지 않는 케어 흐름을 제공한다. 바이탈루는 IoT 제조사가 아닌 데이터 플랫폼이다. 사용자가 이미 보유한 스마트워치(애플워치·갤럭시워치·오스미 등)를 멀티 게이트웨이로 연동하며, 기기가 없는 사용자에게도 저가 OEM 기기(원가 5,000~8,000원)를 최소 마진으로 보급한다.", - "〈경쟁사 분석 및 차별화〉 병원 치료는 통원 중에만 관리 가능하며 해소 후 관리가 전무하다. 유튜브·SNS는 무료이나 전문성이 없고 개인화가 불가능하다. 일반 헬스앱은 근골격고 특화가 없고 AI 분석이 미흡하다. 바이탈루는 IoT 멀티 게이트웨이 연동, 전문가 감수 콘텐츠, LLM 기반 통증 패턴 AI, 해소 후 지속 관리, 앱 내 채팅·즉시 예약을 월 9,900원부터 제공한다. 핵심 경쟁 우위: 근골격고 전문가 자격을 보유한 창업자가 직접 콘텐츠를 감수하고, 전국 필라티스 강사 네트워크(케어클래시아카데미)를 즉시 유통 채널로 활용할 수 있는 진입장벽은 경쟁사 복제 불가하다." - ] - }, - "2-1_준비현황": { - "instruction": "신청 이전까지 시제품 제작, 시장 반응 조사 등 사전 준비 현황. 개발 단계, 프로토타입, 고객 피드백, 기술 검증 포함.", - "paragraphs": [ - "바이탈루는 아이디어 단계가 아니다. 오프라인 사업 이전·MVP 개발·클로즈 베타·학원 설립·파트너사 확보까지 단계별 실행이 이미 진행 중이며, 실제 매출과 고객이 존재한다.", - "준비 현황 및 검증 내용: ① MVP 개발 완료 → 통증 다이어리 및 초기 평가 핵심 기능 구축 완료. 기존 운영 필라티스 센터 및 학원사 환자 대상 클로즈 베타 운영 중. ② 실매출 오프라인 센터 → 오프라인 필라티스 센터 운영 중, 연 매출 1억원 이상 달성, 실제 고객 기반 및 통증 케이스 노하우 축적. ③ 전문 강사 양성 학원 설립 → 근골격고 전문 강사 양성 학원 설립 및 콘텐츠 제작 진행 중, 학원를 통한 플랫폼 파트너사 1호 계약 완료. ④ 콘텐츠 파이프라인 → 근골격고 운동 영상 기획안 30개 작성, 정형외과 1개 클리닉 QR 파트너십 사전 협의 중. ⑤ IoT 멀티 게이트웨이 → Apple HealthKit·Samsung Health SDK·Google Health Connect·Zepp API 연동 기술 검토 및 아키텍처 방향 수립 완료. ⑥ 팀 구성 → CTO 내부 합류 완료, GMV 3,000억 커머스 사업개발 출신 PM·기획·개발 15년 통합 경력자.", - "협약 시점에 이미 ① 검증된 오프라인 매출, ② 클로즈 베타 사용 고객, ③ 플랫폼 파트너사 1호를 확보한 상태로, 협약 기간은 검증된 기반 위에서 디지털 전환과 스케일업을 실행하는 단계이다." - ] - }, - "2-2_구체화방안": { - "instruction": "핵심 기능 및 개발/개선 구체적 계획. 기술 아키텍처, 개발 로드맵, 핵심 기능 명세 포함.", - "paragraphs": [ - "근골격고 통증으로 병원·운동·재활을 따로따로 다니는 30~60대를 위해, 바이탈루는 통증 일기·수면·바이탈 데이터를 하나로 연결해 AI가 맞춤 운동을 처방하고 가까운 전문가를 즉시 연결한다. 흩어진 케어 경험을 하나의 앱으로 이어붙여, 환자는 '내 몸의 흐름'을 처음으로 하나로 보게 되고 전문가는 첫 만남부터 고객 상태를 정확히 파악할 수 있다.", - "〈6개 메뉴 → 파편화된 케어를 하나로 잇는 구조〉 ① 초기 평가(완료): '내 몸 상태를 객관적으로 모른다' 문제 해결 → 근골격고 질문지 6항목으로 신경·근즈·구조적 문제 감별 추적, 진단 이력 입력. ② 통증 다이어리(MVP 완료·클로즈 베타 운영 중): '언제 어떻게 아팠는지 기억에만 의존한다' 문제 해결 → 부위·강도·증상 태그·영상 메모 입력, AI가 반복 패턴 추출. ③ 헬 대시보드(협약 후 3개월): '수면·활동·통증의 연관성을 모른다' 문제 해결 → 스마트워치 바이탈 자동 연동, AI 주간 인사이트 리포트 발송. ④ 운동·건강 정보(협약 후 4개월): '믿을 수 있는 맞춤 운동 정보가 없다' 문제 해결 → 전문가 감수 운동영상·라문·영상정보, QR코드로 병원 대기실 영상·처방 URL 발송. ⑤ 전문가 상담(협약 후 5개월): '전문가 찾기가 번거롭고 비싸다' 문제 해결 → 필라티스 강사·물리치료사·운동처방사 즉시 예약·앱 내 채팅, 의사·한의사도 예약 연결 예정 운영. ⑥ 주변 정보(협약 후 5개월): '내 상태에 맞는 가까운 전문가를 모른다' 문제 해결 → GPS 기반 파트너 센터 지도, 모티피직스 설치 센터 우선 노출, 무료 3D 체형분석 예약.", - "〈오프라인 연결 전략 → 디지털-현장 순환 생태계〉 앱만으로는 파편화 문제를 완전히 해결할 수 없다. 바이탈루는 두 가지 오프라인 파트너 채널로 앱과 현장을 연결한다. 첫째, 모티피직스 파트너 센터: 전국 700개 이상 운동시터·필라티스에 영업 중인 3D 체형분석 솔루션 모티피직스을 유통 파트너로 협력한다. 앱 사용자는 인근 설치 센터에서 무료 체형분석을 예약하고, 센터는 바이탈루 앱의 통증 다이어리를 공유받아 첫 만남부터 맞춤 케어를 제공한다. 둘째, 병원 처방 콘텐츠 솔루션: 정형외과·재활의원에서 근골격고 운동 영상을 메타한다. 대기실 영상으로 브랜드를 노출하고, 진료 후 의사가 QR/URL로 맞춤 처방 운동을 환자에게 즉시 전달한다. 1년 무료 제공 후 연간 유료 구독(기관별 월 30만원~)으로 전환한다.", - "〈장기 비전 → 케어 인프라로의 확장〉 사용자 데이터가 쌓일수록 AI 처방 정밀도는 높아지고, 전문가들은 바이탈루 앱 하나로 고객의 케어 전 과정을 파악하게 된다. 장기적으로는 병원 CRM·예약·사무 시스템과 연계하여 진료 기록과 처방을 자동화하는 헬스케어 인프라로 확장하는 것이 바이탈루의 비전이다." - ] - }, - "3-1_비즈니스모델": { - "instruction": "가치 전달 체계 및 수익 창출 방법. 수익 모델(구독/수수료/라이선스 등), 가격 전략, 핵심 파트너, 비용 구조 포함.", - "paragraphs": [ - "〈수익 모델〉 ① B2C 앱 구독(개인 사용자): 베이직 월 9,900원 / 케어 월 19,900원, 협약기간 목표 유료 전환 50명, IoT 연동·AI 분석·전문가 상담 포함. ② IoT 기기 판매(개인): 39,000원, 목표 100개 판매, 최소 마진 운영으로 앱 구독 전환 유도. ③ B2B SaaS(운동시터·스튜디오): 월 30~50만원, 목표 파트너 10개소, 매니저 대시보드·환자 데이터 포함. ④ 콘텐츠 라이선스-병원 처방 솔루션(정형외과·재활센터): 기관별 월 30만원 이상, 목표 계약 5개소, 1년 무료 후 유료 전환·진료 후 처방 URL 발송 기능 포함. ⑤ 강사 교육(케어클래시아카데미 수강생): 198만원/과정, 목표 2기 이상, 온·오프 하이브리드 기존 비용 43% 절감.", - "바이탈루의 본질적 경쟁력은 기기 판매 마진이 아닌 데이터 축적과 사용자 경험에 있다. 사용자가 기존 스마트워치나 저가 OEM 기기로 데이터를 쌓을수록 AI 분석 정밀도가 높아지고 전환 비용(switching cost)이 자연 형성된다. 장기적으로 축적된 한국형 근골격고 AI 데이터는 제약·보험·공공 분야의 B2B2G 데이터 자산으로 전환되는 경로를 만든다." - ] - }, - "3-2_사업화전략": { - "instruction": "목표시장 진입/진출 방안과 고객 확보 전략. GTM 전략, 마케팅 채널, 초기 고객 확보, 파트너십 전략 포함.", - "paragraphs": [ - "모티피직스 파트너 센터 네트워크: 바이탈루가 모티피직스(3D 체형분석기, 전국 700개 이상 센터 영업) 유통 파트너로 협력한다. 신규 파트너 센터(운동시터·필라티스·재활센터·정형외과)에 모티피직스 도입을 지원하고, 바이탈루 앱 사용자는 '내 주변 모티피직스 설치 센터'에서 무료 3D 체형분석·상담을 예약 받는다. 오프라인 체험이 앱 구독 전환의 핵심 트리거로 작동한다.", - "학원 네트워크 직접: 케어클래시아카데미 등록 강사(전국) 대상 우선 배포. 강사가 수강생에게 앱 추천 시 제로비용 고객 유입 구조가 작동한다. 자격증 취득자에게 앱 구독 1개월 무료 제공 패키지를 운영한다.", - "병원·클리닉 콘텐츠 솔루션: 정형외과·재활의원 대상 근골격고 질환별 올바른 자세·운동 영상 콘텐츠를 메타한다. 대기실 영상과 진료 후 의사의 QR/URL 링크 환자 맞춤 처방 전달로 활용된다. 1년 무료 제공 후 이후 연간 유료 구독 전환하며, 환자가 링크 클릭 시 바이탈루 앱으로 자연 유입된다.", - "디지털 콘텐츠 마케팅: 유튜브·인스타그램 근골격고 60초 일상 팁 주 3개 발행, 목표 구독 1만 명. 라문 기반 주간 뉴스레터 구독 2,000명 목표. 콘텐츠 말미 앱 CTA 삽입으로 유기적 유입 구조를 구축한다. 지자체 B2G 접근: 보건소·복지부 디지털헬스케어 실증 공모 연계, 시범사업 후 본 계약 구조로 진행한다." - ] - }, - "4-1_보유역량": { - "instruction": "대표자 및 조직의 사업화 역량. 예비재창업자는 대표자 중심. 기술 역량, 사업 경험, 도메인 전문성 포함.", - "paragraphs": [ - "〈대표자 핵심 역량〉 ① 헬스케어·건강기능식품 실무: 2015년 테팔러스 헬스플러스 건강기능식품 전문 매장 설립 프로젝트 참여. 전국 36개 지점 직원교육·고객 상담 담당. 인바디 기반 개인 맞춤 제품 제안 경험으로 AI 처방 설계의 현장 근거 확보. ② 근골격고 지도·교육 전문성: 10년 이상 필라티스 지도 및 근골격고 관리 교육 수행. 다양한 통증 사례 직접 지도를 통해 통증 패턴·생활습관·운동 습관 간 상관관계 현장 데이터 축적. ③ 케어클래시아카데미장: 전국 필라티스 강사 자격증 발급 권한 보유. 학원 등록 강사 전체가 즉시 플랫폼 파트너·유통 채널로 연결되어 제로 비용 초기 사용자 확보 구조 형성. ④ 전문가 네트워크 구축: 학원 운영을 통해 강사 교육 과정 및 전문가 네트워크 보유, 콘텐츠 개발·전문가 연계 서비스에 즉시 활용 가능한 파이프라인. ⑤ 명상·스포츠 전문성: 태슬리핀·필라테스 전임 출신 및 명상 전문가로서 근골격고 통증-스트레스 연관 콘텐츠와 만성통증 관리 프로그램 독자 설계 가능.", - "〈CTO 핵심 역량〉 GMV 3,000억 규모 커머스 기업의 사업개발 담당자 출신으로 15년 경력의 PM·기획·개발 통합형 전문가다. 대규모 트래픽 환경에서의 서비스 기획, 프로덕트 로드맵 수립, 테스크 개발 실행을 동시에 수행한 검증된 역량을 보유하며 이미 팀에 합류하였다. 기획과 개발을 단일 리더십 하에 통합하여 의사결정 속도를 높이고, MVP 개발에서 흔히 발생하는 기획-개발 간 적합성 문제를 구조적으로 해소한다. IoT 게이트웨이 아키텍처 설계, LLM 기반 AI 분석 모듈 구현, 데이터 파이프라인 구축까지 핵심 기술 과제를 직접 리드하며, 대형 커머스 플랫폼 운영 경험에서 축적된 사용자 경험 설계 노하우를 헬스케어 도메인에 이식한다.", - "〈기대 효과 및 사회적 가치〉 ① 근골격고 환자 1,500만 명의 파편화된 케어 경험 통합 → 병원·운동·재활·물리치료를 잇는 연속적 케어 루트 제공. ② 해소 후 관리 공백 해소 → 전문가가 통증 다이어리 하나로 고객 상태를 즉시 파악·연계. ③ 전국 필라티스 강사 교육 비용 43% 절감 → 온라인화로 지역 격차 해소. ④ 한국형 근골격고 AI 데이터 구축 → 병원 CRM·사무 자동화 연계로 헬스케어 인프라 확장 비전." - ] - }, - "4-2_조직구성계획": { - "instruction": "협약기간 조직 구성 계획. 필요 직무, 보완 역량, 채용 전략 포함.", - "paragraphs": [ - "협약기간 내 2명 추가 채용 계획: ① 콘텐츠 PD/마케터(협약 후 3개월) → SNS 채널 운영, 운동 영상 제작, B2B 파트너십 자료 제작 담당. 영상 편집·디지털 마케팅 경험 보유자, 건강·피트니스 분야 연관. ② 정형외과 의료 자문(협약 후 1개월, 파트너십 형태) → 콘텐츠 의학적 검토, 병원 파트너십 신뢰도 강화 담당. 정형외과 전공의 이상, 디지털 헬스케어 관심자.", - "중장기적으로는 데이터 사이언티스트 1명을 추가 채용하여 LLM 의존 단계에서 자체 경량 ML 모델 전환을 가속화할 계획이다. 조직 전체가 '데이터 회사'로서의 정체성을 공유하며, 모든 기능 개발과 콘텐츠 기획에서 사용자 데이터 축적과 분석 가능성을 최우선 기준으로 삼는 문화를 구축한다." - ] - } - }, - - "폐업이력": { - "instruction": "과거 폐업기업 개요. 최대 3개 기업, 최근순. 1개 기업만 있으면 companies 배열에 1개만.", - "총_폐업횟수": 1, - "companies": [ - { - "기업명": "○○○ 피트니스 스튜디오", - "기업구분": "개인", - "사업기간": "20○○.○○.○○.~20○○.○○.○○.", - "아이템_개요": "오프라인 필라티스·피트니스 스튜디오 운영 (1:1 개인 트레이닝 및 그룹 수업)", - "폐업원인": "코로나19 장기화에 따른 집합 금지 명령·운영 중단 반복으로 매출 급감. 임대료·인건비 등 고정비 부담이 지속되어 현금흐름이 악화됨. 오프라인 단일 수익 구조의 한계로 외부 충격에 대응하지 못하고 폐업에 이름." - } - ] - }, - - "추진일정": { - "instruction": "협약기간('26.4~10월) 목표 및 추진 일정. 3~5개 행.", - "rows": [ - { - "순번": 1, - "목표": "MVP 고도화 및 IoT 게이트웨이 1차 연동", - "세부내용": "헬 대시보드·운동 건강 정보 메뉴 개발 완료. Apple HealthKit·Samsung Health SDK 연동 게이트웨이 구축. 모티피직스 파트너십 계약 및 콘텐츠 관리 체계 설정.", - "일정": "~ 6월" - }, - { - "순번": 2, - "목표": "웹앱 전체 6개 메뉴 베타 론칭 및 IoT 기기 1차 보급", - "세부내용": "전문가 상담·주변 정보 메뉴 추가 개발. LLM 기반 AI 주간 리포트·크로노 알림 시스템 개발. OEM IoT 기기 100개 보급. B2B 정형외과 파트너십 10개소 계약.", - "일정": "~ 8월" - }, - { - "순번": 3, - "목표": "베타 유저 1,000명 확보 및 시장 검증", - "세부내용": "앱 리텐션 D7 50% 이상, NPS 40+ 달성. B2B SaaS 운동시터 10개소 유료 전환. 콘텐츠 라이선스 5개소 유료 계약. SNS 구독자 1만명·뉴스레터 2,000명 달성.", - "일정": "~ 9월" - }, - { - "순번": 4, - "목표": "통합 케어 경험 완성 및 파트너 생태계 확장", - "세부내용": "통증 다이어리·수면·바이탈 데이터를 통합하여 병원·운동시터·재활·물리치료 전문가가 앱 하나로 고객 상태를 연속적으로 확인하는 케어 루트 완성. 파트너 센터·병원 대상 데이터 공유 기능 고도화. 병원 CRM·사무 자동화 연계 비전 수립 및 후속 투자 유치 준비.", - "일정": "~ 10월" - } - ] - }, - - "사업비": { - "instruction": "사업비 구성. 비목별 산출근거와 금액.", - "constraints": { - "정부지원_한도": "최대 1억원", - "정부지원_비율": "총사업비의 75% 이하", - "현금_비율": "총사업비의 5% 이상", - "현물_비율": "총사업비의 20% 이하" - }, - "비목_options": ["재료비", "외주용역비", "인건비", "지식재산권 등 무형자산 취득비", "여비", "기타"], - "rows": [ - { - "비목": "인건비", - "산출근거": "CTO 350만원×7개월 / 대표자 200만원×7개월(현물)", - "정부지원": 24500000, - "현금": 0, - "현물": 14000000 - }, - { - "비목": "재료비", - "산출근거": "IoT 게이트웨이 테스트 장비·OEM 샘플·개발 장비", - "정부지원": 10000000, - "현금": 0, - "현물": 0 - }, - { - "비목": "외주용역비", - "산출근거": "모티피직스 도입·커스터마이징 / 운동영상 30개·인터뷰 편집", - "정부지원": 10000000, - "현금": 2000000, - "현물": 0 - }, - { - "비목": "지식재산권 등 무형자산 취득비", - "산출근거": "LLM API 크레딧(7개월) / 클라우드·AI 파이프라인 R&D 구축", - "정부지원": 11500000, - "현금": 0, - "현물": 0 - }, - { - "비목": "기타", - "산출근거": "SNS 광고비 / B2B 영업수당 / 법인설립·법무", - "정부지원": 4000000, - "현금": 4000000, - "현물": 0 - } - ] - }, - - "인력현황": { - "instruction": "재직 인력 고용현황. 채용 완료 인력만. 개인정보 마스킹 필수.", - "현재_재직인원": 2, - "추가_고용계획": 2, - "members": [ - { - "순번": 1, - "직위": "대표 (예비재창업자)", - "담당업무": "사업 총괄, 콘텐츠 기획·제작, B2B 영업, AI 처방 감수", - "보유역량": "근골격고 국제 건강관리사 / 케어클래시아카데미장 / 테팔러스 헬스케어 실무(전국 36개 지점) / 필라티스 지도 10년 이상 / 명상·스포츠 전문가" - }, - { - "순번": 2, - "직위": "CTO", - "담당업무": "서비스 기획·개발 총괄, IoT 게이트웨이 설계, LLM AI 모듈 구현, 데이터 파이프라인 구축", - "보유역량": "PM·기획·개발 통합 15년 / GMV 3,000억 ○○○ 커머스 사업개발 출신 / 플랫폼 기획~개발 단독 수행" - } - ] - }, - - "가점": { - "instruction": "해당 항목만 true로 변경. 해당 없으면 false 유지.", - "서류평가가점": { - "특별지원지역_소재": false, - "노란우산공제_가입": false, - "3년이상_업력": false, - "재도전_사례공모전_수상": false - }, - "발표평가가점": { - "유공포상_수상": false, - "타사업자등록_없음": false - }, - "서류평가면제": { - "K스타트업_왕중왕전_진출": false, - "중진공_심층평가_통과": false, - "중진공_특화교육_우수": false, - "유공포상_수상": false, - "TIPS_선정이력": false - }, - "우선선정": { - "K스타트업_왕중왕전_대상": false - } - }, - - "_manual_only": { - "_note": "아래 항목만 한글에서 직접 수정 필요", - "items": [ - "폐업이력 사업기간 실제 날짜 입력 (20○○.○○.○○. → 실제 날짜)", - "가점 해당 항목 true로 변경 확인", - "파란색 안내문구 삭제", - "붙임: 증빙서류 이미지 삽입", - "전체 7페이지 이내 확인" - ] - } -}