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"]