diff --git a/README.md b/README.md index 35a6ad7..e8f8d2f 100644 --- a/README.md +++ b/README.md @@ -145,7 +145,8 @@ make check - Intent ID·입력 중복, evidence exact substring과 순서 - Train/Validation 중복·누락과 template leakage - 외국인등록번호·여권번호·전화번호 형태의 개인정보 패턴 -- 전체 자동화 테스트 18개 +- 6대 Workflow Runtime의 의존성·완료 증빙·HR Gate와 E2E 경로 18건 +- 전체 자동화 테스트 25개 ## 저장소 구성 @@ -186,6 +187,7 @@ make check - [데이터 사용 안내](fowoco-knowledge/data/README.md) - [Intent 라벨 기준](fowoco-knowledge/docs/INTENT_DATA.md) - [Agent 연동 계약](fowoco-knowledge/docs/AGENT_INTEGRATION.md) +- [6대 Workflow 공통 실행 계약](fowoco-knowledge/docs/WORKFLOW_RUNTIME.md) - [공식 데이터 파이프라인](fowoco-knowledge/docs/OFFICIAL_DATA_PIPELINE.md) - [E-9 신고·연장 Workflow](fowoco-knowledge/docs/E9_REPORTING_WORKFLOWS.md) - [모델 서빙 실행 방법](fowoco-knowledge/hr-intent-service/README.md) diff --git a/fowoco-knowledge/CHANGELOG.md b/fowoco-knowledge/CHANGELOG.md index 6d9a6fb..4336578 100644 --- a/fowoco-knowledge/CHANGELOG.md +++ b/fowoco-knowledge/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +- 6대 Workflow의 Master/Subflow 관계, 공통 상태·이벤트·단계 의존성·완료 증빙 계약 추가 +- Workflow별 정상·정보 누락·수동 검토 경로를 고정한 Runtime E2E Fixture 18건 추가 +- 외부기관 처리와 민감 판단의 HR Gate, 병렬 READY 조건, 복합 Intent 분해 정책 검증 추가 - Workflow Slot에 UI·Agent 공용 한글 표시명, 쉬운 표현, 출처 우선순위, 담당 주체와 검증 규칙 추가 - 이름 변형·복합 요청·Intent 경계·OUT_OF_SCOPE·베트남어 안내 E2E Gold 후보 11건 추가 - 여권·외국인등록증 제출 상태를 Knowledge 소유 조회형 Context Slot으로 정의 diff --git a/fowoco-knowledge/README.md b/fowoco-knowledge/README.md index dea6c0a..e16ef84 100644 --- a/fowoco-knowledge/README.md +++ b/fowoco-knowledge/README.md @@ -22,6 +22,8 @@ Slot·공식 출처·Guardrail을 조회하고, 데이터 계약과 교차참조 `hr-intent-service/`는 프로젝트 결과 재현과 AI 저장소 인계를 위한 스냅샷입니다. 파일 SHA·학습 데이터 계보·Secret 관리 기준은 [`docs/MODEL_ARTIFACT_POLICY.md`](docs/MODEL_ARTIFACT_POLICY.md)를 따릅니다. +여섯 대표 업무의 단계 의존성·완료 증빙·HR 승인 계약은 +[`docs/WORKFLOW_RUNTIME.md`](docs/WORKFLOW_RUNTIME.md)를 기준으로 공유합니다. ## 데이터 기준 diff --git a/fowoco-knowledge/data/evaluation/workflow_runtime_cases.jsonl b/fowoco-knowledge/data/evaluation/workflow_runtime_cases.jsonl new file mode 100644 index 0000000..8d745a8 --- /dev/null +++ b/fowoco-knowledge/data/evaluation/workflow_runtime_cases.jsonl @@ -0,0 +1,18 @@ +{"case_id":"RUNTIME-EXPIRY-001","profile_id":"RUN-EXPIRY-RENEWAL","path":"HAPPY_PATH","trigger_type":"NATURAL_LANGUAGE","provided_slots":["worker_id","due_at","stay_expiry_date","passport_status","arc_status"],"missing_slots":[],"expected_state":"READY","expected_ready_stage_ids":["confirm_contract"],"guardrails":{"human_approval_required":true,"automatic_external_submission":false,"automatic_completion":false}} +{"case_id":"RUNTIME-EXPIRY-002","profile_id":"RUN-EXPIRY-RENEWAL","path":"MISSING_INPUT","trigger_type":"DEADLINE_REACHED","provided_slots":["worker_id"],"missing_slots":["due_at"],"expected_state":"NEEDS_INFO","expected_ready_stage_ids":[],"guardrails":{"human_approval_required":true,"automatic_external_submission":false,"automatic_completion":false}} +{"case_id":"RUNTIME-EXPIRY-003","profile_id":"RUN-EXPIRY-RENEWAL","path":"MANUAL_REVIEW","trigger_type":"OCR_COMPLETED","provided_slots":["worker_id","due_at"],"missing_slots":[],"expected_state":"MANUAL_REVIEW","expected_ready_stage_ids":["collect_identity_documents"],"guardrails":{"human_approval_required":true,"automatic_external_submission":false,"automatic_completion":false}} +{"case_id":"RUNTIME-ONBOARD-001","profile_id":"RUN-WORKER-ONBOARDING","path":"HAPPY_PATH","trigger_type":"OCR_COMPLETED","provided_slots":["source_document_id"],"missing_slots":[],"expected_state":"READY","expected_ready_stage_ids":["ingest_source"],"guardrails":{"human_approval_required":true,"automatic_external_submission":false,"automatic_completion":false}} +{"case_id":"RUNTIME-ONBOARD-002","profile_id":"RUN-WORKER-ONBOARDING","path":"MISSING_INPUT","trigger_type":"NATURAL_LANGUAGE","provided_slots":[],"missing_slots":["source_document_id"],"expected_state":"NEEDS_INFO","expected_ready_stage_ids":[],"guardrails":{"human_approval_required":true,"automatic_external_submission":false,"automatic_completion":false}} +{"case_id":"RUNTIME-ONBOARD-003","profile_id":"RUN-WORKER-ONBOARDING","path":"MANUAL_REVIEW","trigger_type":"OCR_COMPLETED","provided_slots":["source_document_id"],"missing_slots":[],"expected_state":"MANUAL_REVIEW","expected_ready_stage_ids":["extract_and_validate"],"guardrails":{"human_approval_required":true,"automatic_external_submission":false,"automatic_completion":false}} +{"case_id":"RUNTIME-CHANGE-001","profile_id":"RUN-EMPLOYMENT-CHANGE","path":"HAPPY_PATH","trigger_type":"NATURAL_LANGUAGE","provided_slots":["worker_id","change_type","incident_at"],"missing_slots":[],"expected_state":"READY","expected_ready_stage_ids":["capture_and_confirm_incident"],"guardrails":{"human_approval_required":true,"automatic_external_submission":false,"automatic_completion":false}} +{"case_id":"RUNTIME-CHANGE-002","profile_id":"RUN-EMPLOYMENT-CHANGE","path":"MISSING_INPUT","trigger_type":"NATURAL_LANGUAGE","provided_slots":["worker_id"],"missing_slots":["change_type","incident_at"],"expected_state":"NEEDS_INFO","expected_ready_stage_ids":[],"guardrails":{"human_approval_required":true,"automatic_external_submission":false,"automatic_completion":false}} +{"case_id":"RUNTIME-CHANGE-003","profile_id":"RUN-EMPLOYMENT-CHANGE","path":"MANUAL_REVIEW","trigger_type":"DEADLINE_REACHED","provided_slots":["worker_id","change_type","incident_at"],"missing_slots":[],"expected_state":"MANUAL_REVIEW","expected_ready_stage_ids":["capture_and_confirm_incident"],"guardrails":{"human_approval_required":true,"automatic_external_submission":false,"automatic_completion":false}} +{"case_id":"RUNTIME-DOCUMENT-001","profile_id":"RUN-DOCUMENT-REQUEST","path":"HAPPY_PATH","trigger_type":"NATURAL_LANGUAGE","provided_slots":["worker_id","document_type","due_at","submission_channel"],"missing_slots":[],"expected_state":"READY","expected_ready_stage_ids":["prepare_notice"],"guardrails":{"human_approval_required":true,"automatic_external_submission":false,"automatic_completion":false}} +{"case_id":"RUNTIME-DOCUMENT-002","profile_id":"RUN-DOCUMENT-REQUEST","path":"MISSING_INPUT","trigger_type":"NATURAL_LANGUAGE","provided_slots":["worker_id"],"missing_slots":["document_type","due_at","submission_channel"],"expected_state":"NEEDS_INFO","expected_ready_stage_ids":[],"guardrails":{"human_approval_required":true,"automatic_external_submission":false,"automatic_completion":false}} +{"case_id":"RUNTIME-DOCUMENT-003","profile_id":"RUN-DOCUMENT-REQUEST","path":"MANUAL_REVIEW","trigger_type":"WORKER_RESPONSE_RECEIVED","provided_slots":["worker_id","document_type","due_at","submission_channel"],"missing_slots":[],"expected_state":"MANUAL_REVIEW","expected_ready_stage_ids":["verify_or_follow_up"],"guardrails":{"human_approval_required":true,"automatic_external_submission":false,"automatic_completion":false}} +{"case_id":"RUNTIME-PAYROLL-001","profile_id":"RUN-PAYROLL-EXPLANATION","path":"HAPPY_PATH","trigger_type":"OCR_COMPLETED","provided_slots":["worker_id","pay_period","source_document_id"],"missing_slots":[],"expected_state":"READY","expected_ready_stage_ids":["calculate_payroll_difference"],"guardrails":{"human_approval_required":true,"automatic_external_submission":false,"automatic_completion":false}} +{"case_id":"RUNTIME-PAYROLL-002","profile_id":"RUN-PAYROLL-EXPLANATION","path":"MISSING_INPUT","trigger_type":"NATURAL_LANGUAGE","provided_slots":["worker_id"],"missing_slots":["pay_period","source_document_id"],"expected_state":"NEEDS_INFO","expected_ready_stage_ids":[],"guardrails":{"human_approval_required":true,"automatic_external_submission":false,"automatic_completion":false}} +{"case_id":"RUNTIME-PAYROLL-003","profile_id":"RUN-PAYROLL-EXPLANATION","path":"MANUAL_REVIEW","trigger_type":"WORKER_RESPONSE_RECEIVED","provided_slots":["worker_id","pay_period","source_document_id"],"missing_slots":[],"expected_state":"MANUAL_REVIEW","expected_ready_stage_ids":["collect_and_route_question"],"guardrails":{"human_approval_required":true,"automatic_external_submission":false,"automatic_completion":false}} +{"case_id":"RUNTIME-INSTRUCTION-001","profile_id":"RUN-WORK-INSTRUCTION","path":"HAPPY_PATH","trigger_type":"NATURAL_LANGUAGE","provided_slots":["worker_id","effective_at","work_location","work_action"],"missing_slots":[],"expected_state":"READY","expected_ready_stage_ids":["prepare_instruction"],"guardrails":{"human_approval_required":true,"automatic_external_submission":false,"automatic_completion":false}} +{"case_id":"RUNTIME-INSTRUCTION-002","profile_id":"RUN-WORK-INSTRUCTION","path":"MISSING_INPUT","trigger_type":"NATURAL_LANGUAGE","provided_slots":["worker_id","work_action"],"missing_slots":["effective_at","work_location"],"expected_state":"NEEDS_INFO","expected_ready_stage_ids":[],"guardrails":{"human_approval_required":true,"automatic_external_submission":false,"automatic_completion":false}} +{"case_id":"RUNTIME-INSTRUCTION-003","profile_id":"RUN-WORK-INSTRUCTION","path":"MANUAL_REVIEW","trigger_type":"WORKER_RESPONSE_RECEIVED","provided_slots":["worker_id","effective_at","work_location","work_action"],"missing_slots":[],"expected_state":"MANUAL_REVIEW","expected_ready_stage_ids":["follow_up_if_needed"],"guardrails":{"human_approval_required":true,"automatic_external_submission":false,"automatic_completion":false}} diff --git a/fowoco-knowledge/data/evaluation/workflow_runtime_manifest.yaml b/fowoco-knowledge/data/evaluation/workflow_runtime_manifest.yaml new file mode 100644 index 0000000..336df27 --- /dev/null +++ b/fowoco-knowledge/data/evaluation/workflow_runtime_manifest.yaml @@ -0,0 +1,8 @@ +dataset_id: FOWOCO-WORKFLOW-RUNTIME-E2E +version: 1.0.0 +path: data/evaluation/workflow_runtime_cases.jsonl +schema: schemas/workflow-runtime-case.schema.json +record_count: 18 +sha256: ac096b8b75175b74ccc36476b92a41968fc05d0b10f9c13e21a776082521681a +required_paths: [HAPPY_PATH, MISSING_INPUT, MANUAL_REVIEW] +cases_per_profile: 3 diff --git a/fowoco-knowledge/docs/WORKFLOW_RUNTIME.md b/fowoco-knowledge/docs/WORKFLOW_RUNTIME.md new file mode 100644 index 0000000..06c27ba --- /dev/null +++ b/fowoco-knowledge/docs/WORKFLOW_RUNTIME.md @@ -0,0 +1,47 @@ +# 6대 Workflow 공통 실행 계약 + +## 목적 + +Intent와 업무 설명만으로는 Server가 어떤 단계를 먼저 열고, 무엇을 기다리고, 언제 +완료할지 알 수 없다. `knowledge/workflow_runtime.yaml`은 기존 Catalog를 바꾸지 않고 +여섯 대표 업무를 같은 상태·이벤트·의존성 규칙으로 실행하기 위한 계약이다. + +## 실행 방법 + +```text +자연어·기한·OCR·근로자 응답 이벤트 + → Intent별 Runtime Profile 선택 + → 필수 Slot 확인 + → 의존성이 충족된 Stage를 READY로 전환 + → 완료 증빙 저장 + → HR 승인 또는 직접 기관 처리 + → 다음 Stage 개방 +``` + +`depends_on`이 모두 끝난 Stage는 같은 `parallel_group` 안에서 병렬로 열 수 있다. +완료는 결과 문구만으로 확정하지 않고 `completion_evidence`와 `human_gate`를 함께 +검사한다. + +## 여섯 Profile + +| Profile | 종류 | 재사용 관계 | +| --- | --- | --- | +| `RUN-EXPIRY-RENEWAL` | Master | 계약·공통 서류 요청·체류 연장을 연결 | +| `RUN-WORKER-ONBOARDING` | Master | 필요 시 공통 서류 요청 Subflow 사용 | +| `RUN-EMPLOYMENT-CHANGE` | Master | 증빙 보완 시 공통 서류 요청 Subflow 사용 | +| `RUN-DOCUMENT-REQUEST` | Subflow | 다른 Master가 재사용 가능 | +| `RUN-PAYROLL-EXPLANATION` | Master | 급여 계산과 설명 검토 분리 | +| `RUN-WORK-INSTRUCTION` | Master | 안내 승인과 근로자 응답 추적 분리 | + +복합 발화는 evidence가 먼저 등장한 Intent를 대표로 표시하되 여러 Workflow 후보로 +분리하고, 실행 전 HR 확인을 받는다. + +## 안전 기준 + +- 외부기관 제출·법적 상태·퇴사 확정은 HR 승인 없이 완료하지 않는다. +- 근로자 메시지와 번역문은 HR 승인 뒤에만 전달한다. +- 이벤트 재처리는 같은 멱등성 키로 중복 Task·문서를 만들지 않는다. +- `OUT_OF_SCOPE`는 Runtime Profile이 아니라 수동 처리 분기다. + +정상·필수정보 누락·수동 검토 경로는 +`data/evaluation/workflow_runtime_cases.jsonl`의 18개 Fixture로 고정한다. diff --git a/fowoco-knowledge/knowledge/manifest.yaml b/fowoco-knowledge/knowledge/manifest.yaml index 26039ad..a544adf 100644 --- a/fowoco-knowledge/knowledge/manifest.yaml +++ b/fowoco-knowledge/knowledge/manifest.yaml @@ -26,6 +26,7 @@ files: easy_korean: knowledge/easy_korean_rules.yaml multilingual_templates: knowledge/multilingual_templates.yaml guardrails: knowledge/guardrail_rules.yaml + workflow_runtime: knowledge/workflow_runtime.yaml datasets: source_manifest: data/external/source_manifest.yaml processed_manifest: data/processed/manifest.yaml @@ -38,6 +39,8 @@ datasets: catalog_e2e_manifest: data/evaluation/e2e_catalog_manifest.yaml catalog_e2e_gold_candidates: data/evaluation/e2e_catalog_cases.jsonl demo_expiry_renewal: data/demo/expiry_renewal_golden.yaml + workflow_runtime_manifest: data/evaluation/workflow_runtime_manifest.yaml + workflow_runtime_cases: data/evaluation/workflow_runtime_cases.jsonl artifacts: intent_models: hr-intent-service/models/artifact-manifest.yaml intent_model_schema: schemas/model-artifact-manifest.schema.json diff --git a/fowoco-knowledge/knowledge/workflow_runtime.yaml b/fowoco-knowledge/knowledge/workflow_runtime.yaml new file mode 100644 index 0000000..935541d --- /dev/null +++ b/fowoco-knowledge/knowledge/workflow_runtime.yaml @@ -0,0 +1,214 @@ +version: 1.0.0 +policy: + states: [DRAFT, NEEDS_INFO, READY, IN_PROGRESS, WAITING_WORKER, WAITING_EXTERNAL, REVIEW_REQUIRED, COMPLETED, CANCELLED, MANUAL_REVIEW] + transitions: + - {from: DRAFT, event: REQUIRED_CONTEXT_RESOLVED, to: READY} + - {from: DRAFT, event: REQUIRED_CONTEXT_MISSING, to: NEEDS_INFO} + - {from: NEEDS_INFO, event: ANSWERS_SUBMITTED, to: READY} + - {from: READY, event: EXECUTION_STARTED, to: IN_PROGRESS} + - {from: IN_PROGRESS, event: WORKER_INPUT_REQUESTED, to: WAITING_WORKER} + - {from: WAITING_WORKER, event: WORKER_RESPONSE_RECEIVED, to: IN_PROGRESS} + - {from: IN_PROGRESS, event: EXTERNAL_RESULT_REQUIRED, to: WAITING_EXTERNAL} + - {from: WAITING_EXTERNAL, event: EXTERNAL_RESULT_RECORDED, to: REVIEW_REQUIRED} + - {from: IN_PROGRESS, event: HUMAN_REVIEW_REQUIRED, to: REVIEW_REQUIRED} + - {from: REVIEW_REQUIRED, event: HUMAN_APPROVED, to: COMPLETED} + - {from: DRAFT, event: CANCELLED_BY_HR, to: CANCELLED} + - {from: IN_PROGRESS, event: UNSUPPORTED_EXCEPTION, to: MANUAL_REVIEW} + parallel_ready_rule: ALL_DEPENDENCIES_COMPLETED + completion_rule: EVIDENCE_PRESENT_AND_HUMAN_GATE_SATISFIED +event_contract: + supported_types: [NATURAL_LANGUAGE, DEADLINE_REACHED, OCR_COMPLETED, WORKER_RESPONSE_RECEIVED] + idempotency_required: true +composite_intent_policy: + strategy: SPLIT_TO_WORKFLOW_CANDIDATES + representative_intent: FIRST_EVIDENCE_ORDER + confirmation_required: true + +profiles: + - id: RUN-EXPIRY-RENEWAL + intent: EXPIRY_RENEWAL + kind: MASTER + master_workflow_id: WF-STY-001 + included_workflow_ids: [WF-CON-001, WF-DOC-001, WF-STY-001] + reusable_subflow_ids: [WF-DOC-001] + applicability: + subject: WORKER + conditions: [E-9 근로자의 재계약·취업활동기간·체류기간 연장 준비] + required_slots_ref: WF-STY-001 + stages: + - id: confirm_contract + catalog_steps: [WF-CON-001.confirm_company_intent, WF-CON-001.confirm_worker_intent, WF-CON-001.prepare_contract, WF-CON-001.review_and_sign] + depends_on: [] + condition: ALWAYS + completion_evidence: [worker_intent, signed_contract_record, hr_approval_id] + human_gate: HR_CONFIRMATION + parallel_group: PREPARATION + - id: collect_identity_documents + catalog_steps: [WF-DOC-001.validate_request, WF-DOC-001.generate_notice, WF-DOC-001.approve_notice, WF-DOC-001.collect_response, WF-DOC-001.verify_submission, WF-DOC-001.close_or_ticket] + depends_on: [] + condition: MISSING_INFORMATION + completion_evidence: [approved_notice_id, verified_document_id] + human_gate: HR_APPROVAL + parallel_group: PREPARATION + - id: extend_employment_period + catalog_steps: [WF-CON-001.prepare_permit_extension, WF-CON-001.manual_external_process, WF-CON-001.close_task] + depends_on: [confirm_contract, collect_identity_documents] + condition: ALL_DEPENDENCIES_COMPLETED + completion_evidence: [employment_permit_extension_receipt_or_note, hr_approval_id] + human_gate: MANUAL_EXTERNAL_ACTION + - id: extend_stay_period + catalog_steps: [WF-STY-001.create_task, WF-STY-001.load_current_checklist, WF-STY-001.check_prerequisite, WF-STY-001.compare_documents, WF-STY-001.request_worker_documents, WF-STY-001.manual_external_process, WF-STY-001.close_task] + depends_on: [extend_employment_period] + condition: ALL_DEPENDENCIES_COMPLETED + completion_evidence: [stay_extension_receipt_or_note, next_review_at, hr_approval_id] + human_gate: MANUAL_EXTERNAL_ACTION + + - id: RUN-WORKER-ONBOARDING + intent: WORKER_ONBOARDING + kind: MASTER + master_workflow_id: WF-WRK-001 + included_workflow_ids: [WF-WRK-001] + reusable_subflow_ids: [WF-DOC-001] + applicability: + subject: WORKER + conditions: [입사 서류가 있고 HR이 신규 근로자 등록을 요청함] + required_slots_ref: WF-WRK-001 + stages: + - id: ingest_source + catalog_steps: [WF-WRK-001.ingest_document] + depends_on: [] + condition: ALWAYS + completion_evidence: [source_document_id] + human_gate: NONE + - id: extract_and_validate + catalog_steps: [WF-WRK-001.extract_fields, WF-WRK-001.validate_fields] + depends_on: [ingest_source] + condition: ALL_DEPENDENCIES_COMPLETED + completion_evidence: [worker_profile_draft, validation_report] + human_gate: NONE + - id: approve_and_persist + catalog_steps: [WF-WRK-001.review_draft, WF-WRK-001.persist_profile] + depends_on: [extract_and_validate] + condition: ALL_DEPENDENCIES_COMPLETED + completion_evidence: [hr_approval_id, worker_id] + human_gate: HR_APPROVAL + + - id: RUN-EMPLOYMENT-CHANGE + intent: EMPLOYMENT_CHANGE + kind: MASTER + master_workflow_id: WF-CHG-001 + included_workflow_ids: [WF-CHG-001] + reusable_subflow_ids: [WF-DOC-001] + applicability: + subject: WORKER + conditions: [퇴사·결근·연락두절·사업장변경 후보 사건이 발생함] + required_slots_ref: WF-CHG-001 + stages: + - id: capture_and_confirm_incident + catalog_steps: [WF-CHG-001.capture_incident, WF-CHG-001.confirm_facts] + depends_on: [] + condition: ALWAYS + completion_evidence: [confirmed_event_and_base_date] + human_gate: HR_CONFIRMATION + - id: prepare_report + catalog_steps: [WF-CHG-001.calculate_candidate_deadline, WF-CHG-001.load_official_guidance, WF-CHG-001.create_actions] + depends_on: [capture_and_confirm_incident] + condition: ALL_DEPENDENCIES_COMPLETED + completion_evidence: [report_due_at_candidate, guidance_pack, action_tasks] + human_gate: NONE + - id: report_and_close + catalog_steps: [WF-CHG-001.manual_report, WF-CHG-001.close_task] + depends_on: [prepare_report] + condition: ALL_DEPENDENCIES_COMPLETED + completion_evidence: [external_receipt_or_manual_note, confirmed_incident_id] + human_gate: MANUAL_EXTERNAL_ACTION + + - id: RUN-DOCUMENT-REQUEST + intent: DOCUMENT_REQUEST + kind: SUBFLOW + master_workflow_id: WF-DOC-001 + included_workflow_ids: [WF-DOC-001] + reusable_subflow_ids: [] + applicability: + subject: WORKER + conditions: [승인된 문서 유형을 근로자에게 요청하거나 제출을 추적함] + required_slots_ref: WF-DOC-001 + stages: + - id: prepare_notice + catalog_steps: [WF-DOC-001.validate_request, WF-DOC-001.generate_notice, WF-DOC-001.approve_notice] + depends_on: [] + condition: ALWAYS + completion_evidence: [approved_notice_id] + human_gate: HR_APPROVAL + - id: collect_submission + catalog_steps: [WF-DOC-001.collect_response] + depends_on: [prepare_notice] + condition: ALL_DEPENDENCIES_COMPLETED + completion_evidence: [worker_response] + human_gate: NONE + - id: verify_or_follow_up + catalog_steps: [WF-DOC-001.verify_submission, WF-DOC-001.close_or_ticket] + depends_on: [collect_submission] + condition: ALL_DEPENDENCIES_COMPLETED + completion_evidence: [verified_document_id] + human_gate: HR_CONFIRMATION + + - id: RUN-PAYROLL-EXPLANATION + intent: PAYROLL_EXPLANATION + kind: MASTER + master_workflow_id: WF-PAY-001 + included_workflow_ids: [WF-PAY-001] + reusable_subflow_ids: [] + applicability: + subject: WORKER + conditions: [검증할 급여명세서와 귀속월이 선택됨] + required_slots_ref: WF-PAY-001 + stages: + - id: calculate_payroll_difference + catalog_steps: [WF-PAY-001.parse_payslip, WF-PAY-001.calculate_difference] + depends_on: [] + condition: ALWAYS + completion_evidence: [payroll_fields, calculation_trace] + human_gate: NONE + - id: explain_and_review + catalog_steps: [WF-PAY-001.generate_explanation, WF-PAY-001.review_explanation] + depends_on: [calculate_payroll_difference] + condition: ALL_DEPENDENCIES_COMPLETED + completion_evidence: [approved_explanation, hr_approval_id] + human_gate: HR_APPROVAL + - id: collect_and_route_question + catalog_steps: [WF-PAY-001.collect_question, WF-PAY-001.escalate_if_needed] + depends_on: [explain_and_review] + condition: ALL_DEPENDENCIES_COMPLETED + completion_evidence: [worker_response, task_or_ticket] + human_gate: HR_CONFIRMATION + + - id: RUN-WORK-INSTRUCTION + intent: WORK_INSTRUCTION + kind: MASTER + master_workflow_id: WF-INS-001 + included_workflow_ids: [WF-INS-001] + reusable_subflow_ids: [] + applicability: + subject: WORKER + conditions: [근무일정·작업·장소를 근로자에게 안내함] + required_slots_ref: WF-INS-001 + stages: + - id: prepare_instruction + catalog_steps: [WF-INS-001.validate_instruction, WF-INS-001.generate_notice, WF-INS-001.approve_notice] + depends_on: [] + condition: ALWAYS + completion_evidence: [approved_notice_id] + human_gate: HR_APPROVAL + - id: collect_acknowledgement + catalog_steps: [WF-INS-001.collect_response] + depends_on: [prepare_instruction] + condition: ALL_DEPENDENCIES_COMPLETED + completion_evidence: [worker_response] + human_gate: NONE + - id: follow_up_if_needed + catalog_steps: [WF-INS-001.follow_up] + depends_on: [collect_acknowledgement] + condition: EXCEPTION_DETECTED + completion_evidence: [task_or_ticket] + human_gate: HR_CONFIRMATION diff --git a/fowoco-knowledge/schemas/workflow-runtime-case.schema.json b/fowoco-knowledge/schemas/workflow-runtime-case.schema.json new file mode 100644 index 0000000..1da7c1e --- /dev/null +++ b/fowoco-knowledge/schemas/workflow-runtime-case.schema.json @@ -0,0 +1,27 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://fowoco.dev/schemas/workflow-runtime-case.schema.json", + "type": "object", + "additionalProperties": false, + "required": ["case_id", "profile_id", "path", "trigger_type", "provided_slots", "missing_slots", "expected_state", "expected_ready_stage_ids", "guardrails"], + "properties": { + "case_id": {"type": "string", "pattern": "^RUNTIME-[A-Z0-9-]+-[0-9]{3}$"}, + "profile_id": {"type": "string", "pattern": "^RUN-"}, + "path": {"enum": ["HAPPY_PATH", "MISSING_INPUT", "MANUAL_REVIEW"]}, + "trigger_type": {"enum": ["NATURAL_LANGUAGE", "DEADLINE_REACHED", "OCR_COMPLETED", "WORKER_RESPONSE_RECEIVED"]}, + "provided_slots": {"type": "array", "uniqueItems": true, "items": {"type": "string"}}, + "missing_slots": {"type": "array", "uniqueItems": true, "items": {"type": "string"}}, + "expected_state": {"enum": ["READY", "NEEDS_INFO", "MANUAL_REVIEW"]}, + "expected_ready_stage_ids": {"type": "array", "uniqueItems": true, "items": {"type": "string"}}, + "guardrails": { + "type": "object", + "additionalProperties": false, + "required": ["human_approval_required", "automatic_external_submission", "automatic_completion"], + "properties": { + "human_approval_required": {"const": true}, + "automatic_external_submission": {"const": false}, + "automatic_completion": {"const": false} + } + } + } +} diff --git a/fowoco-knowledge/schemas/workflow-runtime.schema.json b/fowoco-knowledge/schemas/workflow-runtime.schema.json new file mode 100644 index 0000000..822d791 --- /dev/null +++ b/fowoco-knowledge/schemas/workflow-runtime.schema.json @@ -0,0 +1,108 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://fowoco.dev/schemas/workflow-runtime.schema.json", + "title": "FOWOCO Workflow Runtime Contract", + "type": "object", + "additionalProperties": false, + "required": ["version", "policy", "event_contract", "composite_intent_policy", "profiles"], + "properties": { + "version": {"type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$"}, + "policy": { + "type": "object", + "additionalProperties": false, + "required": ["states", "transitions", "parallel_ready_rule", "completion_rule"], + "properties": { + "states": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": {"enum": ["DRAFT", "NEEDS_INFO", "READY", "IN_PROGRESS", "WAITING_WORKER", "WAITING_EXTERNAL", "REVIEW_REQUIRED", "COMPLETED", "CANCELLED", "MANUAL_REVIEW"]} + }, + "transitions": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["from", "event", "to"], + "properties": { + "from": {"type": "string"}, + "event": {"type": "string", "pattern": "^[A-Z][A-Z0-9_]+$"}, + "to": {"type": "string"} + } + } + }, + "parallel_ready_rule": {"const": "ALL_DEPENDENCIES_COMPLETED"}, + "completion_rule": {"const": "EVIDENCE_PRESENT_AND_HUMAN_GATE_SATISFIED"} + } + }, + "event_contract": { + "type": "object", + "additionalProperties": false, + "required": ["supported_types", "idempotency_required"], + "properties": { + "supported_types": { + "type": "array", + "uniqueItems": true, + "items": {"enum": ["NATURAL_LANGUAGE", "DEADLINE_REACHED", "OCR_COMPLETED", "WORKER_RESPONSE_RECEIVED"]} + }, + "idempotency_required": {"const": true} + } + }, + "composite_intent_policy": { + "type": "object", + "additionalProperties": false, + "required": ["strategy", "representative_intent", "confirmation_required"], + "properties": { + "strategy": {"const": "SPLIT_TO_WORKFLOW_CANDIDATES"}, + "representative_intent": {"const": "FIRST_EVIDENCE_ORDER"}, + "confirmation_required": {"const": true} + } + }, + "profiles": { + "type": "array", + "minItems": 6, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "intent", "kind", "master_workflow_id", "included_workflow_ids", "reusable_subflow_ids", "applicability", "required_slots_ref", "stages"], + "properties": { + "id": {"type": "string", "pattern": "^RUN-[A-Z0-9-]+$"}, + "intent": {"type": "string"}, + "kind": {"enum": ["MASTER", "SUBFLOW"]}, + "master_workflow_id": {"type": "string", "pattern": "^WF-"}, + "included_workflow_ids": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string", "pattern": "^WF-"}}, + "reusable_subflow_ids": {"type": "array", "uniqueItems": true, "items": {"type": "string", "pattern": "^WF-"}}, + "applicability": { + "type": "object", + "additionalProperties": false, + "required": ["subject", "conditions"], + "properties": { + "subject": {"enum": ["WORKER", "COMPANY"]}, + "conditions": {"type": "array", "minItems": 1, "items": {"type": "string", "minLength": 3}} + } + }, + "required_slots_ref": {"type": "string", "pattern": "^WF-"}, + "stages": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "catalog_steps", "depends_on", "condition", "completion_evidence", "human_gate"], + "properties": { + "id": {"type": "string", "pattern": "^[a-z][a-z0-9_]+$"}, + "catalog_steps": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string", "pattern": "^WF-[A-Z0-9-]+\\.[a-z][a-z0-9_]+$"}}, + "depends_on": {"type": "array", "uniqueItems": true, "items": {"type": "string", "pattern": "^[a-z][a-z0-9_]+$"}}, + "condition": {"enum": ["ALWAYS", "ALL_DEPENDENCIES_COMPLETED", "MISSING_INFORMATION", "EXCEPTION_DETECTED"]}, + "completion_evidence": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string", "pattern": "^[a-z][a-z0-9_]+$"}}, + "human_gate": {"enum": ["NONE", "HR_APPROVAL", "HR_CONFIRMATION", "MANUAL_EXTERNAL_ACTION"]}, + "parallel_group": {"type": "string", "pattern": "^[A-Z][A-Z0-9_]+$"} + } + } + } + } + } + } + } +} diff --git a/fowoco-knowledge/src/fowoco_knowledge/repository.py b/fowoco-knowledge/src/fowoco_knowledge/repository.py index 6be2f84..a9a6777 100644 --- a/fowoco-knowledge/src/fowoco_knowledge/repository.py +++ b/fowoco-knowledge/src/fowoco_knowledge/repository.py @@ -165,6 +165,11 @@ def compile_context(self, workflow_id: str) -> dict[str, Any]: ), None, ) + runtime_profiles = [ + profile + for profile in context["workflow_runtime"]["profiles"] + if workflow_id in profile["included_workflow_ids"] + ] return { "pack": { @@ -183,4 +188,5 @@ def compile_context(self, workflow_id: str) -> dict[str, Any]: "checklist": checklist, "official_sources": sources, "administrative_procedure": administrative_procedure, + "runtime_profiles": runtime_profiles, } diff --git a/fowoco-knowledge/src/fowoco_knowledge/validation.py b/fowoco-knowledge/src/fowoco_knowledge/validation.py index da100e6..fdc5099 100644 --- a/fowoco-knowledge/src/fowoco_knowledge/validation.py +++ b/fowoco-knowledge/src/fowoco_knowledge/validation.py @@ -113,6 +113,7 @@ def validate_all(self) -> list[str]: self._validate_manifest_files() self._validate_processed_datasets() self._validate_workflow_schema() + self._validate_workflow_runtime() self._validate_required_slot_contracts() self._validate_cross_references() self._validate_seed_data() @@ -249,6 +250,176 @@ def _validate_workflow_schema(self) -> None: path = ".".join(str(item) for item in error.path) self.errors.append(f"workflow schema [{path}]: {error.message}") + def _validate_workflow_runtime(self) -> None: + runtime = self.repository.load_yaml("knowledge/workflow_runtime.yaml") + schema = self.repository.load_json("schemas/workflow-runtime.schema.json") + schema_errors = list(Draft202012Validator(schema).iter_errors(runtime)) + for error in schema_errors: + path = ".".join(str(item) for item in error.path) + self.errors.append(f"workflow runtime schema [{path}]: {error.message}") + if schema_errors: + return + + catalog = self.repository.load_yaml("knowledge/workflow_catalog.yaml") + workflows = {workflow["id"]: workflow for workflow in catalog["workflows"]} + slot_requirements = self.repository.load_yaml("knowledge/required_slots.yaml")[ + "workflow_requirements" + ] + profiles = {profile["id"]: profile for profile in runtime["profiles"]} + if len(profiles) != len(runtime["profiles"]): + self.errors.append("workflow runtime: duplicate profile id") + + expected_intents = { + "EXPIRY_RENEWAL", + "WORKER_ONBOARDING", + "EMPLOYMENT_CHANGE", + "DOCUMENT_REQUEST", + "PAYROLL_EXPLANATION", + "WORK_INSTRUCTION", + } + if {profile["intent"] for profile in profiles.values()} != expected_intents: + self.errors.append("workflow runtime: six MVP intents must have one profile each") + + for profile_id, profile in profiles.items(): + included_workflow_ids = set(profile["included_workflow_ids"]) + if profile["master_workflow_id"] not in included_workflow_ids: + self.errors.append(f"workflow runtime {profile_id}: master must be included") + for workflow_id in included_workflow_ids | set(profile["reusable_subflow_ids"]): + if workflow_id not in workflows: + self.errors.append( + f"workflow runtime {profile_id}: unknown workflow {workflow_id}" + ) + master = workflows.get(profile["master_workflow_id"]) + if master and master["intent"] != profile["intent"]: + self.errors.append(f"workflow runtime {profile_id}: master intent mismatch") + if profile["required_slots_ref"] not in slot_requirements: + self.errors.append(f"workflow runtime {profile_id}: unknown required slots ref") + + stages = {stage["id"]: stage for stage in profile["stages"]} + if len(stages) != len(profile["stages"]): + self.errors.append(f"workflow runtime {profile_id}: duplicate stage id") + stage_order = {stage["id"]: index for index, stage in enumerate(profile["stages"])} + referenced_steps: set[str] = set() + for stage in profile["stages"]: + for dependency in stage["depends_on"]: + if dependency not in stages: + self.errors.append( + f"workflow runtime {profile_id}.{stage['id']}: " + f"unknown dependency {dependency}" + ) + elif stage_order[dependency] >= stage_order[stage["id"]]: + self.errors.append( + f"workflow runtime {profile_id}.{stage['id']}: " + f"dependency {dependency} must be earlier" + ) + for step_reference in stage["catalog_steps"]: + workflow_id, step_id = step_reference.split(".", maxsplit=1) + if workflow_id not in included_workflow_ids: + self.errors.append( + f"workflow runtime {profile_id}: step outside included workflow " + f"{step_reference}" + ) + continue + known_steps = {step["id"]: step for step in workflows[workflow_id]["steps"]} + if step_id not in known_steps: + self.errors.append( + f"workflow runtime {profile_id}: unknown step {step_reference}" + ) + continue + if step_reference in referenced_steps: + self.errors.append( + f"workflow runtime {profile_id}: duplicate step {step_reference}" + ) + referenced_steps.add(step_reference) + catalog_step = known_steps[step_id] + if (catalog_step["actor"] == "HR" or catalog_step.get("gate")) and stage[ + "human_gate" + ] == "NONE": + self.errors.append( + f"workflow runtime {profile_id}.{stage['id']}: " + "human-controlled step requires a human gate" + ) + if ( + step_id in {"manual_external_process", "manual_report"} + and stage["human_gate"] != "MANUAL_EXTERNAL_ACTION" + ): + self.errors.append( + f"workflow runtime {profile_id}.{stage['id']}: " + "external action must remain manual" + ) + + expected_steps = { + f"{workflow_id}.{step['id']}" + for workflow_id in included_workflow_ids + for step in workflows[workflow_id]["steps"] + } + if referenced_steps != expected_steps: + self.errors.append( + f"workflow runtime {profile_id}: all included catalog steps must be mapped" + ) + + fixture_manifest = self.repository.load_yaml( + "data/evaluation/workflow_runtime_manifest.yaml" + ) + fixture_schema = self.repository.load_json(fixture_manifest["schema"]) + fixture_path = self.repository.root / fixture_manifest["path"] + if file_sha256(fixture_path) != fixture_manifest["sha256"]: + self.errors.append("workflow runtime fixtures: checksum mismatch") + + cases: list[dict[str, Any]] = [] + seen_case_ids: set[str] = set() + for line_number, raw_line in enumerate( + fixture_path.read_text(encoding="utf-8").splitlines(), start=1 + ): + if not raw_line.strip(): + continue + try: + case = json.loads(raw_line) + except json.JSONDecodeError as exc: + self.errors.append(f"workflow runtime fixture line {line_number}: {exc}") + continue + cases.append(case) + for error in Draft202012Validator(fixture_schema).iter_errors(case): + path = ".".join(str(item) for item in error.path) + self.errors.append( + f"workflow runtime fixture line {line_number} [{path}]: {error.message}" + ) + case_id = case.get("case_id") + if case_id in seen_case_ids: + self.errors.append(f"workflow runtime fixture: duplicate {case_id}") + seen_case_ids.add(case_id) + profile = profiles.get(case.get("profile_id")) + if not profile: + self.errors.append(f"workflow runtime fixture line {line_number}: unknown profile") + continue + stage_ids = {stage["id"] for stage in profile["stages"]} + if not set(case.get("expected_ready_stage_ids", [])) <= stage_ids: + self.errors.append( + f"workflow runtime fixture line {line_number}: unknown ready stage" + ) + if set(case.get("provided_slots", [])) & set(case.get("missing_slots", [])): + self.errors.append( + f"workflow runtime fixture line {line_number}: " + "slot cannot be provided and missing" + ) + if case.get("path") == "MISSING_INPUT" and not case.get("missing_slots"): + self.errors.append( + f"workflow runtime fixture line {line_number}: missing path needs missing slots" + ) + + if len(cases) != fixture_manifest["record_count"]: + self.errors.append("workflow runtime fixtures: record count mismatch") + cases_by_profile = Counter(case.get("profile_id") for case in cases) + required_paths = set(fixture_manifest["required_paths"]) + for profile_id in profiles: + if cases_by_profile[profile_id] != fixture_manifest["cases_per_profile"]: + self.errors.append(f"workflow runtime fixtures: {profile_id} must have three cases") + profile_paths = { + case.get("path") for case in cases if case.get("profile_id") == profile_id + } + if profile_paths != required_paths: + self.errors.append(f"workflow runtime fixtures: {profile_id} paths are incomplete") + def _validate_required_slot_contracts(self) -> None: config = self.repository.load_yaml("knowledge/required_slots.yaml") schema = self.repository.load_json("schemas/required-slots.schema.json") diff --git a/fowoco-knowledge/tests/test_repository.py b/fowoco-knowledge/tests/test_repository.py index ae92477..bc9c022 100644 --- a/fowoco-knowledge/tests/test_repository.py +++ b/fowoco-knowledge/tests/test_repository.py @@ -48,6 +48,7 @@ def test_compiled_context_is_cross_linked() -> None: assert context["checklist"]["id"] == "CHK-STAY-RENEW-001" assert context["administrative_procedure"]["id"] == "PROC-STAY-PERIOD-EXTENSION-001" assert any(rule["id"] == "GRD-003" for rule in context["guardrails"]) + assert {profile["id"] for profile in context["runtime_profiles"]} == {"RUN-EXPIRY-RENEWAL"} def test_employment_change_context_uses_one_stop_reporting_procedure() -> None: diff --git a/fowoco-knowledge/tests/test_validation.py b/fowoco-knowledge/tests/test_validation.py index abf38e4..c2747bf 100644 --- a/fowoco-knowledge/tests/test_validation.py +++ b/fowoco-knowledge/tests/test_validation.py @@ -183,3 +183,27 @@ def test_git_lfs_pointer_exposes_artifact_checksum_and_size(tmp_path: Path) -> N "abf0d7d3bdb89a5ac34abc8cc41a77396bf024f02de6939591b3c555446bd48b", 442518124, ) + + +def test_six_workflow_runtime_profiles_have_three_e2e_paths_each() -> None: + runtime = yaml.safe_load((ROOT / "knowledge/workflow_runtime.yaml").read_text(encoding="utf-8")) + cases = [ + json.loads(line) + for line in (ROOT / "data/evaluation/workflow_runtime_cases.jsonl") + .read_text(encoding="utf-8") + .splitlines() + if line.strip() + ] + + assert len(runtime["profiles"]) == 6 + assert len(cases) == 18 + for profile in runtime["profiles"]: + profile_cases = [case for case in cases if case["profile_id"] == profile["id"]] + assert {case["path"] for case in profile_cases} == { + "HAPPY_PATH", + "MISSING_INPUT", + "MANUAL_REVIEW", + } + assert all(stage["completion_evidence"] for stage in profile["stages"]) + assert all(not case["guardrails"]["automatic_external_submission"] for case in cases) + assert all(not case["guardrails"]["automatic_completion"] for case in cases)