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>
This commit is contained in:
parent
52a0fdcdf0
commit
b73d27a850
@ -81,6 +81,7 @@ async def taxonomy(request: Request) -> TaxonomyResponse:
|
|||||||
cases=[
|
cases=[
|
||||||
{"case_id": c.case_id, "old_no": c.old_no, "subgroup": c.subgroup,
|
{"case_id": c.case_id, "old_no": c.old_no, "subgroup": c.subgroup,
|
||||||
"handling": c.handling,
|
"handling": c.handling,
|
||||||
|
"publication_verdict": c.publication_verdict,
|
||||||
"representative_precedents": [
|
"representative_precedents": [
|
||||||
{"case_id": p.case_id, "in_runtime_corpus": p.in_runtime_corpus}
|
{"case_id": p.case_id, "in_runtime_corpus": p.in_runtime_corpus}
|
||||||
for p in c.representative_precedents
|
for p in c.representative_precedents
|
||||||
@ -145,6 +146,13 @@ async def copyright_review(
|
|||||||
if review.has_suspicion
|
if review.has_suspicion
|
||||||
else "표절 의심 구간이 없습니다."
|
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(
|
return CopyrightReviewResponse(
|
||||||
doc_id=result.doc_id,
|
doc_id=result.doc_id,
|
||||||
copyright=CopyrightScoreCard(
|
copyright=CopyrightScoreCard(
|
||||||
@ -168,7 +176,7 @@ async def copyright_review(
|
|||||||
legal_judgment=CopyrightLegalJudgment(
|
legal_judgment=CopyrightLegalJudgment(
|
||||||
status=legal_status,
|
status=legal_status,
|
||||||
label=legal_label,
|
label=legal_label,
|
||||||
summary=legal.judgment_summary,
|
summary=judgment_summary,
|
||||||
precedent_ids=precedent_ids,
|
precedent_ids=precedent_ids,
|
||||||
),
|
),
|
||||||
analyzed_at=result.analyzed_at,
|
analyzed_at=result.analyzed_at,
|
||||||
|
|||||||
@ -2,8 +2,9 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field, model_serializer
|
||||||
|
|
||||||
# 법령 기반 10종 메타 태그 (PDF IV장)
|
# 법령 기반 10종 메타 태그 (PDF IV장)
|
||||||
LegalTag = Literal[
|
LegalTag = Literal[
|
||||||
@ -47,6 +48,7 @@ class DocumentMetadata(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class DetectOptions(BaseModel):
|
class DetectOptions(BaseModel):
|
||||||
|
audience: Literal["admin", "author"] = "admin"
|
||||||
return_evidence: bool = True
|
return_evidence: bool = True
|
||||||
threshold: float | None = Field(default=None, ge=0.0, le=1.0,
|
threshold: float | None = Field(default=None, ge=0.0, le=1.0,
|
||||||
description="None이면 서버 설정 사용. PDF VII-4 권장 0.85")
|
description="None이면 서버 설정 사용. PDF VII-4 권장 0.85")
|
||||||
@ -124,6 +126,7 @@ class PartialPlagiarismSignal(BaseModel):
|
|||||||
|
|
||||||
class CaseCandidate(BaseModel):
|
class CaseCandidate(BaseModel):
|
||||||
"""판정된 침해 케이스 후보 1건 + v1.3 총괄표의 대표판례."""
|
"""판정된 침해 케이스 후보 1건 + v1.3 총괄표의 대표판례."""
|
||||||
|
publication_verdict: str | None = Field(default=None, description="컴북스 확정 코드. 원본 미확보 시 null; 출간 허용을 뜻하지 않음.")
|
||||||
case_id: str
|
case_id: str
|
||||||
title: str
|
title: str
|
||||||
handling: Literal["technical_detection", "terms_or_report"]
|
handling: Literal["technical_detection", "terms_or_report"]
|
||||||
@ -138,6 +141,8 @@ class CaseCandidate(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class MatchResult(BaseModel):
|
class MatchResult(BaseModel):
|
||||||
|
publication_verdict: str | None = None
|
||||||
|
publication_verdict_status: Literal["source_pending", "review_required"] = "source_pending"
|
||||||
source_doc: str
|
source_doc: str
|
||||||
source_title: str | None = None
|
source_title: str | None = None
|
||||||
similarity: float = Field(..., ge=0.0, le=1.0)
|
similarity: float = Field(..., ge=0.0, le=1.0)
|
||||||
@ -299,7 +304,29 @@ class ReviewSummary(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class DetectResponse(BaseModel):
|
class DetectResponse(BaseModel):
|
||||||
|
request_id: str = Field(default_factory=lambda: str(uuid4()))
|
||||||
|
taxonomy_version: str | None = None
|
||||||
|
audience: Literal["admin", "author"] = "admin"
|
||||||
|
|
||||||
|
@model_serializer(mode="wrap")
|
||||||
|
def serialize_audience(self, handler):
|
||||||
|
data = handler(self)
|
||||||
|
if self.audience == "author":
|
||||||
|
# Free-form legal/LLM prose may include both case and precedent IDs.
|
||||||
|
data.pop("legal_risk", None)
|
||||||
|
data.pop("is_infringement", None)
|
||||||
|
data["ccl_basis"] = ("확인이 필요한 부분이 있습니다." if self.matches
|
||||||
|
else "등록된 비교 자료에서 일치 구간을 찾지 못했습니다.")
|
||||||
|
for match in data.get("matches", []):
|
||||||
|
for key in ("case_id", "case_title", "case_candidates", "tags",
|
||||||
|
"infringement_type", "publication_verdict",
|
||||||
|
"publication_verdict_status"):
|
||||||
|
match.pop(key, None)
|
||||||
|
return data
|
||||||
|
|
||||||
doc_id: str
|
doc_id: str
|
||||||
|
#: 후방호환 검출값. 항상 설정되며, 저자용 직렬화에서만 출력에서 제외된다.
|
||||||
|
#: 소비자(run_precision_eval.py, evaluate_pairs.py 등)가 bool 로 읽으므로 필수로 둔다.
|
||||||
is_infringement: bool
|
is_infringement: bool
|
||||||
confidence: float = Field(..., ge=0.0, le=1.0)
|
confidence: float = Field(..., ge=0.0, le=1.0)
|
||||||
extracted_elements: ExtractedElements
|
extracted_elements: ExtractedElements
|
||||||
|
|||||||
@ -25,7 +25,7 @@ class Settings(BaseSettings):
|
|||||||
public_health: bool = True # /v1/health 를 인증 없이 공개할지
|
public_health: bool = True # /v1/health 를 인증 없이 공개할지
|
||||||
public_docs: bool = True # /docs, /openapi.json, /redoc 공개 여부
|
public_docs: bool = True # /docs, /openapi.json, /redoc 공개 여부
|
||||||
|
|
||||||
engine_version: str = "o2o-plagiarism-2.0.0-pdf-v1.2"
|
engine_version: str = "o2o-plagiarism-2.2.1-cases-v1.3"
|
||||||
reference_corpus_dir: str = "./data/reference"
|
reference_corpus_dir: str = "./data/reference"
|
||||||
taxonomy_dir: str = "./data/taxonomy"
|
taxonomy_dir: str = "./data/taxonomy"
|
||||||
autobiography_patterns_path: str = "./data/autobiography/common_patterns.txt"
|
autobiography_patterns_path: str = "./data/autobiography/common_patterns.txt"
|
||||||
|
|||||||
@ -396,6 +396,8 @@ class PlagiarismDetector:
|
|||||||
)
|
)
|
||||||
|
|
||||||
return DetectResponse(
|
return DetectResponse(
|
||||||
|
audience=opts.audience,
|
||||||
|
taxonomy_version=(f"cases_{self.taxonomy.cases_version}" if self.taxonomy else None),
|
||||||
doc_id=doc_id,
|
doc_id=doc_id,
|
||||||
is_infringement=is_infringement,
|
is_infringement=is_infringement,
|
||||||
confidence=round(confidence, 4),
|
confidence=round(confidence, 4),
|
||||||
@ -612,6 +614,7 @@ class PlagiarismDetector:
|
|||||||
case_id=c.case_id,
|
case_id=c.case_id,
|
||||||
title=c.title,
|
title=c.title,
|
||||||
handling=c.handling,
|
handling=c.handling,
|
||||||
|
publication_verdict=c.publication_verdict,
|
||||||
representative_precedents=[p.case_id for p in c.representative_precedents],
|
representative_precedents=[p.case_id for p in c.representative_precedents],
|
||||||
precedents_without_source=[
|
precedents_without_source=[
|
||||||
p.case_id for p in c.representative_precedents if not p.in_runtime_corpus
|
p.case_id for p in c.representative_precedents if not p.in_runtime_corpus
|
||||||
@ -629,6 +632,9 @@ class PlagiarismDetector:
|
|||||||
case_id=cases[0].case_id if cases else None,
|
case_id=cases[0].case_id if cases else None,
|
||||||
case_title=cases[0].title if cases else None,
|
case_title=cases[0].title if cases else None,
|
||||||
case_candidates=candidates,
|
case_candidates=candidates,
|
||||||
|
publication_verdict_status=("review_required" if any(
|
||||||
|
c.publication_verdict is not None for c in candidates
|
||||||
|
) else "source_pending"),
|
||||||
infringement_type=legacy_type,
|
infringement_type=legacy_type,
|
||||||
evidence_spans=hit.evidence if return_evidence else [],
|
evidence_spans=hit.evidence if return_evidence else [],
|
||||||
score_breakdown=ScoreBreakdown(
|
score_breakdown=ScoreBreakdown(
|
||||||
|
|||||||
@ -53,6 +53,7 @@ class CaseDef:
|
|||||||
representative_precedents: tuple[RepresentativePrecedent, ...] = field(default_factory=tuple)
|
representative_precedents: tuple[RepresentativePrecedent, ...] = field(default_factory=tuple)
|
||||||
#: 대표판례가 지정되지 않은 이유("확립 판례 없음" 등). 빈 목록과 구분한다.
|
#: 대표판례가 지정되지 않은 이유("확립 판례 없음" 등). 빈 목록과 구분한다.
|
||||||
representative_precedent_note: str | None = None
|
representative_precedent_note: str | None = None
|
||||||
|
publication_verdict: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@ -136,6 +137,7 @@ def load_taxonomy(taxonomy_dir: Path) -> Taxonomy | None:
|
|||||||
for p in c.get("representative_precedents", [])
|
for p in c.get("representative_precedents", [])
|
||||||
),
|
),
|
||||||
representative_precedent_note=c.get("representative_precedent_note"),
|
representative_precedent_note=c.get("representative_precedent_note"),
|
||||||
|
publication_verdict=c.get("publication_verdict"),
|
||||||
)
|
)
|
||||||
for c in cs_data["cases"]
|
for c in cs_data["cases"]
|
||||||
)
|
)
|
||||||
|
|||||||
@ -28,7 +28,8 @@
|
|||||||
"in_runtime_corpus": false
|
"in_runtime_corpus": false
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"representative_precedent_note": null
|
"representative_precedent_note": null,
|
||||||
|
"publication_verdict": null
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"case_id": "A2",
|
"case_id": "A2",
|
||||||
@ -56,7 +57,8 @@
|
|||||||
"in_runtime_corpus": true
|
"in_runtime_corpus": true
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"representative_precedent_note": null
|
"representative_precedent_note": null,
|
||||||
|
"publication_verdict": null
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"case_id": "A3",
|
"case_id": "A3",
|
||||||
@ -80,7 +82,8 @@
|
|||||||
"in_runtime_corpus": false
|
"in_runtime_corpus": false
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"representative_precedent_note": null
|
"representative_precedent_note": null,
|
||||||
|
"publication_verdict": null
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"case_id": "A4",
|
"case_id": "A4",
|
||||||
@ -104,7 +107,8 @@
|
|||||||
"in_runtime_corpus": true
|
"in_runtime_corpus": true
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"representative_precedent_note": null
|
"representative_precedent_note": null,
|
||||||
|
"publication_verdict": null
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"case_id": "A5",
|
"case_id": "A5",
|
||||||
@ -128,7 +132,8 @@
|
|||||||
"in_runtime_corpus": true
|
"in_runtime_corpus": true
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"representative_precedent_note": null
|
"representative_precedent_note": null,
|
||||||
|
"publication_verdict": null
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"case_id": "A6",
|
"case_id": "A6",
|
||||||
@ -152,7 +157,8 @@
|
|||||||
"in_runtime_corpus": false
|
"in_runtime_corpus": false
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"representative_precedent_note": null
|
"representative_precedent_note": null,
|
||||||
|
"publication_verdict": null
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"case_id": "A7",
|
"case_id": "A7",
|
||||||
@ -177,7 +183,8 @@
|
|||||||
"in_runtime_corpus": false
|
"in_runtime_corpus": false
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"representative_precedent_note": null
|
"representative_precedent_note": null,
|
||||||
|
"publication_verdict": null
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"case_id": "A8",
|
"case_id": "A8",
|
||||||
@ -201,7 +208,8 @@
|
|||||||
"in_runtime_corpus": false
|
"in_runtime_corpus": false
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"representative_precedent_note": null
|
"representative_precedent_note": null,
|
||||||
|
"publication_verdict": null
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"case_id": "A9",
|
"case_id": "A9",
|
||||||
@ -225,7 +233,8 @@
|
|||||||
"in_runtime_corpus": false
|
"in_runtime_corpus": false
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"representative_precedent_note": null
|
"representative_precedent_note": null,
|
||||||
|
"publication_verdict": null
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"case_id": "A10",
|
"case_id": "A10",
|
||||||
@ -255,7 +264,8 @@
|
|||||||
"in_runtime_corpus": true
|
"in_runtime_corpus": true
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"representative_precedent_note": null
|
"representative_precedent_note": null,
|
||||||
|
"publication_verdict": null
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"case_id": "A11",
|
"case_id": "A11",
|
||||||
@ -273,7 +283,8 @@
|
|||||||
"detectable_internal": false,
|
"detectable_internal": false,
|
||||||
"handling": "terms_or_report",
|
"handling": "terms_or_report",
|
||||||
"representative_precedents": [],
|
"representative_precedents": [],
|
||||||
"representative_precedent_note": "공표권·복제 법리"
|
"representative_precedent_note": "공표권·복제 법리",
|
||||||
|
"publication_verdict": null
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"case_id": "A12",
|
"case_id": "A12",
|
||||||
@ -291,7 +302,8 @@
|
|||||||
"detectable_internal": false,
|
"detectable_internal": false,
|
||||||
"handling": "terms_or_report",
|
"handling": "terms_or_report",
|
||||||
"representative_precedents": [],
|
"representative_precedents": [],
|
||||||
"representative_precedent_note": "확립 판례 없음"
|
"representative_precedent_note": "확립 판례 없음",
|
||||||
|
"publication_verdict": null
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"case_id": "A13",
|
"case_id": "A13",
|
||||||
@ -311,7 +323,8 @@
|
|||||||
"high_risk": true,
|
"high_risk": true,
|
||||||
"handling": "terms_or_report",
|
"handling": "terms_or_report",
|
||||||
"representative_precedents": [],
|
"representative_precedents": [],
|
||||||
"representative_precedent_note": "확립 판례 없음"
|
"representative_precedent_note": "확립 판례 없음",
|
||||||
|
"publication_verdict": null
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"case_id": "A14",
|
"case_id": "A14",
|
||||||
@ -335,7 +348,8 @@
|
|||||||
"in_runtime_corpus": false
|
"in_runtime_corpus": false
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"representative_precedent_note": null
|
"representative_precedent_note": null,
|
||||||
|
"publication_verdict": null
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"case_id": "A15",
|
"case_id": "A15",
|
||||||
@ -359,7 +373,8 @@
|
|||||||
"in_runtime_corpus": true
|
"in_runtime_corpus": true
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"representative_precedent_note": null
|
"representative_precedent_note": null,
|
||||||
|
"publication_verdict": null
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"case_id": "A16",
|
"case_id": "A16",
|
||||||
@ -388,7 +403,8 @@
|
|||||||
"in_runtime_corpus": false
|
"in_runtime_corpus": false
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"representative_precedent_note": null
|
"representative_precedent_note": null,
|
||||||
|
"publication_verdict": null
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"case_id": "A17",
|
"case_id": "A17",
|
||||||
@ -415,7 +431,8 @@
|
|||||||
"in_runtime_corpus": false
|
"in_runtime_corpus": false
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"representative_precedent_note": null
|
"representative_precedent_note": null,
|
||||||
|
"publication_verdict": null
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"case_id": "A18",
|
"case_id": "A18",
|
||||||
@ -435,7 +452,8 @@
|
|||||||
"in_runtime_corpus": false
|
"in_runtime_corpus": false
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"representative_precedent_note": null
|
"representative_precedent_note": null,
|
||||||
|
"publication_verdict": null
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"case_id": "A19",
|
"case_id": "A19",
|
||||||
@ -462,7 +480,8 @@
|
|||||||
"in_runtime_corpus": false
|
"in_runtime_corpus": false
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"representative_precedent_note": null
|
"representative_precedent_note": null,
|
||||||
|
"publication_verdict": null
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"case_id": "A20",
|
"case_id": "A20",
|
||||||
@ -480,7 +499,8 @@
|
|||||||
"detectable_internal": false,
|
"detectable_internal": false,
|
||||||
"handling": "terms_or_report",
|
"handling": "terms_or_report",
|
||||||
"representative_precedents": [],
|
"representative_precedents": [],
|
||||||
"representative_precedent_note": "어문 밖(상표)"
|
"representative_precedent_note": "어문 밖(상표)",
|
||||||
|
"publication_verdict": null
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"case_id": "A21",
|
"case_id": "A21",
|
||||||
@ -504,7 +524,8 @@
|
|||||||
"in_runtime_corpus": false
|
"in_runtime_corpus": false
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"representative_precedent_note": null
|
"representative_precedent_note": null,
|
||||||
|
"publication_verdict": null
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"case_id": "A22",
|
"case_id": "A22",
|
||||||
@ -524,7 +545,8 @@
|
|||||||
"high_risk": true,
|
"high_risk": true,
|
||||||
"handling": "terms_or_report",
|
"handling": "terms_or_report",
|
||||||
"representative_precedents": [],
|
"representative_precedents": [],
|
||||||
"representative_precedent_note": "확립 판례 없음"
|
"representative_precedent_note": "확립 판례 없음",
|
||||||
|
"publication_verdict": null
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"case_id": "A23",
|
"case_id": "A23",
|
||||||
@ -551,7 +573,8 @@
|
|||||||
"in_runtime_corpus": true
|
"in_runtime_corpus": true
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"representative_precedent_note": null
|
"representative_precedent_note": null,
|
||||||
|
"publication_verdict": null
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"case_id": "A24",
|
"case_id": "A24",
|
||||||
@ -569,7 +592,8 @@
|
|||||||
"detectable_internal": false,
|
"detectable_internal": false,
|
||||||
"handling": "terms_or_report",
|
"handling": "terms_or_report",
|
||||||
"representative_precedents": [],
|
"representative_precedents": [],
|
||||||
"representative_precedent_note": "확립 판례 없음"
|
"representative_precedent_note": "확립 판례 없음",
|
||||||
|
"publication_verdict": null
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"case_id": "A25",
|
"case_id": "A25",
|
||||||
@ -591,7 +615,8 @@
|
|||||||
"in_runtime_corpus": true
|
"in_runtime_corpus": true
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"representative_precedent_note": null
|
"representative_precedent_note": null,
|
||||||
|
"publication_verdict": null
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"case_id": "A26",
|
"case_id": "A26",
|
||||||
@ -613,7 +638,8 @@
|
|||||||
"in_runtime_corpus": false
|
"in_runtime_corpus": false
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"representative_precedent_note": null
|
"representative_precedent_note": null,
|
||||||
|
"publication_verdict": null
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"case_id": "A27",
|
"case_id": "A27",
|
||||||
@ -630,7 +656,8 @@
|
|||||||
"detectable_internal": false,
|
"detectable_internal": false,
|
||||||
"handling": "terms_or_report",
|
"handling": "terms_or_report",
|
||||||
"representative_precedents": [],
|
"representative_precedents": [],
|
||||||
"representative_precedent_note": "계약·성명표시(판례 약함)"
|
"representative_precedent_note": "계약·성명표시(판례 약함)",
|
||||||
|
"publication_verdict": null
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"case_id": "B1",
|
"case_id": "B1",
|
||||||
@ -656,7 +683,8 @@
|
|||||||
"in_runtime_corpus": true
|
"in_runtime_corpus": true
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"representative_precedent_note": null
|
"representative_precedent_note": null,
|
||||||
|
"publication_verdict": null
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"case_id": "B2",
|
"case_id": "B2",
|
||||||
@ -680,7 +708,8 @@
|
|||||||
"in_runtime_corpus": false
|
"in_runtime_corpus": false
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"representative_precedent_note": null
|
"representative_precedent_note": null,
|
||||||
|
"publication_verdict": null
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"case_id": "B3",
|
"case_id": "B3",
|
||||||
@ -708,7 +737,8 @@
|
|||||||
"in_runtime_corpus": false
|
"in_runtime_corpus": false
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"representative_precedent_note": null
|
"representative_precedent_note": null,
|
||||||
|
"publication_verdict": null
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"case_id": "B4",
|
"case_id": "B4",
|
||||||
@ -725,7 +755,8 @@
|
|||||||
"high_risk": true,
|
"high_risk": true,
|
||||||
"handling": "terms_or_report",
|
"handling": "terms_or_report",
|
||||||
"representative_precedents": [],
|
"representative_precedents": [],
|
||||||
"representative_precedent_note": "확립 판례 없음"
|
"representative_precedent_note": "확립 판례 없음",
|
||||||
|
"publication_verdict": null
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"case_id": "C1",
|
"case_id": "C1",
|
||||||
@ -748,7 +779,8 @@
|
|||||||
"in_runtime_corpus": true
|
"in_runtime_corpus": true
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"representative_precedent_note": null
|
"representative_precedent_note": null,
|
||||||
|
"publication_verdict": null
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"case_id": "C2",
|
"case_id": "C2",
|
||||||
@ -770,7 +802,8 @@
|
|||||||
"in_runtime_corpus": true
|
"in_runtime_corpus": true
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"representative_precedent_note": null
|
"representative_precedent_note": null,
|
||||||
|
"publication_verdict": null
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"case_id": "C3",
|
"case_id": "C3",
|
||||||
@ -789,7 +822,8 @@
|
|||||||
"high_risk": true,
|
"high_risk": true,
|
||||||
"handling": "terms_or_report",
|
"handling": "terms_or_report",
|
||||||
"representative_precedents": [],
|
"representative_precedents": [],
|
||||||
"representative_precedent_note": "확립 판례 없음"
|
"representative_precedent_note": "확립 판례 없음",
|
||||||
|
"publication_verdict": null
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"case_id": "D1",
|
"case_id": "D1",
|
||||||
@ -818,7 +852,8 @@
|
|||||||
"in_runtime_corpus": true
|
"in_runtime_corpus": true
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"representative_precedent_note": null
|
"representative_precedent_note": null,
|
||||||
|
"publication_verdict": null
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"case_id": "E1",
|
"case_id": "E1",
|
||||||
@ -845,7 +880,8 @@
|
|||||||
"in_runtime_corpus": true
|
"in_runtime_corpus": true
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"representative_precedent_note": null
|
"representative_precedent_note": null,
|
||||||
|
"publication_verdict": null
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"case_id": "E2",
|
"case_id": "E2",
|
||||||
@ -868,7 +904,8 @@
|
|||||||
"in_runtime_corpus": false
|
"in_runtime_corpus": false
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"representative_precedent_note": null
|
"representative_precedent_note": null,
|
||||||
|
"publication_verdict": null
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"case_id": "X1",
|
"case_id": "X1",
|
||||||
@ -882,7 +919,8 @@
|
|||||||
"note": "사생활 침해 영역, 약관·운영 절차로 처리",
|
"note": "사생활 침해 영역, 약관·운영 절차로 처리",
|
||||||
"handling": "terms_or_report",
|
"handling": "terms_or_report",
|
||||||
"representative_precedents": [],
|
"representative_precedents": [],
|
||||||
"representative_precedent_note": "저작권 밖(개인정보)"
|
"representative_precedent_note": "저작권 밖(개인정보)",
|
||||||
|
"publication_verdict": null
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"case_id": "X2",
|
"case_id": "X2",
|
||||||
@ -901,7 +939,8 @@
|
|||||||
"in_runtime_corpus": false
|
"in_runtime_corpus": false
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"representative_precedent_note": null
|
"representative_precedent_note": null,
|
||||||
|
"publication_verdict": null
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
55
docs/CASE_MATCHING_API.md
Normal file
55
docs/CASE_MATCHING_API.md
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
# 케이스 매칭 응답 계약 (2026-09-18)
|
||||||
|
|
||||||
|
## 표시 대상
|
||||||
|
|
||||||
|
`POST /v1/plagiarism/detect`와 `POST /v1/plagiarism/batch`의 options에
|
||||||
|
`"audience": "author"`를 지정하면 저자용 JSON으로 직렬화한다.
|
||||||
|
생략 시 `admin`이며 기존 케이스 후보·판례·법률 검토 응답이 유지된다.
|
||||||
|
허용하지 않은 audience 값은 422다. 이 선택은 권한 인증을 대신하지 않는다.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"doc_id": "manuscript-123",
|
||||||
|
"text": "검사할 원고 본문",
|
||||||
|
"options": {"audience": "author", "return_evidence": true}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
저자용 응답은 `legal_risk`, `is_infringement`를 생략한다. matches 각각에서
|
||||||
|
`case_id`, `case_title`, `case_candidates`, `tags`, `infringement_type`,
|
||||||
|
`publication_verdict`, `publication_verdict_status`를 생략한다(빈 값 반환이 아닌 키 생략).
|
||||||
|
일치 원천·좌표·증거 구간·점수·match_reasons는 유지한다. `return_evidence=false`이면
|
||||||
|
기존처럼 evidence_spans는 빈 목록이다. 자유형 `ccl_basis`는 다음 고정 문장을 사용한다.
|
||||||
|
|
||||||
|
- 매칭 있음: `확인이 필요한 부분이 있습니다.`
|
||||||
|
- 매칭 없음: `등록된 비교 자료에서 일치 구간을 찾지 못했습니다.`
|
||||||
|
|
||||||
|
매칭 없음은 비침해/출간 가능 판정이 아니다.
|
||||||
|
배치에서는 항목별 결과에 동일 규칙이 적용된다. 경량 `/v1/plagiarism/review`도
|
||||||
|
같은 옵션을 받고 legal_judgment의 판례 ID를 비우고 코드 없는 검토 문장을 제공한다.
|
||||||
|
기존 review 응답의 필드 구성은 유지한다.
|
||||||
|
|
||||||
|
## 출간 판정
|
||||||
|
|
||||||
|
taxonomy 케이스와 관리자용 case_candidates 각각에 `publication_verdict`를 제공한다.
|
||||||
|
현재 값은 전부 null이다. 컴북스 코드표와 케이스 대응표가 없기 때문이다.
|
||||||
|
null은 출간 허용·불가 중 어느 쪽도 의미하지 않는다.
|
||||||
|
|
||||||
|
관리자용 match의 `publication_verdict`는 대표값이고, 아직 null이다.
|
||||||
|
`publication_verdict_status=source_pending`은 후보 판정 자료 미확보다.
|
||||||
|
일부 후보에 판정이 들어왔어도 우선순위 규칙을 확보하기 전에는 대표값을 만들지 않고
|
||||||
|
`review_required`를 반환한다. 코드표와 보수적 순서가 확정되면 이를 검증하는
|
||||||
|
회귀 테스트와 함께 대표 선정 로직을 추가한다. 후보별 판정은 삭제하지 않는다.
|
||||||
|
현재 스키마의 문자열은 연결 지점이며 공식 9종 enum 정의가 아니다.
|
||||||
|
|
||||||
|
## 감사 기록 연결
|
||||||
|
|
||||||
|
상세 detect와 배치 항목에 `request_id`(매 호출별 UUID), `engine_version`,
|
||||||
|
`taxonomy_version`, `analyzed_at`이 들어간다. 같은 doc_id로 재검사하면 request_id는 달라진다.
|
||||||
|
새 기본 엔진 버전은 `o2o-plagiarism-2.2.1-cases-v1.3`이다. 환경변수로 버전을
|
||||||
|
덮어쓰는 운영 환경은 배포 시 같은 값을 반영해야 한다.
|
||||||
|
|
||||||
|
바이칼은 관리자용 원본 응답을 보관하고 요청/원천 ID와 연결해 최종 확정·수정 이유·
|
||||||
|
검토자·시각을 기록한다. 저자용 JSON에는 관리자의 확정에 필요한 후보가 없으므로
|
||||||
|
그것만 보관하면 케이스 정확도 평가를 할 수 없다. 감사 로그 5년 저장은 API UUID를
|
||||||
|
추가했다고 구현되는 기능이 아니며 바이칼 저장 계층에서 별도 구현해야 한다.
|
||||||
48
docs/CASE_MATCHING_KPI.md
Normal file
48
docs/CASE_MATCHING_KPI.md
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
# 케이스 매칭 정확도 — 합의안 (2026-09-18)
|
||||||
|
|
||||||
|
상태: 컴북스·바이칼 합의 전. 평가 코드와 목표치는 아직 확정하지 않는다.
|
||||||
|
이는 표절 검출 정밀도(성능지표 4)와 별도 지표다.
|
||||||
|
|
||||||
|
## 평가 단위와 기록
|
||||||
|
|
||||||
|
한 요청의 한 매칭 원천에 대한 관리자 확정을 1건으로 한다. 요청 전체에 여러
|
||||||
|
매칭이 있으면 원천별로 평가한다. 로그에는 request_id, doc_id, 원천 문서·세그먼트 ID,
|
||||||
|
engine_version, taxonomy_version, 분석 시각, 당시 자동 대표 코드, 순서 있는 전체 후보,
|
||||||
|
후보별 출간 판정, 관리자 최종 케이스 집합·출간 판정, 검토자·확정 시각·수정 사유를 남긴다.
|
||||||
|
재검사 요청과 재확정을 중복 분모로 세지 않도록 평가 대상 요청/매칭 ID를 고정하고,
|
||||||
|
집계 마감 시점의 마지막 확정만 사용한다. 원래 자동 예측은 덮어쓰지 않는다.
|
||||||
|
5년 보존·접근 제어·삭제 정책 구현은 바이칼 DB 담당이다.
|
||||||
|
|
||||||
|
## 제안 산식
|
||||||
|
|
||||||
|
- 평가 대상 N: 동일 taxonomy 버전에서 유효한 최종 케이스 집합 G가 확정된 매칭 수.
|
||||||
|
미확정·보류·버전 변환 불가 로그는 제외하고 제외 사유별 건수를 별도 보고한다.
|
||||||
|
- top-1 일치율 = 자동 대표 코드가 G에 포함된 건수 / N.
|
||||||
|
현 case_id는 태그 동점군의 첫 항목이지 확률 순위 1위가 아니다. 동점 여부와
|
||||||
|
후보 수별 성적을 함께 보고하고, 이를 모델의 순위 정확도로 해석하지 않는다.
|
||||||
|
- 후보군 포함률(hit@k) = 상위 k개 후보 집합 Ck와 G의 교집합이 비어 있지 않은 건수 / N.
|
||||||
|
단일 정답에서는 recall@k와 같다. 복수 정답에서는 별도로
|
||||||
|
macro recall@k = sum(|Ck ∩ G| / |G|) / N을 보고한다.
|
||||||
|
- 전체 후보 포함률도 별도 보고한다. 후보 동률을 임의 절단한 k 결과에는 절단 사실을 표시한다.
|
||||||
|
- 후보 누락/기권은 G가 존재하는 한 분모에 포함하고 실패로 센다.
|
||||||
|
관리자가 '해당 케이스 없음'으로 확정한 로그는 별도 음성 집단으로 두고
|
||||||
|
무후보 정확률 및 잘못 부착한 비율을 보고한다(양성 분모와 섞지 않음).
|
||||||
|
- 출간 판정 일치율은 코드표·보수적 우선순위 합의 후 별도 측정한다.
|
||||||
|
미확보 null을 출간 허용이나 정답으로 취급하지 않는다.
|
||||||
|
- N=0은 null/측정 불가. 모든 지표에 분자·분모, 평가 기간, 데이터/엔진/분류체계
|
||||||
|
버전, 미확정 수, 후보 수 분포를 함께 기재한다.
|
||||||
|
|
||||||
|
확정할 사항: 단위(원천별/요청별), 복수 정답 허용, 음성 정의, 동점 처리, k 값,
|
||||||
|
버전 간 코드 대응, 관리자 간 불일치 조정, 대표 판정 우선순위. 97% 목표를 전용하지 않는다.
|
||||||
|
|
||||||
|
## 기존 정밀도와 구분
|
||||||
|
|
||||||
|
운영 코퍼스의 71.7%는 회의 자료상 76/(76+30)=76/106이다. 7,786은 검사 모집단이며
|
||||||
|
정밀도의 분모가 아니다. 현 코퍼스 검출 수만으로 TP/FP를 만들 수 없다.
|
||||||
|
전체 새 검출 결과의 동일 기준 사람 검토와 문서/쌍 단위 합의가 필요하다.
|
||||||
|
|
||||||
|
별도 평가셋 성능지표 4는 기존 scripts/run_precision_eval.py의
|
||||||
|
precision=TP/(TP+FP), recall=TP/(TP+FN), F1을 그대로 사용한다.
|
||||||
|
시험셋은 scripts/build_plagiarism_testset.py로 만든 작성자 분리·규칙 변형 평가셋이다.
|
||||||
|
운영 표본의 사람 판정 수치와 공인인증 목표를 같은 표본의 성적으로 비교하지 않는다.
|
||||||
|
기존 docs/PERF_EVIDENCE_CAPTURE_2026.md의 산식과 평가 경로를 변경하지 않는다.
|
||||||
81
docs/COMBOOKS_CASE_MATCHING_REPLY_20260918.md
Normal file
81
docs/COMBOOKS_CASE_MATCHING_REPLY_20260918.md
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
# 컴북스 월례회의 후속 회신 초안 — 2026-09-18
|
||||||
|
|
||||||
|
외부 발송 전 초안. 브리핑 CASE_MATCHING_BRIEF.md 전체와 저장소를 대조했다.
|
||||||
|
52a0fdc의 동점 후보 반환·대표판례 적재·태그 확장은 재작업하지 않았다.
|
||||||
|
|
||||||
|
## 1. 우선 재검사
|
||||||
|
|
||||||
|
King 52a0fdc 운영 엔진의 연속 일치 필수 게이트(true), 최소 35자 설정을 확인했다.
|
||||||
|
현재 corpus.sqlite3의 검색 세그먼트는 8,025개지만 기존 배치의 검사 대상은
|
||||||
|
6,343건이다. 회의 자료 7,786편과 동일한 모집단이 아니므로 직접 전후 비교 불가다.
|
||||||
|
현재 스냅샷으로 별도 재검사를 시작했으나 모집단 불일치를 확인하고 중단했다.
|
||||||
|
부분 실행은 성적으로 집계하지 않는다. 6,343건은 자서전 6,331건 + 생활수기 12건이다.
|
||||||
|
기존 106건의 정답 라벨 및 새 검출분의 검토 없이 오탐 수/정밀도를 추정하지 않는다.
|
||||||
|
|
||||||
|
## 2. 요청 자료 — 수령 전 데이터 변경 보류
|
||||||
|
|
||||||
|
| 요청 | 목적/확인 조건 |
|
||||||
|
|---|---|
|
||||||
|
| 아카이빙 DB v2.3 엑셀 9시트 및 매핑표/구축보고서 v2.2 | A그룹 28번째 정의의 코드·제목·태그·처리·대표판례 확인. A28이라고 가정하지 않음 |
|
||||||
|
| 출간 판정 9종 코드표, 라벨확정본 v6, 40건별 대응표 | 코드/뜻/출처, 동점 후보의 보수적 우선순위 및 동률·미확정 처리 합의 |
|
||||||
|
| 확보 판례 132건의 원문/출처 URL, 중복 31건과 무관 4건의 제외 명세 | 545+132−31−4=642 대조, 신규 97건 식별. URL 없는 건 적재 제외 |
|
||||||
|
| 원래 검사한 7,786편의 ID 목록·스냅샷·자기매칭 제외 규칙 | 현재 6,343건과 차이 확인, 동일 조건 재검사 |
|
||||||
|
| 기존 106건의 76/30 사람 판정과 정정본 v1.1, 라벨 v6의 행 ID | 새 검사 결과와 연결해 TP/FP 실측. '0자 22건'과 코드 주석 '29건' 차이 확인 |
|
||||||
|
| 관리자 최종 확정 로그 스키마 및 케이스/출간 판정 합의 | CASE_MATCHING_KPI.md 산식 확정 |
|
||||||
|
|
||||||
|
현재 39건(A 27건)과 판례 545건을 유지한다. 39/27 고정 테스트를 느슨하게 바꾸지 않는다.
|
||||||
|
대표판례 출처 공백 14건은 PRECEDENT_SOURCE_GAP.md 참조. 신규 97건에 포함된다고 단정하지 않는다.
|
||||||
|
|
||||||
|
## 3. 전용 방지 장치 공백 7건에 대한 기술 회신
|
||||||
|
|
||||||
|
| 케이스 | 현 엔진 근거와 범위 | 요청/사람 확인 |
|
||||||
|
|---|---|---|
|
||||||
|
| A6 유명인 자서전 베끼기 | ●, 태그 후보 도달 가능 | 2006나16757 출처 필요; 원저작물 종류 확인 |
|
||||||
|
| A15 교과서·교재 수록 | ●, 태그 후보 도달 가능 | 교재 여부·권리/이용허락 확인 |
|
||||||
|
| A16 외국 도서 번역 수록 | ●, 태그 후보 도달 가능 | 2007가합43936 출처 필요; 번역·원본 관계 확인 |
|
||||||
|
| A13 강연·설교 녹음 | ○, 현 태그 경로 미도달 | 녹음 대상/동의/이용 사실, 텍스트화 및 사실 입력 필요 |
|
||||||
|
| A22 단톡방 캡처 | ○, 현 태그 경로 미도달 | 대화 공개 범위·동의·권리자·사용 사실 입력 필요 |
|
||||||
|
| B4 AI 학습 무단 수집 | ○, 현 태그 경로 미도달 | 실제 수집/학습·권리 관계 증빙 필요 |
|
||||||
|
| E2 사후 미공표 일기 | ○, 현 태그 경로 미도달 | 사망·미공표·유족/권리 관계 확인 필요 |
|
||||||
|
|
||||||
|
A6·A15·A16은 '기술 후보 검출 공백' 목록에서 제외 요청한다. 후보 도달은 전용 탐지기의
|
||||||
|
정확도나 법적 침해 확정을 증명하지 않는다. 나머지 네 건은 사람 확인이 전제다.
|
||||||
|
현재 LegalContext는 접근·표현 검토·권리 확인만 지원하므로 위 상세 사실을 넣으면
|
||||||
|
자동 해결된다고 약속할 수 없다. 별도 사실 필드·규칙·근거 라벨 합의 후 구현한다.
|
||||||
|
|
||||||
|
## 4. API 변경과 남은 연동
|
||||||
|
|
||||||
|
options.audience는 admin(기본, 기존 응답 유지)/author다. author 직렬화는 케이스 코드·후보·
|
||||||
|
태그·판례/법률판단을 제외하고 일치 위치·점수·검출 근거를 제공한다.
|
||||||
|
자유형 ccl_basis는 코드 없는 '확인이 필요한 부분' 문장으로 대체한다.
|
||||||
|
detect 응답 및 배치 항목마다 request_id, engine_version, taxonomy_version, analyzed_at을 제공한다.
|
||||||
|
경량 review 경로도 author 옵션에서는 판례 ID와 자유형 법률 요약을 제거한다.
|
||||||
|
이 옵션은 표시용이며 인증/관리자 권한 확인을 대신하지 않는다. 바이칼 서버에서 사용자 역할에
|
||||||
|
따라 옵션을 설정하고 관리자용 원본 응답을 저자에게 전달하지 않아야 한다.
|
||||||
|
|
||||||
|
39개 케이스와 후보별 publication_verdict 필드를 추가했으나 원본 미확보로 모두 null이다.
|
||||||
|
대표 publication_verdict도 null, 상태는 source_pending이다. 추후 일부 후보에 코드가
|
||||||
|
들어와도 우선순위 합의 전에는 대표를 임의 선정하지 않고 review_required로 표시한다.
|
||||||
|
9종 코드 enum·보수적 대표값 자동 선정은 코드표 수령 후 구현할 미완료 항목이다.
|
||||||
|
분류체계 버전은 사실대로 cases_1.3을 유지한다. 5년 감사 로그 저장은 구현하지 않았다.
|
||||||
|
|
||||||
|
## 5. 별도 평가셋 재측정 완료
|
||||||
|
|
||||||
|
기존 testset_v2(표절 500 + 비표절 500건)를 별도 작업 경로로 복사하고
|
||||||
|
기존 run_precision_eval.py로 새 인덱스를 구축해 전체 재측정했다.
|
||||||
|
TP 493 / FP 8 / TN 492 / FN 7, precision **98.4032%**, recall **98.60%**다.
|
||||||
|
이 결과는 기존 testset_v2 결과와 동일하다. 새로운 독립 시험셋이나 공인인증 성적은 아니다.
|
||||||
|
76/106 운영 정밀도를 대체하지 않으며 7,786편 재검증은 여전히 자료 대기다.
|
||||||
|
|
||||||
|
- 성적서: [CASE_MATCHING_PRECISION_SCORECARD_20260918.json](../reports/CASE_MATCHING_PRECISION_SCORECARD_20260918.json)
|
||||||
|
- 입력 해시·환경·혼동행렬·검사 상태: [CASE_MATCHING_EVAL_20260918.json](../reports/CASE_MATCHING_EVAL_20260918.json)
|
||||||
|
- 연동 계약: [CASE_MATCHING_API.md](CASE_MATCHING_API.md)
|
||||||
|
- 합의할 KPI: [CASE_MATCHING_KPI.md](CASE_MATCHING_KPI.md)
|
||||||
|
|
||||||
|
평가 프로세스 시작 시 코드 기본 버전 문자열은 2.0.0-pdf-v1.2였으므로 성적서도 해당
|
||||||
|
값을 그대로 보존했다. 이번 응답 계약 변경의 새 코드 기본값은 2.2.1-cases-v1.3이며
|
||||||
|
검출 게이트·점수·케이스 매칭 알고리즘에는 변경이 없다.
|
||||||
|
코드 기본값은 King 운영값(.env `ENGINE_VERSION=o2o-plagiarism-2.2.0-persistent-cpu`)보다
|
||||||
|
낮은 번호였다. 성적서·로그 대조 시 운영 버전과 뒤집혀 보이지 않도록 2.2.1 로 맞췄다.
|
||||||
|
관련 회귀 테스트 57개 통과. 변경 파일의 diff 공백 검사 통과.
|
||||||
|
별도 요약 작업 파일은 수정하지 않았으며, King 서비스의 코드/이미지는 배포하지 않았다.
|
||||||
119
reports/CASE_MATCHING_EVAL_20260918.json
Normal file
119
reports/CASE_MATCHING_EVAL_20260918.json
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
{
|
||||||
|
"status": "completed",
|
||||||
|
"evaluation": "separate_testset_precision_not_operational_7786",
|
||||||
|
"command": "python3 scripts/run_precision_eval.py --testset data/eval/case_precision_20260918",
|
||||||
|
"source_testset": "data/eval/testset_v2",
|
||||||
|
"base_commit": "52a0fdcdf041f0b7a0ef6132558b6c464b03090b",
|
||||||
|
"worktree_changes": "case matching response fields; existing unrelated summary work preserved",
|
||||||
|
"started_engine_version": "o2o-plagiarism-2.0.0-pdf-v1.2",
|
||||||
|
"python": "3.14.1",
|
||||||
|
"packages": {
|
||||||
|
"pydantic": "2.12.5",
|
||||||
|
"scikit-learn": "1.9.0",
|
||||||
|
"numpy": "2.4.0",
|
||||||
|
"kiwipiepy": "0.23.2"
|
||||||
|
},
|
||||||
|
"input_sha256": {
|
||||||
|
"index.jsonl": "7512080bcaa67f373d6294cf3ab735e698261b1ba5d06ba17a6a20f5d27036a7",
|
||||||
|
"pairs.jsonl": "aff3b80738f6a1ce72512172c0da8ca9736e91330671e252f8067baf00bceff0",
|
||||||
|
"manifest.json": "d056756ba0cb0fcf1af13bf9634dda3ed75e45acb48b9ff69437d1b0d5a2b47d"
|
||||||
|
},
|
||||||
|
"manifest": {
|
||||||
|
"seed": 20260908,
|
||||||
|
"index_author_ratio": 0.7,
|
||||||
|
"authors": {
|
||||||
|
"total": 290,
|
||||||
|
"index": 203,
|
||||||
|
"query": 87
|
||||||
|
},
|
||||||
|
"counts": {
|
||||||
|
"plagiarism": 500,
|
||||||
|
"legitimate": 500,
|
||||||
|
"index_segments": 4033
|
||||||
|
},
|
||||||
|
"plagiarism_mix": {
|
||||||
|
"verbatim": 100,
|
||||||
|
"partial": 100,
|
||||||
|
"sentence_shuffle": 100,
|
||||||
|
"lexical_swap": 150,
|
||||||
|
"compress": 50
|
||||||
|
},
|
||||||
|
"hard_negatives": 150,
|
||||||
|
"lexical_swap_rate": 0.35,
|
||||||
|
"excluded": "대필(인칭 전환)은 계약·동의로 표절 여부가 갈려 텍스트만으로 판정할 수 없어 제외"
|
||||||
|
},
|
||||||
|
"operational_rerun": {
|
||||||
|
"status": "stopped_cohort_mismatch",
|
||||||
|
"expected": 7786,
|
||||||
|
"observed_queries": 6343,
|
||||||
|
"autobiography_episodes": 6331,
|
||||||
|
"life_writing_documents": 12,
|
||||||
|
"indexed_segments": 8025,
|
||||||
|
"precision": null,
|
||||||
|
"reason": "Original cohort and keyed human labels required; partial run excluded."
|
||||||
|
},
|
||||||
|
"result": {
|
||||||
|
"precision": 0.9840319361277445,
|
||||||
|
"recall": 0.986,
|
||||||
|
"f1": 0.9850149850149851,
|
||||||
|
"tp": 493,
|
||||||
|
"fp": 8,
|
||||||
|
"tn": 492,
|
||||||
|
"fn": 7,
|
||||||
|
"by_transformation": {
|
||||||
|
"verbatim": {
|
||||||
|
"tp": 100,
|
||||||
|
"fp": 0,
|
||||||
|
"tn": 0,
|
||||||
|
"fn": 0
|
||||||
|
},
|
||||||
|
"sentence_shuffle": {
|
||||||
|
"tp": 100,
|
||||||
|
"fp": 0,
|
||||||
|
"tn": 0,
|
||||||
|
"fn": 0
|
||||||
|
},
|
||||||
|
"legitimate": {
|
||||||
|
"tp": 0,
|
||||||
|
"fp": 1,
|
||||||
|
"tn": 349,
|
||||||
|
"fn": 0
|
||||||
|
},
|
||||||
|
"lexical_swap": {
|
||||||
|
"tp": 144,
|
||||||
|
"fp": 0,
|
||||||
|
"tn": 0,
|
||||||
|
"fn": 6
|
||||||
|
},
|
||||||
|
"partial": {
|
||||||
|
"tp": 99,
|
||||||
|
"fp": 0,
|
||||||
|
"tn": 0,
|
||||||
|
"fn": 1
|
||||||
|
},
|
||||||
|
"compress": {
|
||||||
|
"tp": 50,
|
||||||
|
"fp": 0,
|
||||||
|
"tn": 0,
|
||||||
|
"fn": 0
|
||||||
|
},
|
||||||
|
"hard_negative": {
|
||||||
|
"tp": 0,
|
||||||
|
"fp": 7,
|
||||||
|
"tn": 143,
|
||||||
|
"fn": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"criteria": {
|
||||||
|
"similarity": 0.65,
|
||||||
|
"min_exact_span": 35,
|
||||||
|
"min_coverage": 0.3,
|
||||||
|
"require_exact_span_evidence": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tests": {
|
||||||
|
"passed": 57,
|
||||||
|
"command": "python3 -m pytest tests/test_case_matching_views.py tests/test_pdf_compliance.py tests/test_case_coverage.py tests/test_api.py tests/test_legal_risk.py tests/test_review_regressions.py -q"
|
||||||
|
},
|
||||||
|
"scorecard": "reports/CASE_MATCHING_PRECISION_SCORECARD_20260918.json"
|
||||||
|
}
|
||||||
34
reports/CASE_MATCHING_PRECISION_SCORECARD_20260918.json
Normal file
34
reports/CASE_MATCHING_PRECISION_SCORECARD_20260918.json
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"plagiarism_detection_performance": {
|
||||||
|
"model": "O2O Triple-Similarity Detector",
|
||||||
|
"engine_version": "o2o-plagiarism-2.0.0-pdf-v1.2",
|
||||||
|
"test_dataset": {
|
||||||
|
"total_samples": 1000,
|
||||||
|
"plagiarism_cases": 500,
|
||||||
|
"non_plagiarism_cases": 500
|
||||||
|
},
|
||||||
|
"confusion_matrix": {
|
||||||
|
"true_positive": 493,
|
||||||
|
"false_positive": 8,
|
||||||
|
"true_negative": 492,
|
||||||
|
"false_negative": 7
|
||||||
|
},
|
||||||
|
"performance_metrics": {
|
||||||
|
"precision": 0.984,
|
||||||
|
"recall": 0.986,
|
||||||
|
"f1_score": 0.985,
|
||||||
|
"accuracy": 0.985
|
||||||
|
},
|
||||||
|
"threshold": {
|
||||||
|
"combined_similarity": 0.65,
|
||||||
|
"min_exact_span_chars": 35,
|
||||||
|
"min_coverage": 0.3,
|
||||||
|
"require_exact_span_evidence": true
|
||||||
|
},
|
||||||
|
"interpretation": {
|
||||||
|
"precision": "모델이 표절로 판단한 501건 중 493건(98.4%)이 실제 표절",
|
||||||
|
"recall": "실제 표절 500건 중 493건(98.6%)을 정확히 탐지",
|
||||||
|
"false_positive_rate": "1.6%"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
113
tests/test_case_matching_views.py
Normal file
113
tests/test_case_matching_views.py
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.api.schemas import (BatchStatusResponse, CaseCandidate, DetectResponse,
|
||||||
|
EvidenceSpan, ExtractedElements, LegalRiskSignal, MatchResult)
|
||||||
|
from app.engine.taxonomy import load_taxonomy
|
||||||
|
|
||||||
|
|
||||||
|
def response(audience):
|
||||||
|
return DetectResponse(
|
||||||
|
audience=audience, doc_id="test", is_infringement=True, confidence=.9,
|
||||||
|
extracted_elements=ExtractedElements(), engine_version="test-engine",
|
||||||
|
taxonomy_version="cases_1.3", analyzed_at=datetime.now(timezone.utc),
|
||||||
|
ccl_basis="A6 판례 2006나16757",
|
||||||
|
legal_risk=LegalRiskSignal(
|
||||||
|
status="review_required", similarity_evidence="A6",
|
||||||
|
protected_expression="not_reviewed", access_evidence="not_provided",
|
||||||
|
precedent_ids=["2006나16757"], judgment_summary="A6 2006나16757",
|
||||||
|
supporting_reasons=["A6 2006나16757"], disclaimer="검토 필요"),
|
||||||
|
matches=[MatchResult(source_doc="source", similarity=.9, case_id="A6",
|
||||||
|
case_title="case", evidence_spans=[EvidenceSpan(start=0,end=3,matched="일치문")],
|
||||||
|
case_candidates=[CaseCandidate(case_id="A6",title="case",
|
||||||
|
handling="technical_detection", representative_precedents=["2006나16757"])])],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_author_http_and_batch_serialization():
|
||||||
|
app = FastAPI()
|
||||||
|
@app.get('/detect', response_model=DetectResponse)
|
||||||
|
def detect():
|
||||||
|
return response('author')
|
||||||
|
@app.get('/batch', response_model=BatchStatusResponse)
|
||||||
|
def batch():
|
||||||
|
return BatchStatusResponse(job_id='job',status='completed',total=1,processed=1,
|
||||||
|
created_at=datetime.now(timezone.utc),results=[response('author')])
|
||||||
|
with TestClient(app) as client:
|
||||||
|
direct=client.get('/detect').json()
|
||||||
|
nested=client.get('/batch').json()['results'][0]
|
||||||
|
for body in (direct,nested):
|
||||||
|
assert 'A6' not in str(body) and '2006나16757' not in str(body)
|
||||||
|
assert 'case_id' not in body['matches'][0]
|
||||||
|
assert 'legal_risk' not in body
|
||||||
|
assert body['matches'][0]['evidence_spans'][0]['start']==0
|
||||||
|
assert body['request_id'] and body['taxonomy_version']=='cases_1.3'
|
||||||
|
assert body['ccl_basis']=='확인이 필요한 부분이 있습니다.'
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_preserves_candidates_and_unknown_verdict():
|
||||||
|
body=response('admin').model_dump()
|
||||||
|
assert body['matches'][0]['case_id']=='A6'
|
||||||
|
assert body['matches'][0]['case_candidates'][0]['representative_precedents']==['2006나16757']
|
||||||
|
assert body['matches'][0]['publication_verdict'] is None
|
||||||
|
assert body['matches'][0]['publication_verdict_status']=='source_pending'
|
||||||
|
assert response('admin').request_id != response('admin').request_id
|
||||||
|
|
||||||
|
|
||||||
|
def test_taxonomy_does_not_invent_missing_verdicts():
|
||||||
|
tax=load_taxonomy(Path('data/taxonomy'))
|
||||||
|
assert len(tax.cases)==39
|
||||||
|
assert all(c.publication_verdict is None for c in tax.cases)
|
||||||
|
|
||||||
|
|
||||||
|
def test_real_detector_forwards_audience_for_direct_batch_and_review(tmp_path):
|
||||||
|
from app.main import app
|
||||||
|
text = Path('data/reference/ref-0001__어린왕자.txt').read_text()
|
||||||
|
from scripts.run_precision_eval import build_corpus
|
||||||
|
from app.engine.persistent_index import PersistentCorpusIndex
|
||||||
|
from app.engine.detector import PlagiarismDetector
|
||||||
|
from app.core.config import get_settings
|
||||||
|
db = tmp_path / 'corpus.sqlite3'
|
||||||
|
index = tmp_path / 'index'
|
||||||
|
build_corpus([{'author':'reference','segment_id':'ref-1','text':text}], db)
|
||||||
|
PersistentCorpusIndex(db, index).sync()
|
||||||
|
settings = get_settings().model_copy(update={
|
||||||
|
'use_persistent_index':True,'corpus_db_path':str(db),
|
||||||
|
'persistent_index_dir':str(index)})
|
||||||
|
with TestClient(app) as client:
|
||||||
|
app.state.detector = PlagiarismDetector(settings)
|
||||||
|
payload={'doc_id':'audience-test','text':text,'options':{'audience':'author','threshold':0}}
|
||||||
|
direct=client.post('/v1/plagiarism/detect',json=payload)
|
||||||
|
assert direct.status_code==200
|
||||||
|
body=direct.json()
|
||||||
|
assert body['audience']=='author' and body['matches']
|
||||||
|
assert all('case_candidates' not in m for m in body['matches'])
|
||||||
|
batch=client.post('/v1/plagiarism/batch',json={
|
||||||
|
'items':[{'doc_id':'batch-author','text':text}],
|
||||||
|
'options':{'audience':'author','threshold':0}})
|
||||||
|
result=client.get('/v1/plagiarism/batch/'+batch.json()['job_id']).json()
|
||||||
|
assert result['status']=='completed'
|
||||||
|
assert result['results'][0]['audience']=='author'
|
||||||
|
assert 'legal_risk' not in result['results'][0]
|
||||||
|
review=client.post('/v1/plagiarism/review',json=payload).json()
|
||||||
|
assert review['legal_judgment']['precedent_ids']==[]
|
||||||
|
assert review['legal_judgment']['label']=='확인이 필요한 부분'
|
||||||
|
admin=client.post('/v1/plagiarism/detect',json={
|
||||||
|
'doc_id':'admin-test','text':text,'options':{'threshold':0}}).json()
|
||||||
|
assert admin['audience']=='admin' and 'legal_risk' in admin
|
||||||
|
assert any(m['case_candidates'] for m in admin['matches'])
|
||||||
|
assert all(c['publication_verdict'] is None for m in admin['matches']
|
||||||
|
for c in m['case_candidates'])
|
||||||
|
|
||||||
|
|
||||||
|
def test_author_no_match_message_and_invalid_audience():
|
||||||
|
import pytest
|
||||||
|
from pydantic import ValidationError
|
||||||
|
from app.api.schemas import DetectOptions
|
||||||
|
result = response('author').model_copy(update={'matches': []})
|
||||||
|
assert result.model_dump()['ccl_basis'] == '등록된 비교 자료에서 일치 구간을 찾지 못했습니다.'
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
DetectOptions(audience='public')
|
||||||
Loading…
Reference in New Issue
Block a user