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
190 changes: 190 additions & 0 deletions docs/kangcheolung/issue-100-ocr-llm-hybrid.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
# 이슈 #100 — OCR 약 추출: 정규식/좌표 파서 → LLM 하이브리드

> 브랜치: `feature/100` · 관련: [PIUDAProject/Backend#100](https://github.com/PIUDAProject/Backend/issues/100)
>
> 선행: #96(응답 저장·회귀 하네스), #99(파서 고도화). fixture 9종이 "정답"을 정의.

---

## 1. 문제 상황

OCR(Naver CLOVA)은 글자를 잘 읽는다. 문제는 **그 글자를 `{약이름, 1회량, 1일횟수, 총일수}` 구조로 바꾸는 파싱**이 서식마다 깨진다는 것.

### 파서(정규식 + 좌표)의 구조적 한계

`OcrParser`는 서식별 분기의 사다리다:

```
별표 약봉투(*약이름) → parseByPharmacyReceipt
라벨/압축 약봉투(1정씩2회5일분) → parseByTextSequential
보험코드 처방전(9자리+이름) → parseByPrescriptionCode (#99에서 추가)
표 처방전(명칭/투약량 헤더) → parseByCoordinates
그 외 → parseByRegex (단일 약)
```

#99까지 고쳐서 **알려진 서식 8종은 다 통과**하지만:

- 새 약국 POS 서식 하나가 나오면 → `if` 분기 추가 (밑 빠진 독)
- **뭉개진 약 이름을 교정 못 함** — `지스로먹스장`(OCR 오타)을 `지스로맥스정`으로 못 바꿈. 정규식은 OCR이 준 글자를 그대로 쓸 뿐
- 영수증처럼 상세 섹션 + 요약표가 섞인 서식에서 취약

### 목표

파싱 단계를 **LLM 추출**로 대체할 수 있는지 검증하고, 검증 결과에 따라 도입 방식을 정한다.
응답 스키마(`OcrResultResponse.parsedDrugs`)는 유지 → 프론트 영향 0.

---

## 2. 측정 — 파서 vs LLM

fixture 9종(실제 사진 5 + 합성 4)의 `rawText`/`fields`를 각 방식에 돌려 **약 단위 정확도**를 비교.
정답은 사람이 라벨링(`manifest.json`), 측정 코드는 `LlmDrugExtractorComparisonTest`(키 있을 때만 실행).

### 2-1. gpt-4o-mini + rawText (글자만)

| 서식 | 파서 | LLM |
|---|---|---|
| 표 처방전 | 4/4 | **숫자 뒤죽박죽** — `교부일로부터 7일`을 총투약일수 7로 오인 |
| 약봉투 | 4/4 · 9/9 | 개수 맞음, 이름·용량 형식 다름 |

→ rawText만 주면 표에서 숫자 위치 정보가 없어 컬럼 매핑 실패.

### 2-2. gpt-4o-mini + 좌표 (`텍스트 @(x,y)` 목록)

| 서식 | 결과 |
|---|---|
| 표 처방전 | **여전히 숫자 틀림** (`250mg`을 용량으로) |
| scattered 영수증 | 8약을 13개로 과추출 (성분명 분리) |

→ gpt-4o-mini는 좌표 리스트로 공간 추론을 못 함.

### 2-3. gpt-4o + 좌표 + **프롬프트 개선**

프롬프트 1차는 "제형어 빼라"고 해서 `아모잘탄정`→`아모잘탄` 처럼 접미사까지 제거됨.
→ "제형 접미사는 이름의 일부, 절대 떼지 마라 / 용량 표기·괄호 성분명만 제거 / 성분명만 있는 줄은 약 아님" 으로 수정.

| fixture | 파서 | gpt-4o |
|---|---|---|
| 별표 영수증 | 4/4 | 4/4 |
| 합성 표 처방전 | 5/5 | 5/5 |
| 압축 약봉투(합성) | 4/4 | 4/4 |
| 별표 약봉투(실제) | 4/4 | 3/4 (`코푸시럽` 용량 `1포` vs `1ml`) |
| **압축 약봉투(실제, 9약)** | 9/9 | **9/9** |
| **저화질 표 처방전** | **4/4** | **0/4** (숫자 매핑 실패) |
| **grid 영수증** | 4/5 | **5/5** — 파서가 놓친 약을 잡음 |
| scattered 영수증 | 3/8 | 이름 8/8 (횟수는 오독) |

---

## 3. 결정 — 하이브리드

측정이 말하는 것:

| 서식 | 이긴 쪽 | 이유 |
|---|---|---|
| 병원 처방전 (표 서식) | **파서** | 좌표를 알고리즘으로 정확히 계산. LLM은 좌표 텍스트로 표를 못 읽음 |
| 약봉투 | 무승부 | 파서 이미 정확(무료·즉시), LLM도 gpt-4o면 거의 동급 |
| 영수증 / 미지원 서식 | **LLM** | 파서가 놓치는 약을 잡고 이름이 깨끗 |

→ **"LLM으로 전면 전환"도 "파서 유지"도 아닌 하이브리드**:

```
OCR → 병원 처방전인가? (isPrescription)
├─ YES → 파서 (좌표 알고리즘)
└─ NO → 약봉투/영수증/그 외 → gpt-4o (좌표 텍스트 → 약 JSON)
↓ LLM 실패·약 0개
파서 폴백
```

### 왜 이 신호인가 — 서식별 고유 문구

약봉투·처방전·영수증은 발행처(약국/병원/약국)가 달라 고유 문구가 겹치지 않는다:

| 서식 | 고유 문구 |
|---|---|
| **처방전** | "처방전" 제목 / "처방 의약품" / "교부번호"·"교부일" / 보험코드 줄(`\d{8,10}\s+약이름`) |
| **약봉투** | "복약안내" / `*약이름` 별표 / "N정씩N회N일분" |
| **영수증** | "약제비" / "계산서" / "본인부담금" |

```java
isPrescription = 처방전 신호 있음 AND 약봉투 신호 없음 AND 영수증 신호 없음
```

- 뉴스 처방전 → "처 방 전" + "교부일" + 보험코드 → 파서 ✅
- 향촌 영수증 → "처방전교부번호" 있지만 `1캡슐씩2회7일분`(약봉투 신호) → LLM ✅
- 필독 영수증 → "약제비 계산서" → LLM ✅
- 약봉투(별표/압축) → 별표/압축 신호 → LLM ✅

보험코드 줄 하나만 보면 코드를 인쇄 안 하는 병원 EMR에서 처방전을 놓친다. 그래서 "처방전" 제목·"교부" 등 여러 신호를 OR로 묶고, 약봉투·영수증 신호로 배제한다.

---

## 4. 구현

### 신규

| 파일 | 역할 |
|---|---|
| `service/DrugExtractor` | `List<ParsedOcrData> extract(fields)`. 실패 시 예외 대신 빈 리스트(폴백 신호) |
| `service/LlmDrugExtractor` | `DrugExtractor` 구현. 키 미설정·API 오류 → 빈 리스트. sanity check(횟수 1~6, 일수 1~90 벗어나면 null) |
| `client/OpenAiClient` | fields → `"텍스트 @(x,y)"` 좌표 목록 → Chat Completions(`gpt-4o`, `temperature 0`, `json_object`) → `{"drugs":[...]}` |

### 수정

| 파일 | 변경 |
|---|---|
| `OcrParser` | `isPrescription(fields)` public 추가 (서식별 고유 문구로 처방전 판정) |
| `OcrCommandService` | 하이브리드 라우팅 + `usedLlm`/`method` 로깅 |
| `application.yml` | `openai.api-key/base-url/model` (`OPENAI_API_KEY` 없으면 LLM 자동 비활성 → 파서 전용) |

### 프롬프트 핵심 (`OpenAiClient.SYSTEM_PROMPT`)

- 입력은 `텍스트 @(x,y)` 목록, y 차이 15 이내면 같은 행
- 약 이름 오른쪽 같은 행의 한 자리 숫자를 x순으로 [투약량, 횟수, 일수]
- 제형 접미사(정·캡슐·서방정)는 이름의 일부 — **떼지 마라**
- 용량 표기(500mg)·괄호 성분명·제형만 나타내는 단어(코팅정)·성분명만 있는 줄은 제외
- "교부일로부터 N일"은 총투약일수 아님

### 응답/스키마

`OcrResultResponse.parsedDrugs` 형식 불변. `ocr_result`엔 여전히 첫 약만 저장(3-3 스키마).

---

## 5. 측정 재현

```bash
# 파서 / LLM / 하이브리드 exactR 표 (키 없으면 파서 열만)
OPENAI_API_KEY=sk-... RUNS=3 python3 scripts/ocr-bench.py
```

```
fixture 라우팅 파서 LLM 하이브리드
pharmacy_receipt_starred.json LLM 1.00 1.00 1.00
table_prescription_synth.json 파서(처방전) 1.00 1.00 1.00
drug_bag_compact_real.json LLM 1.00 1.00 1.00
table_prescription_real.json 파서(처방전) 1.00 0.17 1.00
pharmacy_receipt_grid_real.json LLM 0.80 1.00 1.00
...
평균 0.90 0.68 0.78
```

- `table_prescription_real` LLM 0.17 → **파서로 라우팅**돼 하이브리드 1.00
- `pharmacy_receipt_grid_real` 파서 0.80 → **LLM으로 라우팅**돼 1.00
- `scattered` 하이브리드 0.00 = metric 한계 (정답이 전부 null인데 LLM이 이름 8/8 채움)
- 파서 평균이 높은 건 정답을 파서 출력 기준으로 라벨링해서. LLM의 "다른 형식"(용량 표기 포함)이 불일치로 잡힘

단위 테스트(키 불필요, 결정론적):
```bash
./gradlew test --tests "*OcrCommandServiceTest" --tests "*OcrParserRegressionTest"
```

---

## 6. 알려진 한계 · 후속

- **LLM 비결정성**: `temperature 0`이어도 gpt-4o가 run마다 미세하게 다른 출력(이름에 용량이 붙었다 안 붙었다). 결정론적인 파서와 다른 성질. 사용자 확인 UI가 최종 방어선이지만 "같은 사진 다른 결과" 리포트 가능성.
- **비용**: gpt-4o 호출당 약 15~20원. 약봉투·영수증이 LLM 경로라 자주 호출됨. 볼륨 커지면 gpt-4o-mini + 프롬프트 강화 또는 캐싱 검토.
- **이름 정규화 미완**: `지스로먹스장` → `지스로맥스정` 은 gpt-4o도 확실히 못 함. ES(`druginfo`) 대조로 교정 = 다음 이슈. 단 `druginfo` 4,745건이라 커버리지 제한적.
- **저화질 표 처방전**: 좌표가 흩어지면 파서도 흔들림(grid 영수증 4/5). 이미지→비전 LLM은 별도 검토.
- `ocr_result`에 첫 약만 저장 → 다중 약 이력 연결 안 됨 (스키마 개편 별도).
180 changes: 180 additions & 0 deletions scripts/ocr-bench.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
#!/usr/bin/env python3
"""
OCR 약 추출 벤치 — fixture 9종에서 파서 / LLM(gpt-4o) / 하이브리드 정확도를 표로 출력한다.

- 파서 결과: OcrParserRegressionTest(Java, 결정론적)를 한 번 돌려 그 리포트에서 뽑는다.
- LLM 결과: 이 스크립트가 직접 OpenAI를 호출한다(좌표 텍스트 방식, OpenAiClient와 동일).
- 하이브리드: isPrescription 라우팅(서식별 고유 문구)을 여기서 재현한다.

사용법:
OPENAI_API_KEY=sk-... python3 scripts/ocr-bench.py
OPENAI_API_KEY=sk-... OPENAI_MODEL=gpt-4o-mini python3 scripts/ocr-bench.py
python3 scripts/ocr-bench.py # 키 없으면 파서 열만

exactR = 정답(manifest)과 4개 필드(이름·1회량·1일횟수·총일수)가 완전히 일치한 약의 비율.
"""
import json
import os
import re
import subprocess
import sys
import urllib.request

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
FIX = f"{ROOT}/src/test/resources/ocr/fixtures"
MANIFEST = f"{ROOT}/src/test/resources/ocr/expected/manifest.json"
MODEL = os.environ.get("OPENAI_MODEL", "gpt-4o")

API_KEY = os.environ.get("OPENAI_API_KEY")
if not API_KEY and os.path.exists(f"{ROOT}/.env"):
for line in open(f"{ROOT}/.env"):
if line.startswith("OPENAI_API_KEY="):
API_KEY = line.split("=", 1)[1].strip()

SYSTEM_PROMPT = """너는 한국 병원 처방전·약국 약봉투의 OCR 결과에서 복용할 약 목록을 뽑는 도우미다.
입력은 "텍스트 @(x,y)" 줄 목록이다. y 차이가 15 이내면 같은 행.
약 이름 오른쪽 같은 행의 한 자리 숫자를 x 작은 순서대로 [1회 투약량, 1일 투여횟수, 총 투약일수].
"1정씩2회5일분" 압축 표기는 그대로 파싱.
drugName: 제형 접미사(정/캡슐/서방정)는 이름의 일부 - 절대 떼지 마라. 용량 표기(mg)·괄호 성분명·제형만 나타내는 단어는 빼라. 성분명만 있는 줄은 약 아님.
dosagePerTime: 단위 포함 "1정"/"0.5정"/"5ml", 모르면 null. timesPerDay: 정수, 모르면 null.
totalDays: 정수. "교부일로부터 N일"은 총투약일수 아님. 모르면 null.
제외: 주의사항, 병원·약국명, 이름, 금액, 날짜, 보험코드(8~10자리).
아래 JSON 형식으로만: {"drugs":[{"drugName":"...","dosagePerTime":"...","timesPerDay":0,"totalDays":0}]}"""


def raw_text(fields):
return "".join(f["inferText"] + ("\n" if f.get("lineBreak") else " ") for f in fields).strip()


def coord_text(fields):
lines = []
for f in fields:
if not f.get("inferText", "").strip():
continue
vs = (f.get("boundingPoly") or {}).get("vertices", [])
cx = int(sum(v["x"] for v in vs) / len(vs)) if vs else 0
cy = int(sum(v["y"] for v in vs) / len(vs)) if vs else 0
lines.append((cy, cx, f["inferText"].strip()))
lines.sort()
return "\n".join(f"{t} @({x},{y})" for y, x, t in lines)


_DRUG_SUFFIX = r"(?:정|캡슐|캅셀|시럽|액|연고|크림|주사|산|패치)"


def is_prescription(t):
code = len(re.findall(r"\d{8,10}\s+[가-힣A-Za-z]", t)) >= 2
compact = len(re.findall(r"\d+(?:\.\d+)?\s*(?:정|캡슐|캅셀|ml|mg|g|포)\s*씩\s*\d+\s*회\s*\d+\s*일분", t)) >= 2
# 약봉투의 *약이름 — 별표 뒤 단어가 약 접미사로 끝나야 함 (Java STARRED_DRUG_NAME_PATTERN과 동일)
star = re.search(r"\*[가-힣a-zA-Z][가-힣a-zA-Z0-9]*" + _DRUG_SUFFIX, t) is not None
rx = code or any(k in t for k in ("처방전", "처방 의약품", "처방의약품", "교부번호", "교부일"))
bag = ("복약안내" in t) or ("약봉투" in t) or star or compact
receipt = any(k in t for k in ("약제비", "계산서", "본인부담금"))
return rx and not bag and not receipt


def call_llm(fields):
body = json.dumps({
"model": MODEL, "temperature": 0,
"response_format": {"type": "json_object"},
"messages": [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": coord_text(fields)},
],
}).encode()
req = urllib.request.Request(
"https://api.openai.com/v1/chat/completions", data=body,
headers={"Authorization": f"Bearer {API_KEY}", "Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=30) as r:
content = json.loads(r.read())["choices"][0]["message"]["content"]
drugs = json.loads(content).get("drugs", [])
out = []
for d in drugs:
name = (d.get("drugName") or "").strip()
if not name:
continue
tpd = d.get("timesPerDay")
tot = d.get("totalDays")
out.append((
name,
(d.get("dosagePerTime") or None),
tpd if isinstance(tpd, int) and 1 <= tpd <= 6 else None,
tot if isinstance(tot, int) and 1 <= tot <= 90 else None,
))
return out


def norm(d):
return (d["drugName"], d.get("dosagePerTime"), d.get("timesPerDay"), d.get("totalDays"))


def recall(expected, actual):
if not expected:
return 0.0
aset = set(actual)
hit = sum(1 for e in expected if norm(e) in aset)
return hit / len(expected)


def parser_recalls():
"""OcrParserRegressionTest를 돌려 fixture별 exact R을 뽑는다."""
subprocess.run(["./gradlew", "test", "--tests", "*OcrParserRegressionTest", "--rerun", "-q"],
cwd=ROOT, capture_output=True)
res = {}
for fn in os.listdir(f"{ROOT}/build/test-results/test"):
if "OcrParserRegressionTest" not in fn:
continue
s = open(f"{ROOT}/build/test-results/test/{fn}").read()
for cdata in re.findall(r"<system-out><!\[CDATA\[(.*?)\]\]></system-out>", s, re.S):
for m in re.findall(r"\[([\w.]+\.json)\] exact P=[\d.]+ R=([\d.]+)", cdata):
res[m[0]] = float(m[1])
return res


def main():
runs = int(os.environ.get("RUNS", "1"))
manifest = json.load(open(MANIFEST))["fixtures"]
fixtures = [fc for fc in manifest if os.path.exists(f"{FIX}/{fc['file']}")]
print(f"fixture {len(fixtures)}종 · LLM={MODEL} · {runs} run 평균" if API_KEY else f"fixture {len(fixtures)}종 · 파서만 (키 없음)")

p_rec = parser_recalls()

W = 38
print(f"\n{'fixture':<{W}}{'라우팅':<11}{'파서':>7}{'LLM':>7}{'하이브리드':>10}")
print("-" * (W + 35))
sp = sl = sh = 0.0
for fc in fixtures:
fields = json.load(open(f"{FIX}/{fc['file']}"))["images"][0]["fields"]
exp = fc["expected"]
route_parser = is_prescription(raw_text(fields))
p = p_rec.get(fc["file"], float("nan"))

if not API_KEY:
route = "파서(처방전)" if route_parser else "LLM"
print(f"{fc['file']:<{W}}{route:<11}{p:>7.2f}{'—':>7}{'—':>9}")
continue

ls = []
for _ in range(runs):
try:
llm = call_llm(fields)
except Exception as e:
print(f" ! {fc['file']} LLM 오류: {e}", file=sys.stderr)
llm = []
ls.append((recall(exp, llm), bool(llm)))
l = sum(x[0] for x in ls) / runs
any_drug = any(x[1] for x in ls)
hybrid_val = p if route_parser else (l if any_drug else p)
route = "파서(처방전)" if route_parser else ("LLM" if any_drug else "파서(폴백)")
print(f"{fc['file']:<{W}}{route:<11}{p:>7.2f}{l:>7.2f}{hybrid_val:>9.2f}")
sp += p; sl += l; sh += hybrid_val

if API_KEY:
n = len(fixtures)
print("-" * (W + 35))
print(f"{'평균':<{W}}{'':<11}{sp/n:>7.2f}{sl/n:>7.2f}{sh/n:>9.2f}")
print("\nexactR = 정답과 4개 필드 완전 일치 비율. LLM은 temperature 0이어도 run마다 소폭 변동 (RUNS=3 로 평균 권장).")


if __name__ == "__main__":
main()
Loading