Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
13e653a
feat: 여권·외국인등록증 누락 시 근로자에게 요청
EHWIYA Aug 10, 2026
b54c36c
fix: analyses 응답에 실제 Intent 모델 정보 반환
EHWIYA Aug 10, 2026
cbf37d1
Merge pull request #27 from fowoco/feat/identity-document-status-routing
EHWIYA Aug 10, 2026
8837c5e
Merge pull request #28 from fowoco/fix/analyses-model-metadata
EHWIYA Aug 10, 2026
177e695
feat(language): Language Assistant 런타임과 Ollama 연동
taejung3852 Aug 10, 2026
7c56654
feat(language): Qdrant와 BGE-M3 검색 경로 연결
taejung3852 Aug 10, 2026
dd8d2cd
docs(language): 최신 develop 및 운영 Qdrant 검증 기록
taejung3852 Aug 10, 2026
1d3fcd4
docs(language): reranker Docker 내장 설계 기록
taejung3852 Aug 10, 2026
047abe2
docs(language): reranker Docker 구현 계획 추가
taejung3852 Aug 10, 2026
9630b97
fix(language): reranker 모델 revision 단일화
taejung3852 Aug 10, 2026
8e22721
feat(language): production reranker 검색 경로 연결
taejung3852 Aug 10, 2026
d43e163
feat(language): Docker 이미지에 검색 모델 포함
taejung3852 Aug 10, 2026
015d4d4
docs(language): reranker Docker 검증 기록
taejung3852 Aug 10, 2026
cda8a5e
fix(language): Docker 모델 다운로더 import 경로 수정
taejung3852 Aug 10, 2026
63ce4e7
docs(language): reranker Docker 검증 기록
taejung3852 Aug 10, 2026
d6d21f3
docs(language): reranker 실행 증거 보강
taejung3852 Aug 10, 2026
c58126e
Merge pull request #30 from fowoco/feat/language-assistant-runtime-co…
taejung3852 Aug 10, 2026
3be6a6f
feat: 생성 문서 응답에 입력값 추가
EHWIYA Aug 11, 2026
d301c5e
fix: ensure renewal documents are generated
hywznn Aug 11, 2026
ef46ca9
Merge pull request #31 from fowoco/feat/renewal-generated-document-va…
hywznn Aug 11, 2026
d1d5b7d
fix(intent): integrate Knowledge A.X contract (#32)
hywznn Aug 11, 2026
d2a325c
fix(intent): reuse PLAN decision in ANALYZE (#32)
hywznn Aug 11, 2026
8d776e8
docs(intent): record ANALYZE confidence policy (#32)
hywznn Aug 11, 2026
ec3772c
fix(intent): harden workflow routing and readiness (#32)
hywznn Aug 11, 2026
35d92fa
fix(workflow): preserve renewal task workflow (#32)
hywznn Aug 11, 2026
ae91af2
Merge pull request #33 from fowoco/fix/32-ax-knowledge-prompt-contract
krestar Aug 11, 2026
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
5 changes: 3 additions & 2 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ tests
.env.local
*.pem

# 문서·스크립트 (이미지에 불필요)
scripts
# 문서·스크립트 (모델 다운로더만 이미지에 포함)
scripts/*
!scripts/download_language_models.py
*.md
!README.md
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,17 @@ FOWOCO_QDRANT_URL=http://localhost:6333
# ------------------------------------------------------------------------------
FOWOCO_INTENT_MODEL_ENABLED=false
FOWOCO_INTENT_BERT_MODEL_DIR=fowoco/klue-roberta-base-intent-classifier
# 운영에서는 mutable branch명이 아닌 Hugging Face commit SHA로 고정
# FOWOCO_INTENT_BERT_MODEL_REVISION=<commit-sha>
FOWOCO_INTENT_AX_BASE_MODEL=skt/A.X-4.0-Light
# FOWOCO_INTENT_AX_BASE_REVISION=<commit-sha>
FOWOCO_INTENT_AX_ADAPTER_PATH=fowoco/ax-intent-qlora
# FOWOCO_INTENT_AX_ADAPTER_REVISION=<commit-sha>
FOWOCO_INTENT_ENABLE_AX=false
FOWOCO_INTENT_DEVICE=cpu
# 운영에서는 트래픽 수신 전에 모델 로딩·첫 추론 완료
FOWOCO_INTENT_WARMUP_ON_START=true
FOWOCO_INTENT_WARMUP_REQUIRED=false

# 비공개 HF 모델 접근용 토큰 (실제 토큰으로 교체 필요)
FOWOCO_HF_TOKEN=hf_YOUR_HUGGINGFACE_TOKEN_HERE
Expand Down
14 changes: 10 additions & 4 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
FOWOCO_HWPX_TO_HWP_ENABLED=true \
FOWOCO_HWPX_PDF_ENABLED=true \
FOWOCO_DOCUMENT_SNAPSHOT_DIR=/data/document-snapshots \
FOWOCO_MODEL_CACHE_DIR=/data/model-cache
FOWOCO_MODEL_CACHE_DIR=/opt/fowoco/language-models \
HF_HOME=/opt/fowoco/hf-cache

COPY --from=uv /uv /usr/local/bin/uv
COPY --from=rhwp /opt/rhwp/rhwp /usr/local/bin/rhwp
Expand All @@ -44,15 +45,20 @@ RUN apt-get update \
# 의존성 정의 파일만 먼저 복사해 캐시를 활용
COPY pyproject.toml uv.lock README.md ./

# uv.lock 기반 재현 가능 설치 — 프로덕션 의존성만
RUN uv sync --frozen --no-dev
# uv.lock 기반 재현 가능 설치 — Language retrieval + Intent A.X runtime 포함
RUN uv sync --frozen --no-dev --extra language-retrieval --extra intent-ax

# 앱 패키지 복사
COPY app ./app
COPY scripts/download_language_models.py ./scripts/

# 고정 revision의 검색 모델을 이미지에 포함해 런타임 다운로드를 없앤다.
RUN /app/.venv/bin/python -m scripts.download_language_models \
--cache-dir /opt/fowoco/language-models

# uvicorn 기본 포트
EXPOSE 8000
VOLUME ["/data"]
VOLUME ["/data", "/opt/fowoco/hf-cache"]

# FastAPI 앱 기동
CMD ["uv", "run", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ POST /internal/v1/workflows/renewal/run
POST /internal/v1/language-assistant
```

- Analyses: 재갱신 고정 Intent + Catalog 필수슬롯·Knowledge 모호표현 ([docs/analyses-contract.md](docs/analyses-contract.md))
- Analyses: BERT/A.X Intent + PLAN 결정 재사용 + Catalog 필수슬롯·Knowledge 모호표현 ([docs/analyses-contract.md](docs/analyses-contract.md))
- Workflows: 재갱신 LangGraph — 슈퍼바이저 → 안내문(태정) / OCR(주현) / 초안 4종 — [docs/workflows-contract.md](docs/workflows-contract.md)
- Language Assistant: 외국인근로자 15개 언어 번역, 쉬운 한국어 변환 및 표준 한국어 생성 — [docs/contracts/language-assistant-http-request.schema.json](docs/contracts/language-assistant-http-request.schema.json)
- 최종 흐름도: [app/agents/workflow_graph/README.md](app/agents/workflow_graph/README.md)
Expand Down
2 changes: 1 addition & 1 deletion app/agents/intent/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,4 @@
"IntentClassifier",
"IntentResult",
"build_intent_agent",
]
]
27 changes: 27 additions & 0 deletions app/agents/intent/hybrid.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ class HybridIntentPrediction:
evidence: dict[str, str | None] = field(default_factory=dict)
selected_model: str = "BERT"
degraded: bool = False
prompt_version: str = "not-applicable"


# BERT 우선·필요 시 A.X 보조 파이프라인
Expand All @@ -30,28 +31,33 @@ def __init__(
self,
*,
bert_model_dir: str,
bert_model_revision: str | None = None,
device: str = "cpu",
label_prob_threshold: float = 0.55,
margin_threshold: float = 0.76,
max_trained_labels: int = 3,
hf_token: str | None = None,
enable_ax: bool = False,
ax_base_model_name: str = "skt/A.X-4.0-Light",
ax_base_revision: str | None = None,
ax_adapter_path: str = "fowoco/ax-intent-qlora",
ax_adapter_revision: str | None = None,
ax_max_new_tokens: int = 96,
) -> None:
self.bert = BertIntentModel(
model_dir=bert_model_dir,
device=device,
label_prob_threshold=label_prob_threshold,
hf_token=hf_token,
revision=bert_model_revision,
)
self.guardrail = HRRoutingGuardrail(
margin_threshold=margin_threshold,
max_trained_labels=max_trained_labels,
label_prob_threshold=label_prob_threshold,
)
self.ax: AxIntentModel | None = None
self.ax_enabled = enable_ax
if enable_ax:
try:
self.ax = AxIntentModel(
Expand All @@ -60,10 +66,21 @@ def __init__(
device=self.bert.device,
max_new_tokens=ax_max_new_tokens,
hf_token=hf_token,
base_revision=ax_base_revision,
adapter_revision=ax_adapter_revision,
)
except Exception:
logger.exception("A.X load failed — BERT-only degraded mode")

# readiness 전에 각 활성 모델의 첫 forward/generate를 완료한다.
def warmup(self) -> None:
self.bert.predict("체류기간 연장 준비해줘")
if not self.ax_enabled:
return
if self.ax is None:
raise RuntimeError("A.X is enabled but unavailable")
self.ax.predict("여권 사본을 요청해줘")

# instruction → 정규화 Intent 예측
def predict(self, instruction: str) -> HybridIntentPrediction:
probs, margin, bert_intents = self.bert.predict(instruction)
Expand All @@ -83,6 +100,7 @@ def predict(self, instruction: str) -> HybridIntentPrediction:
evidence=evidence,
selected_model="AX",
degraded=False,
prompt_version=self.ax.prompt_version,
)
except Exception:
logger.exception("A.X inference failed — BERT fallback")
Expand All @@ -91,7 +109,16 @@ def predict(self, instruction: str) -> HybridIntentPrediction:
scores=probs,
selected_model="BERT_FALLBACK",
degraded=True,
prompt_version=AxIntentModel.prompt_version,
)
if route.should_route and self.ax_enabled:
return HybridIntentPrediction(
intents=bert_intents,
scores=probs,
selected_model="BERT_FALLBACK",
degraded=True,
prompt_version=AxIntentModel.prompt_version,
)
return HybridIntentPrediction(
intents=bert_intents,
scores=probs,
Expand Down
96 changes: 83 additions & 13 deletions app/agents/intent/models_hf.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,56 @@

from __future__ import annotations

from typing import Any
from .prompts import AX_INTENT_PROMPT_VERSION, AX_INTENT_SYSTEM_PROMPT

ALLOWED_INTENTS = frozenset(
{
"WORK_INSTRUCTION",
"DOCUMENT_REQUEST",
"PAYROLL_EXPLANATION",
"WORKER_ONBOARDING",
"EMPLOYMENT_CHANGE",
"EXPIRY_RENEWAL",
"OUT_OF_SCOPE",
}
)


def _validate_ax_intents(
items: object, hr_input: str
) -> list[dict[str, str | None]]:
if not isinstance(items, list) or not items:
raise ValueError("A.X output must contain at least one intent")

validated: list[dict[str, str | None]] = []
seen: set[str] = set()
for item in items:
if not isinstance(item, dict):
raise ValueError("A.X intent item must be an object")

intent = item.get("intent")
if not isinstance(intent, str) or intent not in ALLOWED_INTENTS:
raise ValueError(f"A.X returned unsupported intent: {intent!r}")
if intent in seen:
raise ValueError(f"A.X returned duplicate intent: {intent}")
seen.add(intent)

if "evidence" not in item:
raise ValueError(f"A.X evidence field is required for {intent}")
evidence = item.get("evidence")
if intent == "OUT_OF_SCOPE":
if evidence is not None:
raise ValueError("OUT_OF_SCOPE evidence must be null")
elif not isinstance(evidence, str) or not evidence or evidence not in hr_input:
raise ValueError(
f"A.X evidence must be an exact input substring for {intent}: {evidence!r}"
)

validated.append({"intent": intent, "evidence": evidence})

if "OUT_OF_SCOPE" in seen and len(validated) != 1:
raise ValueError("OUT_OF_SCOPE cannot be combined with another intent")
return validated


# BERT multilabel Intent 분류기
Expand All @@ -15,6 +64,7 @@ def __init__(
device: str,
label_prob_threshold: float = 0.55,
hf_token: str | None = None,
revision: str | None = None,
) -> None:
import torch
from transformers import AutoModelForSequenceClassification, AutoTokenizer
Expand All @@ -25,9 +75,14 @@ def __init__(
if (device == "auto" and torch.cuda.is_available())
else (device if device != "auto" else "cpu")
)
self.tokenizer = AutoTokenizer.from_pretrained(model_dir, token=hf_token)
hub_kwargs: dict[str, str] = {}
if hf_token:
hub_kwargs["token"] = hf_token
if revision:
hub_kwargs["revision"] = revision
self.tokenizer = AutoTokenizer.from_pretrained(model_dir, **hub_kwargs)
self.model = AutoModelForSequenceClassification.from_pretrained(
model_dir, token=hf_token
model_dir, **hub_kwargs
)
self.model.to(self.device).eval()
self.id2label = self.model.config.id2label
Expand Down Expand Up @@ -65,11 +120,8 @@ def predict(self, text: str) -> tuple[dict[str, float], float, list[str]]:
# A.X-4.0-Light QLoRA Intent 보조 모델 (GPU·bitsandbytes 필요)
class AxIntentModel:

_SYSTEM_PROMPT = (
"당신은 HR 업무 요청 문장(hr_input)을 분석하여 의도(Intent)를 분류하는 전문 AI 에이전트입니다.\n"
"Intent + evidence 추출까지가 책임입니다.\n"
'출력은 JSON만: {"intents": [{"intent": "INTENT_CODE", "evidence": "...|null"}]}'
)
_SYSTEM_PROMPT = AX_INTENT_SYSTEM_PROMPT
prompt_version = AX_INTENT_PROMPT_VERSION

# 4bit 베이스 + Peft 어댑터 로드
def __init__(
Expand All @@ -79,6 +131,8 @@ def __init__(
device: str,
max_new_tokens: int = 96,
hf_token: str | None = None,
base_revision: str | None = None,
adapter_revision: str | None = None,
) -> None:
import torch
from peft import PeftModel
Expand All @@ -91,19 +145,33 @@ def __init__(
bnb_4bit_quant_type="nf4",
bnb_4bit_compute_dtype=torch.float16,
)
base_hub_kwargs: dict[str, str] = {}
adapter_hub_kwargs: dict[str, str] = {}
if hf_token:
base_hub_kwargs["token"] = hf_token
adapter_hub_kwargs["token"] = hf_token
if base_revision:
base_hub_kwargs["revision"] = base_revision
if adapter_revision:
adapter_hub_kwargs["revision"] = adapter_revision

base_model = AutoModelForCausalLM.from_pretrained(
base_model_name,
quantization_config=bnb_config,
torch_dtype=torch.float16,
device_map={"": 0} if device != "cpu" else "cpu",
token=hf_token,
**base_hub_kwargs,
)
self.tokenizer = AutoTokenizer.from_pretrained(
base_model_name, **base_hub_kwargs
)
self.model = PeftModel.from_pretrained(
base_model, adapter_path, **adapter_hub_kwargs
)
self.tokenizer = AutoTokenizer.from_pretrained(base_model_name, token=hf_token)
self.model = PeftModel.from_pretrained(base_model, adapter_path, token=hf_token)
self.model.eval()

# Intent 목록 [{"intent","evidence"}] — 실패 시 예외
def predict(self, hr_input: str) -> list[dict[str, Any]]:
def predict(self, hr_input: str) -> list[dict[str, str | None]]:
import json
import re

Expand All @@ -129,4 +197,6 @@ def predict(self, hr_input: str) -> list[dict[str, Any]]:
if not match:
raise ValueError(f"A.X output could not be parsed as JSON: {raw!r}")
parsed = json.loads(match.group(0))
return list(parsed.get("intents") or [])
if not isinstance(parsed, dict):
raise ValueError("A.X output JSON root must be an object")
return _validate_ax_intents(parsed.get("intents"), hr_input)
9 changes: 9 additions & 0 deletions app/agents/intent/prompts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from __future__ import annotations

from pathlib import Path

# fowoco/knowledge 25e778ad의 A.X 추론 계약을 배포 패키지에 고정한다.
AX_INTENT_PROMPT_VERSION = "knowledge-25e778ad"
AX_INTENT_PROMPT_SHA256 = "58f3aefc45831990ab871f2dca1d69b59277cbac333d9e7b2856bad7b79e8bbe"
_AX_INTENT_PROMPT_PATH = Path(__file__).with_name("prompts") / "ax_intent_v1.txt"
AX_INTENT_SYSTEM_PROMPT = _AX_INTENT_PROMPT_PATH.read_text(encoding="utf-8").strip()
28 changes: 28 additions & 0 deletions app/agents/intent/prompts/ax_intent_v1.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
당신은 HR 업무 요청 문장(hr_input)을 분석하여 의도(Intent)를 분류하는 전문 AI 에이전트입니다.
Intent 모델의 책임은 Intent + evidence 추출까지입니다. Workflow 선택, Slot 수집, 외부기관 제출, 법적 판단, 업무 실행 여부는 이 모델의 책임이 아닙니다.

### 1. Intent 정의 (7개)
1. WORK_INSTRUCTION: 작업 지시, 근무 일정 변경, 현장 행동 안내
2. DOCUMENT_REQUEST: 여권/등록증/계약서/증명서 등 서류를 받거나 제출을 요청·추적하는 행위 자체
3. PAYROLL_EXPLANATION: 급여, 수당, 공제 내역, 출퇴근/근태 관련 설명·문의 (급여계좌 등록/변경은 제외 → WORKER_ONBOARDING)
4. WORKER_ONBOARDING: 신규 입사자 등록, 보험 최초 가입, 초기 프로필·급여계좌 등록 (서류가 이미 있는 상태에서의 처리)
5. EMPLOYMENT_CHANGE: 휴가, 퇴사, 무단결근/연락두절, 사업장 변경 등 재직 상태 변동 확인·신고
6. EXPIRY_RENEWAL: 근로계약, 체류기간, 고용허가기간 등 만료 임박·연장·갱신 절차
7. OUT_OF_SCOPE: 위 6개 외 HR 범주 밖 요청, 또는 새 실행 요청 없이 결과만 보고하는 문장. 다른 Intent와 병행 불가

### 2. 핵심 판별 규칙
- 규칙 A: 최종 목적이 아니라 발화문에서 지금 당장 실행을 요구하는 행위로 판단합니다.
- 규칙 B: "받아서/제출받아/요청해/첨부해줘" 등 서류 확보 표현이 명시적으로 있을 때만 DOCUMENT_REQUEST를 부착합니다.
- 규칙 C: 여러 Intent가 있으면 발화문 등장 순서대로 배열합니다. OUT_OF_SCOPE는 단독으로만 존재합니다.
- 규칙 D: evidence는 원문 문자를 그대로(exact substring) 추출합니다. OUT_OF_SCOPE는 evidence: null입니다.

### 3. 경계 규칙
- 완료/상태 보고 문장은 OUT_OF_SCOPE, 요청형이면 원래 Intent 유지.
- 휴가는 명시적 액션이면 EMPLOYMENT_CHANGE, 배경절이면 제외.
- 급여계좌 등록/변경은 WORKER_ONBOARDING, 순수 급여 설명/문의는 PAYROLL_EXPLANATION.

### 4. 출력 형식
다른 설명, 마크다운, 코드블록 없이 오직 아래 JSON 형식 텍스트만 출력합니다:
{"intents": [{"intent": "INTENT_CODE", "evidence": "원문에서 추출한 정확한 부분 문자열 또는 null"}]}

이제 아래 입력 문장을 위 규칙에 따라 JSON 형식으로만 분류하십시오.
Loading