diff --git a/app/api/routes.py b/app/api/routes.py index 14602a0..18ff72f 100644 --- a/app/api/routes.py +++ b/app/api/routes.py @@ -81,6 +81,7 @@ async def taxonomy(request: Request) -> TaxonomyResponse: 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 @@ -145,6 +146,13 @@ async def copyright_review( 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( @@ -168,7 +176,7 @@ async def copyright_review( legal_judgment=CopyrightLegalJudgment( status=legal_status, label=legal_label, - summary=legal.judgment_summary, + summary=judgment_summary, precedent_ids=precedent_ids, ), analyzed_at=result.analyzed_at, diff --git a/app/api/schemas.py b/app/api/schemas.py index 44ffc55..b81b54c 100644 --- a/app/api/schemas.py +++ b/app/api/schemas.py @@ -2,8 +2,9 @@ from __future__ import annotations from datetime import datetime from typing import Literal +from uuid import uuid4 -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, model_serializer # 법령 기반 10종 메타 태그 (PDF IV장) LegalTag = Literal[ @@ -47,6 +48,7 @@ class DocumentMetadata(BaseModel): class DetectOptions(BaseModel): + audience: Literal["admin", "author"] = "admin" return_evidence: bool = True threshold: float | None = Field(default=None, ge=0.0, le=1.0, description="None이면 서버 설정 사용. PDF VII-4 권장 0.85") @@ -124,6 +126,7 @@ class PartialPlagiarismSignal(BaseModel): class CaseCandidate(BaseModel): """판정된 침해 케이스 후보 1건 + v1.3 총괄표의 대표판례.""" + publication_verdict: str | None = Field(default=None, description="컴북스 확정 코드. 원본 미확보 시 null; 출간 허용을 뜻하지 않음.") case_id: str title: str handling: Literal["technical_detection", "terms_or_report"] @@ -138,6 +141,8 @@ class CaseCandidate(BaseModel): class MatchResult(BaseModel): + publication_verdict: str | None = None + publication_verdict_status: Literal["source_pending", "review_required"] = "source_pending" source_doc: str source_title: str | None = None similarity: float = Field(..., ge=0.0, le=1.0) @@ -299,7 +304,29 @@ class ReviewSummary(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 + #: 후방호환 검출값. 항상 설정되며, 저자용 직렬화에서만 출력에서 제외된다. + #: 소비자(run_precision_eval.py, evaluate_pairs.py 등)가 bool 로 읽으므로 필수로 둔다. is_infringement: bool confidence: float = Field(..., ge=0.0, le=1.0) extracted_elements: ExtractedElements diff --git a/app/core/config.py b/app/core/config.py index 61c8f38..74cd3d4 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -25,7 +25,7 @@ class Settings(BaseSettings): public_health: bool = True # /v1/health 를 인증 없이 공개할지 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" taxonomy_dir: str = "./data/taxonomy" autobiography_patterns_path: str = "./data/autobiography/common_patterns.txt" diff --git a/app/engine/detector.py b/app/engine/detector.py index 420c238..90816c2 100644 --- a/app/engine/detector.py +++ b/app/engine/detector.py @@ -396,6 +396,8 @@ class PlagiarismDetector: ) return DetectResponse( + audience=opts.audience, + taxonomy_version=(f"cases_{self.taxonomy.cases_version}" if self.taxonomy else None), doc_id=doc_id, is_infringement=is_infringement, confidence=round(confidence, 4), @@ -612,6 +614,7 @@ class PlagiarismDetector: case_id=c.case_id, title=c.title, handling=c.handling, + publication_verdict=c.publication_verdict, representative_precedents=[p.case_id for p in c.representative_precedents], precedents_without_source=[ 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_title=cases[0].title if cases else None, 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, evidence_spans=hit.evidence if return_evidence else [], score_breakdown=ScoreBreakdown( diff --git a/app/engine/taxonomy.py b/app/engine/taxonomy.py index 2ee95c8..d172786 100644 --- a/app/engine/taxonomy.py +++ b/app/engine/taxonomy.py @@ -53,6 +53,7 @@ class CaseDef: representative_precedents: tuple[RepresentativePrecedent, ...] = field(default_factory=tuple) #: 대표판례가 지정되지 않은 이유("확립 판례 없음" 등). 빈 목록과 구분한다. representative_precedent_note: str | None = None + publication_verdict: str | None = None @dataclass(frozen=True) @@ -136,6 +137,7 @@ def load_taxonomy(taxonomy_dir: Path) -> Taxonomy | None: for p in c.get("representative_precedents", []) ), representative_precedent_note=c.get("representative_precedent_note"), + publication_verdict=c.get("publication_verdict"), ) for c in cs_data["cases"] ) diff --git a/data/taxonomy/cases_v1.3.json b/data/taxonomy/cases_v1.3.json index 44b5ed1..f7270a3 100644 --- a/data/taxonomy/cases_v1.3.json +++ b/data/taxonomy/cases_v1.3.json @@ -28,7 +28,8 @@ "in_runtime_corpus": false } ], - "representative_precedent_note": null + "representative_precedent_note": null, + "publication_verdict": null }, { "case_id": "A2", @@ -56,7 +57,8 @@ "in_runtime_corpus": true } ], - "representative_precedent_note": null + "representative_precedent_note": null, + "publication_verdict": null }, { "case_id": "A3", @@ -80,7 +82,8 @@ "in_runtime_corpus": false } ], - "representative_precedent_note": null + "representative_precedent_note": null, + "publication_verdict": null }, { "case_id": "A4", @@ -104,7 +107,8 @@ "in_runtime_corpus": true } ], - "representative_precedent_note": null + "representative_precedent_note": null, + "publication_verdict": null }, { "case_id": "A5", @@ -128,7 +132,8 @@ "in_runtime_corpus": true } ], - "representative_precedent_note": null + "representative_precedent_note": null, + "publication_verdict": null }, { "case_id": "A6", @@ -152,7 +157,8 @@ "in_runtime_corpus": false } ], - "representative_precedent_note": null + "representative_precedent_note": null, + "publication_verdict": null }, { "case_id": "A7", @@ -177,7 +183,8 @@ "in_runtime_corpus": false } ], - "representative_precedent_note": null + "representative_precedent_note": null, + "publication_verdict": null }, { "case_id": "A8", @@ -201,7 +208,8 @@ "in_runtime_corpus": false } ], - "representative_precedent_note": null + "representative_precedent_note": null, + "publication_verdict": null }, { "case_id": "A9", @@ -225,7 +233,8 @@ "in_runtime_corpus": false } ], - "representative_precedent_note": null + "representative_precedent_note": null, + "publication_verdict": null }, { "case_id": "A10", @@ -255,7 +264,8 @@ "in_runtime_corpus": true } ], - "representative_precedent_note": null + "representative_precedent_note": null, + "publication_verdict": null }, { "case_id": "A11", @@ -273,7 +283,8 @@ "detectable_internal": false, "handling": "terms_or_report", "representative_precedents": [], - "representative_precedent_note": "공표권·복제 법리" + "representative_precedent_note": "공표권·복제 법리", + "publication_verdict": null }, { "case_id": "A12", @@ -291,7 +302,8 @@ "detectable_internal": false, "handling": "terms_or_report", "representative_precedents": [], - "representative_precedent_note": "확립 판례 없음" + "representative_precedent_note": "확립 판례 없음", + "publication_verdict": null }, { "case_id": "A13", @@ -311,7 +323,8 @@ "high_risk": true, "handling": "terms_or_report", "representative_precedents": [], - "representative_precedent_note": "확립 판례 없음" + "representative_precedent_note": "확립 판례 없음", + "publication_verdict": null }, { "case_id": "A14", @@ -335,7 +348,8 @@ "in_runtime_corpus": false } ], - "representative_precedent_note": null + "representative_precedent_note": null, + "publication_verdict": null }, { "case_id": "A15", @@ -359,7 +373,8 @@ "in_runtime_corpus": true } ], - "representative_precedent_note": null + "representative_precedent_note": null, + "publication_verdict": null }, { "case_id": "A16", @@ -388,7 +403,8 @@ "in_runtime_corpus": false } ], - "representative_precedent_note": null + "representative_precedent_note": null, + "publication_verdict": null }, { "case_id": "A17", @@ -415,7 +431,8 @@ "in_runtime_corpus": false } ], - "representative_precedent_note": null + "representative_precedent_note": null, + "publication_verdict": null }, { "case_id": "A18", @@ -435,7 +452,8 @@ "in_runtime_corpus": false } ], - "representative_precedent_note": null + "representative_precedent_note": null, + "publication_verdict": null }, { "case_id": "A19", @@ -462,7 +480,8 @@ "in_runtime_corpus": false } ], - "representative_precedent_note": null + "representative_precedent_note": null, + "publication_verdict": null }, { "case_id": "A20", @@ -480,7 +499,8 @@ "detectable_internal": false, "handling": "terms_or_report", "representative_precedents": [], - "representative_precedent_note": "어문 밖(상표)" + "representative_precedent_note": "어문 밖(상표)", + "publication_verdict": null }, { "case_id": "A21", @@ -504,7 +524,8 @@ "in_runtime_corpus": false } ], - "representative_precedent_note": null + "representative_precedent_note": null, + "publication_verdict": null }, { "case_id": "A22", @@ -524,7 +545,8 @@ "high_risk": true, "handling": "terms_or_report", "representative_precedents": [], - "representative_precedent_note": "확립 판례 없음" + "representative_precedent_note": "확립 판례 없음", + "publication_verdict": null }, { "case_id": "A23", @@ -551,7 +573,8 @@ "in_runtime_corpus": true } ], - "representative_precedent_note": null + "representative_precedent_note": null, + "publication_verdict": null }, { "case_id": "A24", @@ -569,7 +592,8 @@ "detectable_internal": false, "handling": "terms_or_report", "representative_precedents": [], - "representative_precedent_note": "확립 판례 없음" + "representative_precedent_note": "확립 판례 없음", + "publication_verdict": null }, { "case_id": "A25", @@ -591,7 +615,8 @@ "in_runtime_corpus": true } ], - "representative_precedent_note": null + "representative_precedent_note": null, + "publication_verdict": null }, { "case_id": "A26", @@ -613,7 +638,8 @@ "in_runtime_corpus": false } ], - "representative_precedent_note": null + "representative_precedent_note": null, + "publication_verdict": null }, { "case_id": "A27", @@ -630,7 +656,8 @@ "detectable_internal": false, "handling": "terms_or_report", "representative_precedents": [], - "representative_precedent_note": "계약·성명표시(판례 약함)" + "representative_precedent_note": "계약·성명표시(판례 약함)", + "publication_verdict": null }, { "case_id": "B1", @@ -656,7 +683,8 @@ "in_runtime_corpus": true } ], - "representative_precedent_note": null + "representative_precedent_note": null, + "publication_verdict": null }, { "case_id": "B2", @@ -680,7 +708,8 @@ "in_runtime_corpus": false } ], - "representative_precedent_note": null + "representative_precedent_note": null, + "publication_verdict": null }, { "case_id": "B3", @@ -708,7 +737,8 @@ "in_runtime_corpus": false } ], - "representative_precedent_note": null + "representative_precedent_note": null, + "publication_verdict": null }, { "case_id": "B4", @@ -725,7 +755,8 @@ "high_risk": true, "handling": "terms_or_report", "representative_precedents": [], - "representative_precedent_note": "확립 판례 없음" + "representative_precedent_note": "확립 판례 없음", + "publication_verdict": null }, { "case_id": "C1", @@ -748,7 +779,8 @@ "in_runtime_corpus": true } ], - "representative_precedent_note": null + "representative_precedent_note": null, + "publication_verdict": null }, { "case_id": "C2", @@ -770,7 +802,8 @@ "in_runtime_corpus": true } ], - "representative_precedent_note": null + "representative_precedent_note": null, + "publication_verdict": null }, { "case_id": "C3", @@ -789,7 +822,8 @@ "high_risk": true, "handling": "terms_or_report", "representative_precedents": [], - "representative_precedent_note": "확립 판례 없음" + "representative_precedent_note": "확립 판례 없음", + "publication_verdict": null }, { "case_id": "D1", @@ -818,7 +852,8 @@ "in_runtime_corpus": true } ], - "representative_precedent_note": null + "representative_precedent_note": null, + "publication_verdict": null }, { "case_id": "E1", @@ -845,7 +880,8 @@ "in_runtime_corpus": true } ], - "representative_precedent_note": null + "representative_precedent_note": null, + "publication_verdict": null }, { "case_id": "E2", @@ -868,7 +904,8 @@ "in_runtime_corpus": false } ], - "representative_precedent_note": null + "representative_precedent_note": null, + "publication_verdict": null }, { "case_id": "X1", @@ -882,7 +919,8 @@ "note": "사생활 침해 영역, 약관·운영 절차로 처리", "handling": "terms_or_report", "representative_precedents": [], - "representative_precedent_note": "저작권 밖(개인정보)" + "representative_precedent_note": "저작권 밖(개인정보)", + "publication_verdict": null }, { "case_id": "X2", @@ -901,7 +939,8 @@ "in_runtime_corpus": false } ], - "representative_precedent_note": null + "representative_precedent_note": null, + "publication_verdict": null } ] } diff --git a/docs/CASE_MATCHING_API.md b/docs/CASE_MATCHING_API.md new file mode 100644 index 0000000..dcd7c9a --- /dev/null +++ b/docs/CASE_MATCHING_API.md @@ -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를 +추가했다고 구현되는 기능이 아니며 바이칼 저장 계층에서 별도 구현해야 한다. diff --git a/docs/CASE_MATCHING_KPI.md b/docs/CASE_MATCHING_KPI.md new file mode 100644 index 0000000..16e6a0d --- /dev/null +++ b/docs/CASE_MATCHING_KPI.md @@ -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의 산식과 평가 경로를 변경하지 않는다. diff --git a/docs/COMBOOKS_CASE_MATCHING_REPLY_20260918.md b/docs/COMBOOKS_CASE_MATCHING_REPLY_20260918.md new file mode 100644 index 0000000..81a7c41 --- /dev/null +++ b/docs/COMBOOKS_CASE_MATCHING_REPLY_20260918.md @@ -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 서비스의 코드/이미지는 배포하지 않았다. diff --git a/reports/CASE_MATCHING_EVAL_20260918.json b/reports/CASE_MATCHING_EVAL_20260918.json new file mode 100644 index 0000000..b4c4e54 --- /dev/null +++ b/reports/CASE_MATCHING_EVAL_20260918.json @@ -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" +} diff --git a/reports/CASE_MATCHING_PRECISION_SCORECARD_20260918.json b/reports/CASE_MATCHING_PRECISION_SCORECARD_20260918.json new file mode 100644 index 0000000..7abbcc9 --- /dev/null +++ b/reports/CASE_MATCHING_PRECISION_SCORECARD_20260918.json @@ -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%" + } + } +} diff --git a/tests/test_case_matching_views.py b/tests/test_case_matching_views.py new file mode 100644 index 0000000..b01cda9 --- /dev/null +++ b/tests/test_case_matching_views.py @@ -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')