diff --git a/README.md b/README.md
index 789a4da..717af87 100644
--- a/README.md
+++ b/README.md
@@ -19,7 +19,7 @@ E-9 외국인근로자를 고용한 사업장의 재계약·체류기간 연장
| Renewal Agent | LangGraph로 HR 질문·근로자 요청·OCR·문서 생성 분기 | Server가 검증된 Worker·Company·Task Context 제공 |
| Language Assistant | 표준 한국어·쉬운 한국어·15개 대상 언어 안내 초안과 검토 경고 | OpenAI 호환 LLM, 선택적으로 Qdrant 필요 |
| OCR | 여권·외국인등록증 Template OCR 결과 정규화 | CLOVA OCR URL·Secret 필요 |
-| 문서 처리 | 재갱신 HWP 초안 4종, HWP/HWPX 검사·편집·변환 | 변환별 Java·rhwp·LibreOffice 실행환경 필요 |
+| 문서 처리 | 재갱신 HWPX 초안 4종, HWP/HWPX 검사·편집·변환 | 변환별 Java·rhwp·LibreOffice 실행환경 필요 |
| 안전 처리 | 누락정보·낮은 신뢰도·Provider 실패를 HR 검토 상태로 반환 | 자동 승인·자동 발송·업무 DB 직접 수정 금지 |
환경변수를 설정하지 않은 선택 기능은 Stub 또는 명시적인 `503`으로 동작합니다.
diff --git a/app/agents/workflow_graph/README.md b/app/agents/workflow_graph/README.md
index 9b58319..b3f9b47 100644
--- a/app/agents/workflow_graph/README.md
+++ b/app/agents/workflow_graph/README.md
@@ -87,7 +87,7 @@ flowchart TB
T2 --> MAP
T3 --> MAP
T4 --> MAP
- MAP --> EDIT["HWP 채우기
문서 편집 서비스"]
+ MAP --> EDIT["HWPX 채우기
고정 셀 규칙 서비스"]
EDIT -->|성공| OK["상태: 생성 완료"]
EDIT -->|실패| ST["상태: 스텁
매핑 필드만"]
OK --> OUT["생성 문서 목록"]
@@ -101,7 +101,7 @@ flowchart TB
- **안내문 · 태정** / **OCR · 주현**: 동료 교체 자리 (현재 stub)
- **OCR 이후**: 부족해도 담당자 입력으로 되돌아가지 않고 빈 값으로 초안 작성 진행
-- **초안 작성**: 템플릿 4종 모두 필수
+- **초안 작성**: canonical 필드를 고정 셀 규칙에 적용한 HWPX 템플릿 4종 모두 필수
## 슈퍼바이저
diff --git a/app/agents/workflow_graph/document_field_map.py b/app/agents/workflow_graph/document_field_map.py
index 253e3d7..37237fa 100644
--- a/app/agents/workflow_graph/document_field_map.py
+++ b/app/agents/workflow_graph/document_field_map.py
@@ -374,6 +374,8 @@ def map_identity_guaranty(state: RenewalState) -> dict[str, object]:
company = _company(state)
fields = {
"foreign_name",
+ "foreign_family_name",
+ "foreign_given_name",
"foreign_birthdate",
"foreign_nationality",
"foreign_passport",
@@ -392,7 +394,19 @@ def map_identity_guaranty(state: RenewalState) -> dict[str, object]:
"guarantee_date",
}
values: dict[str, object] = _passthrough_known_fields(slots, fields)
- _put(values, "foreign_name", _first(slots.get("full_name"), slots.get("foreign_name")))
+ foreign_name = _first(slots.get("full_name"), slots.get("foreign_name"))
+ family_name, given_name = _split_name(_as_str(foreign_name))
+ _put(values, "foreign_name", foreign_name)
+ _put(
+ values,
+ "foreign_family_name",
+ _first(slots.get("foreign_family_name"), family_name),
+ )
+ _put(
+ values,
+ "foreign_given_name",
+ _first(slots.get("foreign_given_name"), given_name),
+ )
_put(
values,
"foreign_birthdate",
diff --git a/app/agents/workflow_graph/nodes/document_generator.py b/app/agents/workflow_graph/nodes/document_generator.py
index bab3fd7..05a7d1b 100644
--- a/app/agents/workflow_graph/nodes/document_generator.py
+++ b/app/agents/workflow_graph/nodes/document_generator.py
@@ -1,4 +1,4 @@
-# 문서생성 훅 — DB·OCR 병합 후 HWP 초안 생성 (필수 4종)
+# 문서생성 훅 — DB·OCR 병합 후 HWPX 초안 생성 (필수 4종)
from __future__ import annotations
@@ -7,9 +7,8 @@
from pathlib import Path
from typing import Any, Protocol
-from app.documents.common import DocumentFormat
-from app.documents.editing import DocumentEditingService
from app.documents.editing.template_names import template_display_name
+from app.documents.records import DocumentRecordGenerationService
from ..document_field_map import values_for_template
from ..state import RenewalState
@@ -54,7 +53,7 @@ def __call__(self, state: RenewalState) -> list[dict[str, Any]]:
{
"template_id": tid,
"name": template_display_name(tid),
- "format": "hwp",
+ "format": "hwpx",
"status": "stub",
"mapped_fields": sorted(values.keys()),
"values": values,
@@ -63,22 +62,24 @@ def __call__(self, state: RenewalState) -> list[dict[str, Any]]:
return results
-# DocumentEditingService로 초안 생성 시도 실패 시 stub 메타
-class EditingServiceDocumentGenerator:
+# 고정 셀 규칙으로 HWPX 초안 생성 시도, 실패 시 stub 메타
+class HwpxDocumentGenerator:
- # 편집 서비스·출력 경로·템플릿 목록 주입
+ # 레코드 생성 서비스·출력 경로·템플릿 목록 주입
def __init__(
self,
- editing: DocumentEditingService | None = None,
+ record_generation: DocumentRecordGenerationService | None = None,
*,
output_dir: Path | None = None,
template_ids: Sequence[str] | None = None,
) -> None:
- self._editing = editing or DocumentEditingService()
+ self._record_generation = (
+ record_generation or DocumentRecordGenerationService()
+ )
self._output_dir = output_dir
self._template_ids = tuple(template_ids) if template_ids else None
- # 템플릿별 필드 매핑으로 HWP 생성 시도
+ # 템플릿별 canonical 필드 매핑으로 HWPX 생성 시도
def __call__(self, state: RenewalState) -> list[dict[str, Any]]:
out_dir = self._output_dir or Path(tempfile.mkdtemp(prefix="fowoco-renewal-"))
out_dir.mkdir(parents=True, exist_ok=True)
@@ -86,14 +87,13 @@ def __call__(self, state: RenewalState) -> list[dict[str, Any]]:
plans = state.get("document_field_values") or {}
results: list[dict[str, Any]] = []
for tid in template_ids:
- dest = out_dir / f"{tid}.hwp"
+ dest = out_dir / f"{tid}.hwpx"
values = dict(plans[tid]) if tid in plans else values_for_template(tid, state)
try:
- mutation = self._editing.generate(
- tid,
- DocumentFormat.HWP,
+ mutation = self._record_generation.generate(
+ values,
dest,
- values=values or None,
+ template_id=tid,
)
results.append(
{
@@ -112,7 +112,7 @@ def __call__(self, state: RenewalState) -> list[dict[str, Any]]:
{
"template_id": tid,
"name": template_display_name(tid),
- "format": "hwp",
+ "format": "hwpx",
"status": "stub",
"error": str(exc),
"mapped_fields": sorted(values.keys()),
@@ -120,3 +120,7 @@ def __call__(self, state: RenewalState) -> list[dict[str, Any]]:
}
)
return results
+
+
+# 기존 내부 import를 사용하는 코드가 깨지지 않도록 한 릴리스 동안 별칭을 유지한다.
+EditingServiceDocumentGenerator = HwpxDocumentGenerator
diff --git a/app/agents/workflow_graph/subgraphs.py b/app/agents/workflow_graph/subgraphs.py
index dd00456..8d98e96 100644
--- a/app/agents/workflow_graph/subgraphs.py
+++ b/app/agents/workflow_graph/subgraphs.py
@@ -111,7 +111,7 @@ def run_persist(state: RenewalState) -> dict[str, Any]:
return g.compile()
-# Document 서브그래프 컴파일 (HWP 초안)
+# Document 서브그래프 컴파일 (HWPX 초안)
def build_document_subgraph(
*, document_generator: DocumentGenerator | None = None
) -> Any:
diff --git a/app/api/dependencies.py b/app/api/dependencies.py
index 72fb210..171c6d3 100644
--- a/app/api/dependencies.py
+++ b/app/api/dependencies.py
@@ -24,7 +24,7 @@
from app.agents.workflow_graph import RenewalOrchestrator
from app.agents.workflow_graph.language_bridge import build_renewal_language_guide
from app.agents.workflow_graph.nodes.document_generator import (
- EditingServiceDocumentGenerator,
+ HwpxDocumentGenerator,
)
from app.agents.workflow_graph.nodes.language_stub import StubLanguageNode
from app.agents.workflow_graph.ocr_bridge import DocumentOcrNode
@@ -110,9 +110,7 @@ def get_renewal_orchestrator() -> RenewalOrchestrator:
guide_node=build_renewal_language_guide(language_service),
lookup=db,
store=db,
- document_generator=EditingServiceDocumentGenerator(
- get_document_editing_service()
- ),
+ document_generator=HwpxDocumentGenerator(),
task_store=get_task_store(),
)
diff --git a/tests/agents/test_document_field_map.py b/tests/agents/test_document_field_map.py
index b1bba04..0232f95 100644
--- a/tests/agents/test_document_field_map.py
+++ b/tests/agents/test_document_field_map.py
@@ -11,7 +11,7 @@
values_for_template,
)
from app.agents.workflow_graph.nodes import document_generator
-from app.agents.workflow_graph.nodes.document_generator import EditingServiceDocumentGenerator
+from app.agents.workflow_graph.nodes.document_generator import HwpxDocumentGenerator
from app.agents.workflow_graph.state import empty_renewal_state
from app.documents.common import DocumentFormat
from app.documents.editing.models import DocumentMutationResult
@@ -115,6 +115,8 @@ def test_extension_and_guaranty_mapping() -> None:
guaranty = map_identity_guaranty(state)
assert guaranty["foreign_name"] == "NGUYEN VAN AN"
+ assert guaranty["foreign_family_name"] == "NGUYEN"
+ assert guaranty["foreign_given_name"] == "VAN AN"
assert guaranty["foreign_passport"] == "P1234567"
assert guaranty["stay_purpose"] == "취업"
assert guaranty["guarantor_name"] == "김민수"
@@ -150,7 +152,7 @@ def test_document_field_statuses_exclude_assets_and_expose_empty_text_fields() -
# 실생성기가 필수 4종 초안을 만들고 매핑 필드를 남긴다
def test_editing_generator_maps_and_generates_or_stubs(tmp_path: Path) -> None:
- gen = EditingServiceDocumentGenerator(output_dir=tmp_path)
+ gen = HwpxDocumentGenerator(output_dir=tmp_path)
docs = gen(_sample_state())
assert len(docs) == 4
by_id = {d["template_id"]: d for d in docs}
@@ -167,22 +169,20 @@ def test_editing_generator_maps_and_generates_or_stubs(tmp_path: Path) -> None:
def test_editing_generator_reports_generated_document_format(tmp_path: Path) -> None:
- class SuccessfulEditing:
+ class SuccessfulRecordGeneration:
def generate(
self,
- template_id: str,
- document_format: DocumentFormat,
+ values: dict[str, object],
destination: Path,
*,
- values: dict[str, object] | None = None,
- **_: object,
+ template_id: str,
) -> DocumentMutationResult:
destination.touch()
return DocumentMutationResult(
destination,
- document_format,
+ DocumentFormat.HWPX,
template_id,
- tuple(values or {}),
+ tuple(values),
)
state = empty_renewal_state(
@@ -191,14 +191,15 @@ def generate(
instruction="체류기간 연장 갱신",
slots={"full_name": "NGUYEN VAN AN"},
)
- docs = EditingServiceDocumentGenerator(
- SuccessfulEditing(),
+ docs = HwpxDocumentGenerator(
+ SuccessfulRecordGeneration(),
output_dir=tmp_path,
template_ids=("standard_labor_contract_v6",),
)(state)
assert docs[0]["status"] == "generated"
- assert docs[0]["format"] == "hwp"
+ assert docs[0]["format"] == "hwpx"
+ assert Path(docs[0]["path"]).suffix == ".hwpx"
# 사전 계산된 템플릿 계획이 있으면 재매핑하지 않고 그대로 생성에 쓴다
@@ -207,7 +208,7 @@ def test_generator_uses_precomputed_document_field_values(tmp_path: Path) -> Non
state["document_field_values"] = {
"standard_labor_contract_v6": {"employee_name": "PLAN VALUE"}
}
- generator = EditingServiceDocumentGenerator(
+ generator = HwpxDocumentGenerator(
output_dir=tmp_path,
template_ids=("standard_labor_contract_v6",),
)
@@ -227,7 +228,7 @@ def test_generator_derives_values_when_no_precomputed_plan(
"values_for_template",
lambda template_id, renewal_state: {"derived_field": "DERIVED VALUE"},
)
- generator = EditingServiceDocumentGenerator(
+ generator = HwpxDocumentGenerator(
output_dir=tmp_path,
template_ids=("standard_labor_contract_v6",),
)
diff --git a/tests/agents/test_workflow_adapters.py b/tests/agents/test_workflow_adapters.py
index 3f5e3ba..91382e8 100644
--- a/tests/agents/test_workflow_adapters.py
+++ b/tests/agents/test_workflow_adapters.py
@@ -1,12 +1,13 @@
"""Language/OCR 어댑터·태스크 재개·문서생성 훅 테스트."""
+import zipfile
from pathlib import Path
from app.agents.workflow_graph import LanguageNodeAdapter, OcrNodeAdapter, RenewalOrchestrator
from app.agents.workflow_graph.adapters import normalize_language_output, normalize_ocr_output
from app.agents.workflow_graph.document_field_map import values_for_template
from app.agents.workflow_graph.nodes.document_generator import (
- EditingServiceDocumentGenerator,
+ HwpxDocumentGenerator,
StubDocumentGenerator,
)
from app.agents.workflow_graph.nodes.language_stub import CONTRACT_SLOTS
@@ -214,7 +215,7 @@ def test_editing_service_document_generator_writes_files(
tmp_path: Path,
) -> None:
"""실 생성기가 필수 4종 파일을 생성하고 generated 상태를 반환한다."""
- gen = EditingServiceDocumentGenerator(output_dir=tmp_path)
+ gen = HwpxDocumentGenerator(output_dir=tmp_path)
state = empty_renewal_state(
task_id="t",
request_id="r",
@@ -226,6 +227,20 @@ def test_editing_service_document_generator_writes_files(
assert all(d["status"] == "generated" for d in docs)
assert all(Path(d["path"]).is_file() for d in docs)
assert all(Path(d["path"]).stat().st_size > 0 for d in docs)
+ assert all(Path(d["path"]).suffix == ".hwpx" for d in docs)
+ assert all(d["format"] == "hwpx" for d in docs)
+ section_by_template: dict[str, str] = {}
+ for document in docs:
+ with zipfile.ZipFile(document["path"]) as package:
+ assert package.testzip() is None
+ assert package.read("mimetype") == b"application/hwp+zip"
+ section_by_template[document["template_id"]] = package.read(
+ "Contents/section0.xml"
+ ).decode("utf-8")
+ assert document["changed_fields"]
+ assert "Hong" in section_by_template["standard_labor_contract_v6"]
+ assert "Hong" in section_by_template["identity_guaranty_v129"]
+ assert "1990-01-01" in section_by_template["identity_guaranty_v129"]
assert all("mapped_fields" in d for d in docs)
assert all(
d["values"] == values_for_template(d["template_id"], state) for d in docs
diff --git a/tests/api/test_workflows_endpoint.py b/tests/api/test_workflows_endpoint.py
index eaeb57b..f533588 100644
--- a/tests/api/test_workflows_endpoint.py
+++ b/tests/api/test_workflows_endpoint.py
@@ -8,7 +8,7 @@
from app.agents.workflow_graph import RenewalOrchestrator
from app.agents.workflow_graph.nodes.document_generator import (
- EditingServiceDocumentGenerator,
+ HwpxDocumentGenerator,
)
from app.agents.workflow_graph.nodes.language_stub import CONTRACT_SLOTS
from app.agents.workflow_graph.state import IDENTITY_SLOTS, RenewalState
@@ -153,7 +153,7 @@ def successful_guide(state: RenewalState) -> dict[str, object]:
guide_node=successful_guide,
lookup=db,
store=db,
- document_generator=EditingServiceDocumentGenerator(output_dir=tmp_path),
+ document_generator=HwpxDocumentGenerator(output_dir=tmp_path),
task_store=InMemoryTaskStore(),
)
app.dependency_overrides[get_renewal_orchestrator] = lambda: orchestrator
@@ -215,7 +215,7 @@ def successful_guide(state: RenewalState) -> dict[str, object]:
document["status"] == "generated"
for document in data["generatedDocuments"]
)
- assert all(document["format"] == "hwp" for document in data["generatedDocuments"])
+ assert all(document["format"] == "hwpx" for document in data["generatedDocuments"])
immigration = next(
document
for document in data["generatedDocuments"]