o2o-plagiarism-ai/app/api/routes.py
hbyang b73d27a850 feat: split author/admin detect views and add case matching KPI basis
월례회의 자료(2026-09-18) p.7 파이프라인의 3단계 「저자 화면에는 케이스 코드를
노출하지 않는다」를 구현하고, 후속 합의에 필요한 문서를 함께 남긴다.

- DetectOptions.audience(admin 기본 / author). author 직렬화에서 case_id,
  case_candidates, tags, legal_risk, is_infringement 을 제외하고 ccl_basis 를
  코드 없는 문장으로 대체한다. 일치 위치와 점수는 유지한다.
- is_infringement 는 필수 bool 로 둔다. run_precision_eval.py 등 소비자가 bool
  로 읽으므로 선택 필드로 두면 None 이 조용히 흘러간다. 제외는 직렬화에서만 한다.
- publication_verdict 필드 추가. 컴북스 코드표 미확보이므로 39건 전부 null 이며
  null 을 출간 허용으로 해석하지 않는다. enum 과 대표값 선정은 코드표 수령 후.
- request_id / taxonomy_version 을 응답에 싣는다. 관리자 확정 로그와 연결된다.
- engine_version 기본값을 2.2.1-cases-v1.3 으로 맞춘다. 직전 값(2.0.1)이 King
  운영값 2.2.0-persistent-cpu 보다 낮아 성적서 대조 시 뒤집혀 보였다.

케이스 정의는 39건(A 27건)을 유지한다. 회의 자료의 40건(A 28건)과 1건 차이가
있으나 아카이빙 DB v2.3 원본을 받기 전까지 추측해 채우지 않는다.

7,786편 운영 재검사는 모집단 불일치(현재 6,343건)로 중단했고 부분 실행은 집계하지
않는다. 별도 평가셋 재측정은 기존 testset_v2 수치(precision 98.4032%)를 그대로
재현했으며 새 독립 시험 결과가 아니다. 상세는 reports/CASE_MATCHING_EVAL_*.json.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 16:16:33 +09:00

496 lines
17 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,
"handling": c.handling,
"publication_verdict": c.publication_verdict,
"representative_precedents": [
{"case_id": p.case_id, "in_runtime_corpus": p.in_runtime_corpus}
for p in c.representative_precedents
],
"representative_precedent_note": c.representative_precedent_note,
"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 "표절 의심 구간이 없습니다."
)
judgment_summary = legal.judgment_summary
if req.options.audience == "author":
legal_status, legal_label = "review_required", "확인이 필요한 부분"
precedent_ids = []
judgment_summary = ("일치 구간을 확인해 주세요." if review.has_suspicion
else "등록된 비교 자료에서 일치 구간을 찾지 못했습니다.")
suspicion_text = judgment_summary
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=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),
)