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
8 changes: 8 additions & 0 deletions fowoco-knowledge/hr-intent-service/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
venv/
__pycache__/
*.pyc
.env
.git/
.gitignore
README.md
server.log
11 changes: 11 additions & 0 deletions fowoco-knowledge/hr-intent-service/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
BERT_MODEL_DIR=fowoco/klue-roberta-base-intent-classifier
AX_BASE_MODEL_NAME=skt/A.X-4.0-Light
AX_ADAPTER_PATH=fowoco/ax-intent-qlora
HF_TOKEN=
MARGIN_THRESHOLD=0.76
MAX_TRAINED_LABELS=3
LABEL_PROB_THRESHOLD=0.55
MAX_INPUT_LENGTH=150
AX_MAX_NEW_TOKENS=96
DEVICE=auto
ENABLE_AX=True
6 changes: 6 additions & 0 deletions fowoco-knowledge/hr-intent-service/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
venv/
__pycache__/
*.pyc
.env
server.log
models/
18 changes: 18 additions & 0 deletions fowoco-knowledge/hr-intent-service/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
FROM nvidia/cuda:12.1.0-runtime-ubuntu22.04

RUN apt-get update && apt-get install -y python3.11 python3-pip && rm -rf /var/lib/apt/lists/*

WORKDIR /srv

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY app ./app

# 모델은 HF Hub에서 받아온다.
ENV BERT_MODEL_DIR=fowoco/klue-roberta-base-intent-classifier
ENV AX_BASE_MODEL_NAME=skt/A.X-4.0-Light
ENV AX_ADAPTER_PATH=fowoco/ax-intent-qlora

EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
82 changes: 82 additions & 0 deletions fowoco-knowledge/hr-intent-service/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# HR Intent Classification model

BERT(Full FT) 메인 모델 + A.X-4.0-Light(QLoRA) 보조 모델 cascade 구조의 HR 업무 요청 문장 Intent 분류 모델

## 현재 상태 (2026-08-04 기준)

- ✅ 모델 학습·검증 완료
- ✅ 로컬 FastAPI 서비스 구현 및 검증 완료
- ✅ Hugging Face Hub(private) 모델 저장소 연동 완료
- ✅ Colab GPU 환경에서 BERT + A.X 전체 cascade 실제 요청/응답 검증 완료


## 아키텍처

```text
[HR 입력 데이터 수신]
[1차 검증] 활성 라벨 수 ≥ 3개? (OOD) ────────► (YES) ──┐
│ │
▼ │
[2차 검증] 고위험/오답 키워드 포함? ─────────► (YES) ──┼──► [A.X-4.0-Light (LLM) 호출]
│ │
▼ │
[3차 검증] Margin Score < 0.76 ? ────────────► (YES) ──┘
└── (NO: 모든 안전망 통과) ──────────────────► [BERT 결과 최종 사용]
```

라우팅 규칙 및 모델 구조 선정 근거는 팀 노션 문서 참조

### 모델 구성

| 모델 | 역할 | 방식 | Validation 268건 정확도 |
|---|---|---|---|
| klue/roberta-base | 메인 | Full Fine-tuning | 95.5% |
| A.X-4.0-Light | 보조 | QLoRA (checkpoint-402) | 92.2% |
| Cascade 모델 | 최종 | 메인 모델 + 보조 모델 , 라우팅 조건 적용 | 93.2% |


## Hugging Face Hub 연동

https://huggingface.co/fowoco

모델 학습 가중치는 `fowoco` 조직의 private repo에 저장되어 있다. GitHub에는 코드만 올리고, 모델 파일은 여기서 관리한다 .
```
fowoco/klue-roberta-base-intent-classifier
fowoco/ax-intent-qlora
```

### 필요한 환경변수

```
BERT_MODEL_DIR=fowoco/klue-roberta-base-intent-classifier
AX_BASE_MODEL_NAME=skt/A.X-4.0-Light # 공개 모델, 토큰 불필요
AX_ADAPTER_PATH=fowoco/ax-intent-qlora
HF_TOKEN=<fowoco 조직 접근 권한이 있는 개인 토큰 : intent 모델 담당자에 문의 바람 >
```

`HF_TOKEN`은 절대 코드나 `Dockerfile`에 하드코딩하지 않는다. `.env`(`.gitignore`로 제외됨) 또는 배포 시 Secret으로 주입한다.


## 로컬 실행 - 가상환경

```bash
python -m venv venv
venv\Scripts\activate # Windows
pip install -r requirements.txt
cp .env.example .env # 값 채우기 (HF_TOKEN 등)
uvicorn app.main:app --reload
```

GPU가 없는 로컬 환경에서는 `.env`에 `ENABLE_AX=False`로 두면 BERT만으로 서비스가 뜬다 (A.X 로드 실패 시에도 동일하게 자동으로 BERT-only degraded 모드로 전환됨, `pipeline.py` 참고).

## 로컬 실행 - Docker

```bash
docker build -t hr-intent-service:test .
docker run -p 8000:8000 --env-file .env hr-intent-service:test
```

로컬 환경에서는 `.env`에 `ENABLE_AX=False`로 둘 것을 권장함.
Empty file.
96 changes: 96 additions & 0 deletions fowoco-knowledge/hr-intent-service/app/ax_model.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
"""A.X-4.0-Light QLoRA 파인튜닝 모델 로드 및 추론."""

import json
import re

import torch
from peft import PeftModel
from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig

SYSTEM_PROMPT = """당신은 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 형식으로만 분류하십시오."""


def _extract_json(text: str) -> dict | None:
match = re.search(r"\{.*\}", text, re.DOTALL)
if not match:
return None
try:
return json.loads(match.group(0))
except json.JSONDecodeError:
return None


class AxIntentModel:
def __init__(
self, base_model_name: str, adapter_path: str, device: str, max_new_tokens: int = 96, hf_token : str | None = None,
):
self.max_new_tokens = max_new_tokens
bnb_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_compute_dtype=torch.float16,
)
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,
)
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()

def predict(self, hr_input: str) -> list[dict]:
"""실패 시 예외를 던진다 — 호출부(pipeline)에서 graceful degradation 처리."""
messages = [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": hr_input},
]
inputs = self.tokenizer.apply_chat_template(
messages,
add_generation_prompt=True,
tokenize=True,
return_dict=True,
return_tensors="pt",
).to(self.model.device)

with torch.no_grad():
output = self.model.generate(
**inputs, max_new_tokens=self.max_new_tokens, do_sample=False
)

raw = self.tokenizer.decode(
output[0][inputs["input_ids"].shape[-1] :], skip_special_tokens=True
)
parsed = _extract_json(raw)
if parsed is None:
raise ValueError(f"A.X output could not be parsed as JSON: {raw!r}")
return parsed.get("intents", [])
43 changes: 43 additions & 0 deletions fowoco-knowledge/hr-intent-service/app/bert_model.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
"""klue/roberta-base Full Fine-tuning 모델 로드 및 추론."""

import torch
from transformers import AutoModelForSequenceClassification, AutoTokenizer


class BertIntentModel:
def __init__(self, model_dir: str, device: str, label_prob_threshold: float = 0.55, hf_token: str | None = None, ):
self.device = "cuda" 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)
self.model = AutoModelForSequenceClassification.from_pretrained(model_dir, token=hf_token)
self.model.to(self.device).eval()
self.id2label = self.model.config.id2label
self.label_prob_threshold = label_prob_threshold

@torch.no_grad()
def predict(self, text: str) -> tuple[dict[str, float], float, list[str]]:
"""확률 딕셔너리, margin, 활성화된 intent 리스트를 반환.

margin: 활성화(threshold 이상)된 것 중 최저 확률 - 비활성화된 것 중 최고 확률.
"""
enc = self.tokenizer(text, truncation=True, max_length=64, return_tensors="pt").to(
self.device
)
logits = self.model(**enc).logits
probs_array = torch.sigmoid(logits)[0].cpu().numpy()
probs_dict = {self.id2label[i]: float(p) for i, p in enumerate(probs_array)}

activated = [p for p in probs_array if p >= self.label_prob_threshold]
not_activated = [p for p in probs_array if p < self.label_prob_threshold]

if not activated:
margin = float(max(probs_array)) - self.label_prob_threshold
else:
margin = float(min(activated)) - (float(max(not_activated)) if not_activated else 0.0)

picked = [self.id2label[i] for i, p in enumerate(probs_array) if p >= self.label_prob_threshold]
if not picked:
picked = [self.id2label[int(probs_array.argmax())]]

return probs_dict, margin, picked
44 changes: 44 additions & 0 deletions fowoco-knowledge/hr-intent-service/app/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
"""서비스 설정. 모든 값은 환경변수로 주입.

로컬 개발: .env 파일 사용
운영 배포: 컨테이너 오케스트레이터 주입
"""

from functools import lru_cache

from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")

# 모델 경로 - Hugging Face Hub repo ID 형식
# BERT/A.X 어댑터는 fowoco 조직의 private repo이므로 hf_token 인증이 필요
# A.X 베이스 모델(skt/A.X-4.0-Light)은 공개 모델이라 토큰 없이도 접근 가능

bert_model_dir: str = "fowoco/klue-roberta-base-intent-classifier"
ax_base_model_name: str = "skt/A.X-4.0-Light"
ax_adapter_path: str = "fowoco/ax-intent-qlora"

hf_token: str | None = None

# 라우팅 규칙 파라미터
margin_threshold: float = 0.76
max_trained_labels: int = 3
label_prob_threshold: float = 0.55

# 입력 검증
max_input_length: int = 150

# 생성 파라미터 (A.X)
ax_max_new_tokens: int = 96

# 런타임
device: str = "auto" # "auto" | "cuda" | "cpu"
enable_ax: bool = True


@lru_cache
def get_settings() -> Settings:
"""설정을 한 번만 로드하고 재사용 (매 요청마다 다시 읽지 않음)."""
return Settings()
Loading