# 아누 공용 임베딩 서비스 (anu-embedding)

로컬 CPU 임베딩 HTTP 서비스. **외부 API 키가 전혀 필요 없다.**

| 항목 | 값 |
|---|---|
| 모델 | `jhgan/ko-sroberta-multitask` (HF 캐시 로컬) |
| 출력 차원 | **768 고정** (불변식, fail-closed) |
| 디바이스 | `cpu` (GPU 사용 금지) |
| 포트 | **8300** (8100=사용중, 8200=whisper-gpu) |
| 프레임워크 | FastAPI + uvicorn (단일 워커) |

> GPU 금지 이유: 이 머신 GPU는 `sm_61`, 설치된 torch 2.10은 `sm_70+` 만 지원 →
> CUDA 커널 부재 에러. `device="cpu"` 를 반드시 유지할 것.

## 호출 규약

### `POST /embed`

요청
```json
{"texts": ["안녕하세요", "실손의료비 보장 범위"], "normalize": true}
```
- `texts`: 문자열 배열. 1개 이상, 최대 256개(`ANU_EMBEDDING_MAX_BATCH`).
  각 텍스트는 **8000자로 truncate** (`ANU_EMBEDDING_MAX_CHARS`, 기존 파이프라인 관례).
- `normalize`: 생략 시 `true` (L2 정규화 → 코사인 유사도 = 내적).

응답
```json
{"model": "jhgan/ko-sroberta-multitask", "dimension": 768, "embeddings": [[768 floats], ...]}
```

상태 코드
| 코드 | 상황 |
|---|---|
| 200 | 정상 |
| 422 | 빈 `texts`, 공백 문자열, 비문자열 원소, 배치 초과 |
| 500 | **차원 불변식 위반** (768 아님 / 개수 불일치 / NaN·Inf) — 조용한 truncate·padding 없음 |
| 503 | 모델 미로딩 (요청 시점 로딩 폴백 없음) |

### `GET /health`
```json
{"status":"ok","model":"jhgan/ko-sroberta-multitask","dimension":768,"loaded":true,
 "device":"cpu","load_count":1,"loaded_at":1787218971.23,
 "max_text_chars":8000,"max_batch_size":256}
```
`load_count` 는 프로세스 생애주기 동안 모델이 로드된 횟수다. **항상 1이어야 한다**
(요청마다 재로딩이 발생하면 1보다 커진다 → 회귀 탐지 지표).

## 파이썬 클라이언트

```python
import sys; sys.path.insert(0, "/home/jay/workspace/services/embedding")
from client import embed_texts, embed_one, EmbeddingServiceError

vecs = embed_texts(["안녕하세요", "보험 상담"])   # list[list[float]] — 각 768
vec  = embed_one("실손의료비")                     # list[float] — 768
```
서비스가 떠 있지 않으면 **조용히 폴백하지 않고** `EmbeddingServiceError` 를 던진다.
표준 라이브러리(`urllib`)만 사용하므로 추가 의존성이 없다.

CLI 확인: `python3 client.py "임베딩할 문장"`

## 기동 방법

수동(포그라운드):
```bash
cd /home/jay/workspace/services/embedding
./start-embedding.sh
```

수동(백그라운드):
```bash
cd /home/jay/workspace/services/embedding
setsid nohup ./start-embedding.sh > /tmp/anu-embedding-8300.log 2>&1 < /dev/null &
```

systemd user 유닛 (설치는 팀장이 수행):
```bash
cp /home/jay/workspace/services/embedding/anu-embedding.service ~/.config/systemd/user/
systemctl --user daemon-reload
systemctl --user enable --now anu-embedding.service
systemctl --user status anu-embedding.service
```

기동 확인:
```bash
curl -s http://127.0.0.1:8300/health
curl -s -X POST http://127.0.0.1:8300/embed -H 'Content-Type: application/json' \
  -d '{"texts":["안녕하세요"],"normalize":true}' | python3 -c \
  'import json,sys; print(len(json.load(sys.stdin)["embeddings"][0]))'   # -> 768
```

## 설정 (환경변수, 하드코딩 금지)

모든 기본값은 `settings.py` 한 곳에만 정의된다.

| 환경변수 | 기본값 |
|---|---|
| `ANU_EMBEDDING_MODEL` | `jhgan/ko-sroberta-multitask` |
| `ANU_EMBEDDING_DIM` | `768` |
| `ANU_EMBEDDING_DEVICE` | `cpu` |
| `ANU_EMBEDDING_HOST` | `127.0.0.1` (루프백 전용. 인증 없음 → 외부 노출 금지) |
| `ANU_EMBEDDING_PORT` | `8300` |
| `ANU_EMBEDDING_URL` | `http://127.0.0.1:8300` (클라이언트) |
| `ANU_EMBEDDING_MAX_CHARS` | `8000` |
| `ANU_EMBEDDING_MAX_BATCH` | `256` |
| `ANU_EMBEDDING_TIMEOUT` | `120` (클라이언트, 초) |

`HF_HUB_OFFLINE=1` / `TRANSFORMERS_OFFLINE=1` 을 기동 스크립트와 유닛에서 설정하여
런타임 외부 네트워크 호출을 차단한다(캐시된 모델만 사용).

## 테스트

```bash
cd /home/jay/workspace/services/embedding
python3 -m pytest test_server.py -q
```
커버: 768차원 반환, 결정론성, 정규화, 빈/비문자열/배치초과 거부, 8000자 truncate,
차원 위반 fail-closed(384/767/769/1536), NaN·개수불일치 fail-closed, 503, health,
**모델 1회 로드 검증**.

## 파일

| 파일 | 역할 |
|---|---|
| `server.py` | FastAPI 서버 (모델 startup 1회 로드, 차원 불변식 강제) |
| `client.py` | 얇은 파이썬 클라이언트 (`embed_texts`/`embed_one`/`health`) |
| `settings.py` | 공용 설정 (환경변수 override) |
| `start-embedding.sh` | 기동 스크립트 |
| `anu-embedding.service` | systemd user 유닛 (설치는 팀장) |
| `test_server.py` | pytest |
| `requirements.txt` | 의존성 |
