upstage_ocr.py (8개 엔드포인트): - /api/ocr/config: API Key 설정 (AES-256-GCM 암호화) - /api/ocr/parse: 문서 파싱 (PDF/이미지 → 구조화 JSON) - /api/ocr/extract: 정보 추출 (Key-Value, 스키마 기반) - /api/ocr/qa: 문서 QA (자연어 질의) - /api/ocr/batch: 다중 파일 배치 - /api/ocr/history: 처리 이력 - /api/ocr/usage: API 사용량 doc_workflow.py (9개 엔드포인트 — 7종 워크플로우): - /api/docflow/contract: 나라장터 계약서 → 조달 자동 등록 - /api/docflow/server-spec: 납품서 → CMDB 자동 등록 - /api/docflow/invoice: 청구서 → 과금 연동 - /api/docflow/audit-report: CSAP 보고서 → 준수율 - /api/docflow/incident-report: 장애보고서 → SR 자동 생성 - /api/docflow/meeting-minutes: 회의록 → 액션아이템 SR - /api/docflow/brand-contract: 현대백화점 등 브랜드 계약서 doc_template.py (5개 엔드포인트): - 내장 7종 템플릿 (나라장터/납품서/브랜드계약/청구서/장애/CSAP/회의록) - 커스텀 템플릿 CRUD DB 모델 (4개): UpstageOCRConfig, OCRHistory, DocWorkflowJob, DocTemplate ITSM 사이드바: '문서 AI (OCR)' 그룹 추가 (9개 메뉴) 민감 정보 자동 마스킹 (주민번호/카드번호/전화번호) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
342 lines
12 KiB
Python
342 lines
12 KiB
Python
"""
|
||
문서 추출 템플릿 관리
|
||
|
||
내장 7종 + 커스텀 템플릿 CRUD.
|
||
|
||
엔드포인트:
|
||
GET /api/doctemplate/ — 템플릿 목록
|
||
POST /api/doctemplate/ — 커스텀 템플릿 생성
|
||
GET /api/doctemplate/{id} — 템플릿 상세
|
||
PUT /api/doctemplate/{id} — 수정
|
||
DELETE /api/doctemplate/{id} — 삭제
|
||
GET /api/doctemplate/builtin — 내장 템플릿 목록
|
||
POST /api/doctemplate/apply-builtin — 내장 템플릿 테넌트 적용
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
from datetime import datetime
|
||
from typing import Optional
|
||
|
||
from fastapi import APIRouter, Depends, HTTPException
|
||
from pydantic import BaseModel, Field
|
||
from sqlalchemy import select
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from core.auth import get_current_user, require_admin_role
|
||
from database import get_db
|
||
from models import User, DocTemplate
|
||
|
||
router = APIRouter(prefix="/api/doctemplate", tags=["문서 템플릿"])
|
||
|
||
BUILTIN_TEMPLATES = {
|
||
"narasajang_contract": {
|
||
"name": "나라장터 계약서",
|
||
"description": "조달청 나라장터 계약서에서 계약정보를 자동 추출",
|
||
"workflow": "contract",
|
||
"schema": {
|
||
"contract_no": "계약번호",
|
||
"contract_name": "계약품명/서비스명",
|
||
"supplier": "공급사명",
|
||
"supplier_biz_no":"공급사 사업자번호",
|
||
"amount": "계약금액(원)",
|
||
"vat": "부가세액",
|
||
"start_date": "계약시작일(YYYY-MM-DD)",
|
||
"end_date": "계약종료일(YYYY-MM-DD)",
|
||
"institution": "발주기관명",
|
||
"manager": "담당자명",
|
||
"payment_terms": "납부/지급 조건",
|
||
}
|
||
},
|
||
"server_delivery": {
|
||
"name": "서버 납품 명세서",
|
||
"description": "서버·장비 납품명세서에서 사양을 추출하여 CMDB에 자동 등록",
|
||
"workflow": "server_spec",
|
||
"schema": {
|
||
"hostname": "호스트명/서버명",
|
||
"manufacturer": "제조사",
|
||
"model_no": "모델번호",
|
||
"serial_no": "시리얼번호",
|
||
"cpu_model": "CPU 모델명",
|
||
"cpu_cores": "CPU 코어 수",
|
||
"memory_gb": "메모리 용량(GB)",
|
||
"disk_config": "스토리지 구성(예: SSD 1TB×2)",
|
||
"os": "운영체제",
|
||
"ip_addr": "IP주소",
|
||
"rack_location": "랙/위치",
|
||
"warranty_until": "보증기간 만료일",
|
||
"delivery_date": "납품일",
|
||
}
|
||
},
|
||
"brand_contract": {
|
||
"name": "기업 브랜드 계약서",
|
||
"description": "현대백화점·롯데 등 유통/브랜드 계약서 자동 처리",
|
||
"workflow": "brand_contract",
|
||
"schema": {
|
||
"contract_title": "계약서 제목",
|
||
"party_a": "갑(발주사/브랜드사)",
|
||
"party_a_biz_no": "갑 사업자번호",
|
||
"party_b": "을(수주사/입점사)",
|
||
"party_b_biz_no": "을 사업자번호",
|
||
"contract_amount": "계약금액",
|
||
"currency": "통화(KRW/USD/기타)",
|
||
"effective_date": "계약체결일",
|
||
"expiry_date": "계약만료일",
|
||
"auto_renewal": "자동갱신여부(Y/N)",
|
||
"payment_terms": "대금 지급조건",
|
||
"contract_items": "계약 품목/서비스",
|
||
"royalty_rate": "수수료율/로열티율",
|
||
"territory": "적용지역/매장",
|
||
"exclusive": "독점여부(Y/N)",
|
||
"termination": "계약 해지 조건",
|
||
"penalty_clause": "위약금 조항",
|
||
"contact_a": "갑 담당자",
|
||
"contact_b": "을 담당자",
|
||
"special_terms": "특약사항",
|
||
}
|
||
},
|
||
"invoice": {
|
||
"name": "세금계산서/청구서",
|
||
"description": "세금계산서·청구서에서 금액·공급자 정보 자동 추출",
|
||
"workflow": "invoice",
|
||
"schema": {
|
||
"invoice_no": "세금계산서번호/청구번호",
|
||
"issue_date": "발행일",
|
||
"supplier_name": "공급자 상호",
|
||
"supplier_biz_no": "공급자 사업자번호",
|
||
"buyer_name": "공급받는자 상호",
|
||
"buyer_biz_no": "공급받는자 사업자번호",
|
||
"supply_amount": "공급가액",
|
||
"vat_amount": "세액",
|
||
"total_amount": "합계금액",
|
||
"items": "품목/내역(쉼표 구분)",
|
||
"payment_due": "결제기한",
|
||
}
|
||
},
|
||
"incident_report": {
|
||
"name": "장애 보고서",
|
||
"description": "장애보고서 이미지/PDF에서 에러 내용 추출 → SR 자동 생성",
|
||
"workflow": "incident_report",
|
||
"schema": {
|
||
"incident_date": "발생일시",
|
||
"incident_type": "장애유형(H/W·S/W·네트워크·기타)",
|
||
"affected_system": "영향 시스템/서비스",
|
||
"error_message": "오류 메시지/에러코드",
|
||
"root_cause": "근본원인",
|
||
"impact_scope": "영향 범위(사용자 수/서비스)",
|
||
"resolution": "조치사항",
|
||
"downtime_minutes": "다운타임(분)",
|
||
"reporter": "보고자/담당자",
|
||
"severity": "심각도(P1/P2/P3/P4)",
|
||
}
|
||
},
|
||
"csap_report": {
|
||
"name": "CSAP/ISMS 점검 보고서",
|
||
"description": "공공기관 보안 점검 보고서 자동 분석 → CSAP 준수율 업데이트",
|
||
"workflow": "audit_report",
|
||
"schema": {
|
||
"institution": "기관명",
|
||
"check_date": "점검일",
|
||
"auditor": "점검자/감사기관",
|
||
"total_items": "총 점검항목 수",
|
||
"passed_items": "적합(통과) 항목 수",
|
||
"failed_items": "부적합 항목 수",
|
||
"na_items": "해당없음 항목 수",
|
||
"compliance_rate": "준수율(%)",
|
||
"major_findings": "주요 발견사항",
|
||
"recommendations": "권고사항",
|
||
"next_check_date": "차기 점검 예정일",
|
||
}
|
||
},
|
||
"meeting_minutes": {
|
||
"name": "회의록",
|
||
"description": "회의록에서 결정사항·액션아이템 자동 추출 → SR/작업 생성",
|
||
"workflow": "meeting_minutes",
|
||
"schema": {
|
||
"meeting_date": "회의일시",
|
||
"meeting_place": "장소(오프라인/화상)",
|
||
"chairman": "의장/주관자",
|
||
"participants": "참석자 목록",
|
||
"agenda": "회의 안건",
|
||
"decisions": "결정사항(쉼표 구분)",
|
||
"action_items": "액션아이템(담당자/기한 포함)",
|
||
"next_meeting": "차기 회의 일정",
|
||
"notes": "기타 특이사항",
|
||
}
|
||
},
|
||
}
|
||
|
||
|
||
class TemplateCreate(BaseModel):
|
||
name: str = Field(..., max_length=200)
|
||
description: Optional[str] = None
|
||
schema_json: dict = Field(..., description="추출 스키마 {필드명: 설명}")
|
||
workflow: Optional[str] = Field(None, description="연동 워크플로우")
|
||
|
||
|
||
class ApplyBuiltinRequest(BaseModel):
|
||
template_keys: list[str]
|
||
|
||
|
||
@router.get("/builtin")
|
||
async def list_builtin_templates(_: User = Depends(get_current_user)):
|
||
return [
|
||
{
|
||
"key": k,
|
||
"name": v["name"],
|
||
"description": v["description"],
|
||
"workflow": v["workflow"],
|
||
"field_count": len(v["schema"]),
|
||
"fields": list(v["schema"].keys()),
|
||
}
|
||
for k, v in BUILTIN_TEMPLATES.items()
|
||
]
|
||
|
||
|
||
@router.post("/apply-builtin")
|
||
async def apply_builtin_templates(
|
||
req: ApplyBuiltinRequest,
|
||
db: AsyncSession = Depends(get_db),
|
||
user: User = Depends(require_admin_role),
|
||
):
|
||
"""내장 템플릿을 현재 테넌트에 적용."""
|
||
created = []
|
||
for key in req.template_keys:
|
||
tpl = BUILTIN_TEMPLATES.get(key)
|
||
if not tpl:
|
||
continue
|
||
existing = await db.execute(
|
||
select(DocTemplate).where(
|
||
DocTemplate.tenant_id == user.tenant_id,
|
||
DocTemplate.builtin_key == key,
|
||
)
|
||
)
|
||
if existing.scalar_one_or_none():
|
||
continue
|
||
tmpl = DocTemplate(
|
||
tenant_id=user.tenant_id,
|
||
name=tpl["name"],
|
||
description=tpl["description"],
|
||
schema_json=json.dumps(tpl["schema"], ensure_ascii=False),
|
||
workflow=tpl["workflow"],
|
||
builtin_key=key,
|
||
is_builtin=True,
|
||
is_active=True,
|
||
created_at=datetime.utcnow(),
|
||
)
|
||
db.add(tmpl)
|
||
created.append(tpl["name"])
|
||
await db.commit()
|
||
return {"ok": True, "created": created, "count": len(created)}
|
||
|
||
|
||
@router.get("/")
|
||
async def list_templates(
|
||
db: AsyncSession = Depends(get_db),
|
||
user: User = Depends(get_current_user),
|
||
):
|
||
rows = await db.execute(
|
||
select(DocTemplate).where(
|
||
DocTemplate.tenant_id == user.tenant_id,
|
||
DocTemplate.is_active == True,
|
||
).order_by(DocTemplate.is_builtin.desc(), DocTemplate.name)
|
||
)
|
||
templates = rows.scalars().all()
|
||
return [
|
||
{
|
||
"id": t.id, "name": t.name, "description": t.description,
|
||
"workflow": t.workflow, "is_builtin": t.is_builtin,
|
||
"field_count": len(json.loads(t.schema_json or "{}")),
|
||
"created_at": t.created_at,
|
||
}
|
||
for t in templates
|
||
]
|
||
|
||
|
||
@router.post("/")
|
||
async def create_template(
|
||
req: TemplateCreate,
|
||
db: AsyncSession = Depends(get_db),
|
||
user: User = Depends(require_admin_role),
|
||
):
|
||
tmpl = DocTemplate(
|
||
tenant_id=user.tenant_id,
|
||
name=req.name, description=req.description,
|
||
schema_json=json.dumps(req.schema_json, ensure_ascii=False),
|
||
workflow=req.workflow, is_builtin=False, is_active=True,
|
||
created_at=datetime.utcnow(),
|
||
)
|
||
db.add(tmpl)
|
||
await db.commit()
|
||
await db.refresh(tmpl)
|
||
return {"ok": True, "id": tmpl.id}
|
||
|
||
|
||
@router.get("/{template_id}")
|
||
async def get_template(
|
||
template_id: int,
|
||
db: AsyncSession = Depends(get_db),
|
||
user: User = Depends(get_current_user),
|
||
):
|
||
row = await db.execute(
|
||
select(DocTemplate).where(
|
||
DocTemplate.id == template_id,
|
||
DocTemplate.tenant_id == user.tenant_id,
|
||
)
|
||
)
|
||
t = row.scalar_one_or_none()
|
||
if not t:
|
||
raise HTTPException(404)
|
||
return {
|
||
"id": t.id, "name": t.name, "description": t.description,
|
||
"schema": json.loads(t.schema_json or "{}"),
|
||
"workflow": t.workflow, "is_builtin": t.is_builtin,
|
||
}
|
||
|
||
|
||
@router.put("/{template_id}")
|
||
async def update_template(
|
||
template_id: int,
|
||
req: TemplateCreate,
|
||
db: AsyncSession = Depends(get_db),
|
||
user: User = Depends(require_admin_role),
|
||
):
|
||
row = await db.execute(
|
||
select(DocTemplate).where(
|
||
DocTemplate.id == template_id,
|
||
DocTemplate.tenant_id == user.tenant_id,
|
||
)
|
||
)
|
||
t = row.scalar_one_or_none()
|
||
if not t:
|
||
raise HTTPException(404)
|
||
if t.is_builtin:
|
||
raise HTTPException(400, "내장 템플릿은 수정할 수 없습니다. 복제 후 수정하세요.")
|
||
t.name = req.name; t.description = req.description
|
||
t.schema_json = json.dumps(req.schema_json, ensure_ascii=False)
|
||
t.workflow = req.workflow
|
||
await db.commit()
|
||
return {"ok": True}
|
||
|
||
|
||
@router.delete("/{template_id}")
|
||
async def delete_template(
|
||
template_id: int,
|
||
db: AsyncSession = Depends(get_db),
|
||
user: User = Depends(require_admin_role),
|
||
):
|
||
row = await db.execute(
|
||
select(DocTemplate).where(
|
||
DocTemplate.id == template_id,
|
||
DocTemplate.tenant_id == user.tenant_id,
|
||
)
|
||
)
|
||
t = row.scalar_one_or_none()
|
||
if not t:
|
||
raise HTTPException(404)
|
||
if t.is_builtin:
|
||
raise HTTPException(400, "내장 템플릿은 삭제할 수 없습니다.")
|
||
t.is_active = False
|
||
await db.commit()
|
||
return {"ok": True}
|