o2o-plagiarism-ai/app/api/routes.py

482 lines
16 KiB
Python

from __future__ import annotations
from datetime import datetime, timezone
from fastapi import APIRouter, BackgroundTasks, File, Form, HTTPException, Query, Request, UploadFile, status
from starlette.concurrency import run_in_threadpool
from app.api.schemas import (
BatchCreatedResponse,
BatchRequest,
BatchStatusResponse,
CorpusItem,
CorpusListResponse,
CorpusUploadRequest,
CorpusUploadResponse,
CopyrightAiItem,
CopyrightCountItem,
CopyrightLegalJudgment,
CopyrightReviewResponse,
CopyrightScoreCard,
DetectRequest,
DetectResponse,
HealthResponse,
PrecedentItem,
PrecedentListResponse,
SummaryRequest,
SummaryResponse,
TaxonomyResponse,
)
from app.core.config import get_settings
from app.engine.corpus import add_document, delete_document, list_documents
from app.engine.detector import PlagiarismDetector
from app.engine.summarizer import get_summarizer
from app.jobs.store import JobStore
router = APIRouter(prefix="/v1")
def _detector(request: Request) -> PlagiarismDetector:
return request.app.state.detector
def _job_store(request: Request) -> JobStore:
return request.app.state.job_store
@router.get("/health", response_model=HealthResponse, tags=["meta"])
async def health(request: Request) -> HealthResponse:
settings = get_settings()
det: PlagiarismDetector = request.app.state.detector
taxonomy_version = None
if det.taxonomy:
taxonomy_version = f"meta_tags_{det.taxonomy.meta_tags_version}, cases_{det.taxonomy.cases_version}"
return HealthResponse(
status="ok",
engine_version=settings.engine_version,
corpus_size=det.corpus_size,
taxonomy_version=taxonomy_version,
autobiography_mode=settings.autobiography_mode,
corpus_documents=det.corpus_document_count,
index_backend=det.index_backend,
ai_model_ready=getattr(det, "ai_model_ready", False),
precedent_count=det.precedent_count,
)
@router.get("/taxonomy", response_model=TaxonomyResponse, tags=["meta"])
async def taxonomy(request: Request) -> TaxonomyResponse:
"""분류체계 조회 - 컴북스/바이칼이 동일 라벨링 공유용."""
det: PlagiarismDetector = request.app.state.detector
if not det.taxonomy:
raise HTTPException(status_code=503, detail="Taxonomy not loaded")
return TaxonomyResponse(
meta_tags_version=det.taxonomy.meta_tags_version,
cases_version=det.taxonomy.cases_version,
meta_tags=[
{"id": t.id, "label_ko": t.label_ko, "category": t.category,
"law_ref": t.law_ref, "scope": t.scope, "description": t.description}
for t in det.taxonomy.meta_tags
],
cases=[
{"case_id": c.case_id, "old_no": c.old_no, "subgroup": c.subgroup,
"title": c.title, "actor": c.actor,
"primary_tags": list(c.primary_tags), "secondary_tags": list(c.secondary_tags),
"detectable_internal": c.detectable_internal, "high_risk": c.high_risk,
"note": c.note}
for c in det.taxonomy.cases
],
)
@router.post(
"/plagiarism/detect",
response_model=DetectResponse,
tags=["plagiarism"],
)
async def detect(req: DetectRequest, request: Request) -> DetectResponse:
# 형태소/임베딩/행렬 연산으로 event loop가 막히지 않도록 worker thread에서 실행.
return await run_in_threadpool(_detector(request).detect_request, req)
@router.post(
"/plagiarism/review",
response_model=CopyrightReviewResponse,
tags=["plagiarism"],
summary="저작권 탭 경량 검사",
)
async def copyright_review(
req: DetectRequest, request: Request,
) -> CopyrightReviewResponse:
"""상세 탐지는 수행하되 저작권 탭에 필요한 필드만 작게 반환한다."""
result = await run_in_threadpool(_detector(request).detect_request, req)
review = result.review_summary
legal = result.legal_risk
if review is None or legal is None:
raise HTTPException(status_code=503, detail="Copyright review is unavailable")
ai_labels = {
"low": "낮음",
"medium": "중간",
"high": "높음",
"unknown": "확인 불가",
}
if legal.llm_verdict == "likely":
legal_status, legal_label = "suspected", "판례에 비추어 저작권 침해 의심"
elif (
legal.llm_verdict == "unlikely"
or legal.status == "no_registered_corpus_match"
):
legal_status, legal_label = "low", "등록 판례 기준 침해 의심 낮음"
elif legal.status == "insufficient_precedent_data":
legal_status, legal_label = "unavailable", "판례 검토 불가"
else:
legal_status, legal_label = "review_required", "관련 판례에 따른 추가 검토 필요"
precedent_ids = list(legal.llm_matched_precedent_ids or legal.precedent_ids)
suspicion_text = (
"표절 의심 구간이 확인되었습니다."
if review.has_suspicion
else "표절 의심 구간이 없습니다."
)
return CopyrightReviewResponse(
doc_id=result.doc_id,
copyright=CopyrightScoreCard(
originality_percent=review.originality_percent,
similarity_percent=review.similarity_percent,
compared_count=review.compared_count,
has_suspicion=review.has_suspicion,
description=(
f"등록 원문 검색 세그먼트 {review.compared_count:,}건과 대조한 결과 "
f"{suspicion_text}"
),
),
similar_sentences=CopyrightCountItem(
count=review.similar_sentence_count,
label=f"{review.similar_sentence_count}건",
),
ai_generation_suspicion=CopyrightAiItem(
level=review.ai_suspicion_level,
label=ai_labels[review.ai_suspicion_level],
),
legal_judgment=CopyrightLegalJudgment(
status=legal_status,
label=legal_label,
summary=legal.judgment_summary,
precedent_ids=precedent_ids,
),
analyzed_at=result.analyzed_at,
)
@router.post(
"/summary",
response_model=SummaryResponse,
tags=["summary"],
)
async def summarize(req: SummaryRequest) -> SummaryResponse:
"""스토리 요약/분석 (계획서 과제2 ②). 추출적 요약은 외부 의존 0으로 동작.
use_abstractive=True 이고 LLM 키가 있으면 통합(hybrid) 요약, 아니면 추출적 요약 반환.
"""
settings = get_settings()
result = get_summarizer(settings).summarize(
req.text,
ratio=req.ratio,
max_sentences=req.max_sentences,
use_abstractive=req.use_abstractive,
detail=req.detail,
emphasis=req.emphasis,
)
return SummaryResponse(
extractive=result.extractive,
abstractive=result.abstractive,
final=result.final,
mode=result.mode,
selected_indices=result.selected_indices,
num_sentences_in=result.num_sentences_in,
num_sentences_out=result.num_sentences_out,
detail=req.detail,
emphasis=req.emphasis,
engine_version=settings.engine_version,
)
@router.post(
"/plagiarism/batch",
response_model=BatchCreatedResponse,
status_code=status.HTTP_202_ACCEPTED,
tags=["plagiarism"],
)
async def batch_create(
req: BatchRequest,
request: Request,
background_tasks: BackgroundTasks,
) -> BatchCreatedResponse:
store = _job_store(request)
detector = _detector(request)
job = store.create(total=len(req.items))
background_tasks.add_task(_run_batch, store, detector, job.job_id, req)
return BatchCreatedResponse(
job_id=job.job_id,
status=job.status,
total=job.total,
created_at=job.created_at,
)
@router.get(
"/plagiarism/batch/{job_id}",
response_model=BatchStatusResponse,
tags=["plagiarism"],
)
async def batch_status(job_id: str, request: Request) -> BatchStatusResponse:
job = _job_store(request).get(job_id)
if not job:
raise HTTPException(status_code=404, detail="Job not found")
return BatchStatusResponse(
job_id=job.job_id,
status=job.status,
total=job.total,
processed=job.processed,
created_at=job.created_at,
finished_at=job.finished_at,
results=job.results if job.status == "completed" else None,
error=job.error,
)
# ---------- 판례 조회 ----------
@router.get(
"/precedents",
response_model=PrecedentListResponse,
tags=["precedents"],
)
async def precedent_list(
request: Request,
q: str = Query(default="", max_length=200),
grade: str | None = Query(default=None, pattern="^(A|B|C|unreviewed)$"),
work_type: str | None = Query(default=None, max_length=30),
offset: int = Query(default=0, ge=0),
limit: int = Query(default=25, ge=1, le=100),
) -> PrecedentListResponse:
"""엔진이 검색 후보로 사용하는 전체 판례를 조회한다."""
precedents = list(_detector(request).precedents)
needle = q.strip().lower()
def matches(p) -> bool:
if grade == "unreviewed" and p.grade is not None:
return False
if grade in {"A", "B", "C"} and p.grade != grade:
return False
if work_type and p.work_types and work_type not in p.work_types:
return False
if needle:
haystack = " ".join([
p.case_id, p.title, *p.work_types, *p.legal_tags,
*p.criteria, p.holding_summary,
]).lower()
if needle not in haystack:
return False
return True
filtered = [p for p in precedents if matches(p)]
page = filtered[offset:offset + limit]
return PrecedentListResponse(
total=len(filtered),
loaded_total=len(precedents),
graded_total=sum(p.grade is not None for p in precedents),
offset=offset,
limit=limit,
items=[
PrecedentItem(
case_id=p.case_id,
title=p.title,
source_url=p.source_url,
work_types=list(p.work_types),
legal_tags=list(p.legal_tags),
criteria=list(p.criteria),
grade=p.grade,
holding_excerpt=(
p.holding_summary
if len(p.holding_summary) <= 800
else p.holding_summary[:800].rstrip() + "…"
),
)
for p in page
],
)
# ---------- 코퍼스 관리 ----------
def _rebuild(request: Request) -> int:
from app.main import rebuild_detector
return rebuild_detector(request.app)
def _persistent_add_locked(request: Request, detector, doc_id, title, text) -> str:
with request.app.state.detector_lock:
return detector.add_persistent_document(doc_id, title, text)
def _persistent_delete_locked(request: Request, detector, doc_id: str) -> bool:
with request.app.state.detector_lock:
return detector.delete_persistent_document(doc_id)
@router.get(
"/corpus",
response_model=CorpusListResponse,
tags=["corpus"],
)
async def corpus_list(request: Request) -> CorpusListResponse:
settings = get_settings()
detector = _detector(request)
if detector.uses_persistent_index:
docs = [
{
"doc_id": d["document_id"], "title": d["title"],
"size_bytes": d["characters"], "filename": d["source_path"],
}
for d in detector.list_persistent_documents()
]
else:
docs = list_documents(settings.corpus_path)
return CorpusListResponse(
total=len(docs),
docs=[CorpusItem(**d) for d in docs],
)
@router.post(
"/corpus",
response_model=CorpusUploadResponse,
status_code=status.HTTP_201_CREATED,
tags=["corpus"],
)
async def corpus_upload_json(req: CorpusUploadRequest, request: Request) -> CorpusUploadResponse:
"""JSON으로 자서전 1건 업로드. 인덱스 자동 재빌드."""
settings = get_settings()
detector = _detector(request)
if detector.uses_persistent_index:
try:
document_id = await run_in_threadpool(
_persistent_add_locked, request, detector, req.doc_id, req.title, req.text
)
except FileExistsError as e:
raise HTTPException(status_code=409, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
return CorpusUploadResponse(
doc_id=document_id, title=req.title,
size_bytes=len(req.text.encode("utf-8")),
corpus_size_after=detector.corpus_size, rebuilt=False,
)
try:
doc = add_document(settings.corpus_path, req.doc_id, req.title, req.text)
except FileExistsError as e:
raise HTTPException(status_code=409, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
new_size = await run_in_threadpool(_rebuild, request)
return CorpusUploadResponse(
doc_id=doc.doc_id, title=doc.title,
size_bytes=len(doc.text.encode("utf-8")),
corpus_size_after=new_size, rebuilt=True,
)
@router.post(
"/corpus/file",
response_model=CorpusUploadResponse,
status_code=status.HTTP_201_CREATED,
tags=["corpus"],
)
async def corpus_upload_file(
request: Request,
title: str = Form(..., description="자서전 제목"),
doc_id: str | None = Form(default=None, description="비우면 자동 생성"),
file: UploadFile = File(..., description=".txt 파일"),
) -> CorpusUploadResponse:
"""multipart로 .txt 파일 업로드 (큰 자서전 파일용)."""
settings = get_settings()
raw = await file.read()
try:
text = raw.decode("utf-8")
except UnicodeDecodeError:
raise HTTPException(status_code=400, detail="UTF-8 인코딩 텍스트 파일만 업로드 가능합니다.")
detector = _detector(request)
if detector.uses_persistent_index:
try:
document_id = await run_in_threadpool(
_persistent_add_locked, request, detector, doc_id, title, text
)
except FileExistsError as e:
raise HTTPException(status_code=409, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
return CorpusUploadResponse(
doc_id=document_id, title=title, size_bytes=len(raw),
corpus_size_after=detector.corpus_size, rebuilt=False,
)
try:
doc = add_document(settings.corpus_path, doc_id, title, text)
except FileExistsError as e:
raise HTTPException(status_code=409, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
new_size = await run_in_threadpool(_rebuild, request)
return CorpusUploadResponse(
doc_id=doc.doc_id, title=doc.title,
size_bytes=len(doc.text.encode("utf-8")),
corpus_size_after=new_size, rebuilt=True,
)
@router.delete(
"/corpus/{doc_id}",
status_code=status.HTTP_204_NO_CONTENT,
tags=["corpus"],
)
async def corpus_delete(doc_id: str, request: Request) -> None:
settings = get_settings()
detector = _detector(request)
if detector.uses_persistent_index:
deleted = await run_in_threadpool(
_persistent_delete_locked, request, detector, doc_id
)
if not deleted:
raise HTTPException(status_code=404, detail=f"doc_id '{doc_id}' not found")
return
if not delete_document(settings.corpus_path, doc_id):
raise HTTPException(status_code=404, detail=f"doc_id '{doc_id}' not found")
await run_in_threadpool(_rebuild, request)
def _run_batch(store: JobStore, detector: PlagiarismDetector, job_id: str, req: BatchRequest) -> None:
store.update(job_id, status="running")
try:
for item in req.items:
result = detector.detect(
doc_id=item.doc_id,
text=item.text,
metadata=item.metadata,
options=req.options,
include_ai_segments=False,
)
store.append_result(job_id, result)
store.update(job_id, status="completed", finished_at=datetime.now(timezone.utc))
except Exception as exc:
store.update(
job_id,
status="failed",
finished_at=datetime.now(timezone.utc),
error=str(exc),
)