
Claude Vision으로 계약서 양식을 자동 분석하기
요약
Claude Vision 기능을 활용하여 스캔된 계약서 이미지에서 데이터를 추출하고 구조화된 JSON 데이터로 변환하는 파이프라인 구현 방법을 소개합니다. Python의 Pydantic 라이브러리를 사용하여 추출된 데이터의 유효성을 검증하고 안정적인 데이터 처리를 구현하는 과정을 다룹니다.
핵심 포인트
- Claude Vision API를 이용한 이미지-to-JSON 변환 파이프라인 구축
- Pydantic 모델을 활용한 추출 데이터의 스키마 정의 및 유효성 검증
- Pillow를 이용한 이미지 전처리 및 EXIF 회전 정보 보정 방법
- 프롬프트 엔지니어링을 통한 안정적인 JSON 출력 유도 기법
종이 계약서를 스캔하여 수동으로 입력하는 흐름은 오기입(transcription error)이 발생하기 쉬울 뿐만 아니라 단순 작업으로서 시간을 많이 잡아먹는다. Claude의 Vision 기능을 사용하면, 스캔 이미지로부터 기입된 필드를 읽어 들여 그대로 구조화 데이터(structured data)로 출력할 수 있다.
이 기사에서는 Python에서 Claude API를 호출하여 「이미지 → JSON」 변환 파이 파이프라인(pipeline)을 구동하는 것까지 구현한다. 최종 출력은 pydantic 모델로 검증(validation)을 거친 딕셔너리(dictionary)로 하여, 후속 DB 쓰기나 CSV 생성에 그대로 전달할 수 있는 형태로 만들었다.
아키텍처
입력은 JPEG 또는 PNG 스캔 이미지이다. Pillow로 전처리를 한 후 base64로 인코딩하여 Claude Vision API에 전달한다. 응답은 자연어 텍스트이지만, JSON만 출력하도록 지시함으로써 pydantic 모델에 담을 수 있다.
구현
1. 환경 준비
pip install anthropic pydantic Pillow
export ANTHROPIC_API_KEY="your-api-key"
requirements.txt로 관리하는 경우:
anthropic>=0.40.0
pydantic>=2.0.0
Pillow>=10.0.0
2. pydantic 모델 정의
먼저 스키마(schema)를 결정한다. confidence_note를 선택 사항(optional)으로 둠으로써, Claude가 "이 필드는 판독이 불확실함"이라고 주석을 달 수 있는 여지를 만들었다.
from pydantic import BaseModel
class ContractFields(BaseModel):
fields: dict[str, str | None]
...
3. 이미지 전처리
스마트폰으로 촬영한 스캔 이미지는 EXIF에 회전 정보가 들어있지만, PIL로 그대로 열면 방향이 어긋난다. _getexif()로 orientation 태그를 포착하여 회전을 보정한다.
import io
import base64
from pathlib import Path
...
최대 사이즈를 2048px로 설정한 이유는, 그 이상 크게 해도 Claude의 정밀도는 거의 변하지 않고 API 비용만 상승했기 때문이다.
4. 프롬프트 설계
「빈칸을 채우는 것」이 아니라 「기입된 값을 읽어내는 것」임을 시스템 프롬프트(system prompt)로 명확히 하는 점이 포인트였다. 처음에는 user 메시지만으로 지시했으나, JSON만 반환한다는 제약을 system으로 옮기니 안정화되었다.
def build_prompt(field_names: list[str]) -> str:
fields_list = "\n".join(f"- {name}" for name in field_names)
return (
...
tool_use로 JSON 스키마를 강제하는 방법도 시도해 보았으나, Vision API와의 조합에서 레이턴시(latency)가 늘어난 것에 비해 에러율(error rate)은 변하지 않았다. 프롬프트로 JSON만이라고 지시하는 방식으로 거의 안정적으로 동작한다.
5. Claude API 호출 및 pydantic 검증
import json
import anthropic
from pydantic import ValidationError
...
```json ... ``` 블록 기법을 제거하는""
raw = raw.strip()
if raw.startswith("```"):
lines = raw.split("\n")
inner, in_block = [], False
for line in lines:
if line.startswith("```") and not in_block:
in_block = True
continue
if line.startswith("```") and in_block:
break
if in_block:
inner.append(line)
raw = "\n".join(inner)
return raw.strip()
def extract_fields(image_path: Path, field_names: list[str]) -> ContractFields:
client = anthropic.Anthropic()
image_bytes, mime_type = preprocess_image(image_path)
b64_image = base64.standard_b64encode(image_bytes).decode()
response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=1024,
system=(
"あなたは文書解析の専門家です。"
"契約書フォームの画像から記入済みフィールドの値を正確に読み取ります。"
),
messages=[{
"role": "user",
"content": [
{
"type": "image",
"source": {
"type": "base64",
"media_type": mime_type,
"data": b64_image,
},
},
{"type": "text", "text": build_prompt(field_names)},
],
}
],
)
raw_text = response.content[0].text
clean_text = clean_json_response(raw_text)
try:
parsed = json.loads(clean_text)
except json.JSONDecodeError as e:
raise ValueError(f"JSON パース失敗: {e}\n\n生テキスト:\n{raw_text}") from e
return ContractFields.model_validate(parsed)
バリデーションエラーが出た場合は呼び出し元でキャッチして別ファイルに退避する設計にしている。型エラーが残る行を「要確認リスト」として保持しておくと、後から人手でチェックする際に絞り込みやすい。
import argparse
import sys
DEFAULT_FIELDS = [
"성명", "주소", "전화번호", "이메일 주소",
"계약일", "계약 기간", "금액", "서명",
]
def main() -> None:
parser = argparse.ArgumentParser(
description="Claude Vision으로 계약서 양식을 분석하여 JSON으로 출력한다"
)
parser.add_argument("image", type=Path, help="계약서 이미지 파일 (JPEG / PNG)")
parser.add_argument(
"--fields", type=Path, default=None,
help="필드명 리스트를 가진 JSON 파일 (생략 시 기본 8개 항목)"
)
parser.add_argument(
"--output", type=Path, default=None,
help="결과를 저장할 JSON 파일 경로 (생략 시 표준 출력)"
)
args = parser.parse_args()
if not args.image.exists():
print(f"❌ 이미지 파일을 찾을 수 없습니다: {args.image}", file=sys.stderr)
sys.exit(1)
field_names = DEFAULT_FIELDS
if args.fields:
with open(args.fields, encoding="utf-8") as f:
field_names = json.load(f)
print(f"🔍 분석 중: {args.image.name} ({len(field_names)} 필드)", file=sys.stderr)
try:
result = extract_fields(args.image, field_names)
except (ValueError, ValidationError) as e:
print(f"❌ 에러: {e}", file=sys.stderr)
sys.exit(1)
output_data = result.model_dump()
if args.output:
args.output.parent.mkdir(parents=True, exist_ok=True)
with open(args.output, "w", encoding="utf-8") as f:
json.dump(output_data, f, ensure_ascii=False, indent=2)
print(f"✅ 결과 저장 완료: {args.output}", file=sys.stderr)
else:
print(json.dumps(output_data, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()
실행 예시
python contract_ocr.py scan.jpg
{
"fields": {
"성명": "야마다 타로",
"주소": "도쿄도 시부야구 ○○초 1-2-3",
"전화번호": "03-0000-0000",
"이메일 주소": "yamada@example.com",
"계약일": "2026년 5월 18일",
"계약 기간": "2026년 6월 1일〜2027년 5월 31일",
"금액": "월액 50,000엔 (세금 포함)",
"서명": "야마다 타로"
},
"confidence_note": null
}
필드 정의를 JSON 파일로 전달하면, 임의의 계약서 포맷에 대응할 수 있다.
["회사명", "담당자명", "발주 번호", "납기", "발주 금액 (세금 별도)"]
python contract_ocr.py purchase_order.jpg --fields my_fields.json --output result.json
데이터 분석가 관점
OCR로 추출한 문자열을 구조화하는 프로세스는, SQL 집계 전에 로우 로그 (raw log)를 정규화하는 ETL 공정과 구조가 동일하다. 어떤 필드가 신뢰할 수 있는지, 어떤 필드는 수동 확인이 필요한지를 Pydantic 스키마 단계에서 결정해 두지 않으면, 후속 쿼리에서 매번 CASE WHEN
이 늘어날 것이다. 유효성 검사 오류 (Validation Error)를 그대로 "확인 필요 플래그"로서 DB에 유지하는 설계는, 데이터 기반 (Data Infrastructure)의 NULL 처리 정책 그 자체다.
Claude의 confidence_note 필드도 같은 발상으로 마련했다. 사람이 나중에 읽더라도 "어떤 필드가 불확실했는지"를 추적할 수 있도록 해두는 것이다. 정밀도를 높이는 것과 정밀도의 경계를 명시하는 것은 별개의 작업이다.
Discussion

AI 자동 생성 콘텐츠
본 콘텐츠는 Zenn AI의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기