Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`으로 동작합니다.
Expand Down
4 changes: 2 additions & 2 deletions app/agents/workflow_graph/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ flowchart TB
T2 --> MAP
T3 --> MAP
T4 --> MAP
MAP --> EDIT["HWP 채우기<br/>문서 편집 서비스"]
MAP --> EDIT["HWPX 채우기<br/>고정 셀 규칙 서비스"]
EDIT -->|성공| OK["상태: 생성 완료"]
EDIT -->|실패| ST["상태: 스텁<br/>매핑 필드만"]
OK --> OUT["생성 문서 목록"]
Expand All @@ -101,7 +101,7 @@ flowchart TB

- **안내문 · 태정** / **OCR · 주현**: 동료 교체 자리 (현재 stub)
- **OCR 이후**: 부족해도 담당자 입력으로 되돌아가지 않고 빈 값으로 초안 작성 진행
- **초안 작성**: 템플릿 4종 모두 필수
- **초안 작성**: canonical 필드를 고정 셀 규칙에 적용한 HWPX 템플릿 4종 모두 필수

## 슈퍼바이저

Expand Down
16 changes: 15 additions & 1 deletion app/agents/workflow_graph/document_field_map.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
36 changes: 20 additions & 16 deletions app/agents/workflow_graph/nodes/document_generator.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# 문서생성 훅 — DB·OCR 병합 후 HWP 초안 생성 (필수 4종)
# 문서생성 훅 — DB·OCR 병합 후 HWPX 초안 생성 (필수 4종)

from __future__ import annotations

Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -63,37 +62,38 @@ 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)
template_ids = self._template_ids or draft_template_ids(state)
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(
{
Expand All @@ -112,11 +112,15 @@ 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()),
"values": values,
}
)
return results


# 기존 내부 import를 사용하는 코드가 깨지지 않도록 한 릴리스 동안 별칭을 유지한다.
EditingServiceDocumentGenerator = HwpxDocumentGenerator
2 changes: 1 addition & 1 deletion app/agents/workflow_graph/subgraphs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
6 changes: 2 additions & 4 deletions app/api/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(),
)

Expand Down
29 changes: 15 additions & 14 deletions tests/agents/test_document_field_map.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"] == "김민수"
Expand Down Expand Up @@ -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}
Expand All @@ -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(
Expand All @@ -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"


# 사전 계산된 템플릿 계획이 있으면 재매핑하지 않고 그대로 생성에 쓴다
Expand All @@ -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",),
)
Expand All @@ -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",),
)
Expand Down
19 changes: 17 additions & 2 deletions tests/agents/test_workflow_adapters.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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",
Expand All @@ -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
Expand Down
6 changes: 3 additions & 3 deletions tests/api/test_workflows_endpoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"]
Expand Down
Loading