태그 동점 시 첫 케이스만 반환해 나머지를 버리던 문제를 고친다. A1·A2·A3·A4·A5·A6·A15 는 주/보조 태그가 동일하고 실제 구별자는 원본의 종류라 태그로 좁혀지지 않는다. find_case -> find_cases 로 바꿔 동점군을 전부 내보내고, 확정은 사람이 원본 종류로 한다. - cases_v1.3.json 신설(v1.2 삭제): 39건 전부에 대표판례·처리구분(●/○) 적재. 판례 미지정 10건은 사유를 값으로 남긴다. - detectable_internal 10 -> 19건 (v1.3 IX장 총괄표 ● 기준으로 정정) - _assign_tags 주 태그 조합 3 -> 5종. 유사도로 판단 가능한 쟁점만 주 태그로 낸다. 도달 케이스 3 -> 16건(● 19건 중). - B3·C1·D1 은 게재 사실·편집 개입·성명표시가 필요해 텍스트로 알 수 없다. 추측하지 않고 LegalContext 대기로 두며 경계를 테스트로 고정한다. - 케이스 수 검증을 38~39 범위에서 39 로 고정. v1.3 본문 통계 줄(●16/○22=38)이 같은 문서의 표(●19/○20=39)와 어긋난 것이 38/39 혼재의 원인이었다. - docs/PRECEDENT_SOURCE_GAP.md: 대표판례이나 적재본에 없어 출처를 제시할 수 없는 14건. 응답에서도 precedents_without_source 로 구분한다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
488 lines
17 KiB
Python
488 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,
|
|
"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 "표절 의심 구간이 없습니다."
|
|
)
|
|
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),
|
|
)
|