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

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

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

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

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

787 lines
35 KiB
Python

"""저작권 침해 탐지 파이프라인 (PDF VII장 권장 아키텍처).
3단 캐스케이딩:
1차) MinHash + LSH 1차 필터 — 대규모 코퍼스에서 후보 N건 빠르게 추출
2차) 자서전 모드 전처리 (옵션) — 공통 표현 제거 + NER 마스킹
3차) 삼중 유사도 정밀 비교 — text(임베딩) + lemma(형태소) + element(자카드)
4차) 분류 — 10종 법령 메타 태그 (주/보조) + 케이스 매핑
후방 호환:
- infringement_type (5종 enum): 기존 UI/통합 코드용으로 유지
- tags + case_id: PDF 분류체계 신규 필드
"""
from __future__ import annotations
import logging
from datetime import datetime, timezone
from app.api.schemas import (
CaseCandidate,
TAG_LABEL_KO,
AiGenerationSignal,
DetectOptions,
DetectRequest,
DetectResponse,
DocumentMetadata,
ExtractedElements,
InfringementTag,
InfringementType,
LegalContext,
LegalRiskSignal,
MatchResult,
PartialPlagiarismSignal,
ReviewSummary,
ScoreBreakdown,
ScoreSemantics,
)
from app.core.config import Settings, get_settings
from app.engine.autobiography_filter import preprocess_for_autobiography
from app.engine.ai_detector import get_ai_detector
from app.engine.clustering import ClusterIndex
from app.engine.corpus import load_corpus
from app.engine.extractor import Extractor, get_extractor
from app.engine.lsh_filter import LshIndex
from app.engine.legal_risk import LegalRiskEngine, load_precedents
from app.engine.openai_legal_judge import OpenAILegalJudge
from app.engine.persistent_index import PersistentCorpusIndex, PersistentHit
from app.engine.provenance import DocumentRecord, SegmentRecord, stable_id
from app.engine.source_extraction import chunk_with_offsets
from app.engine.similarity import (
DualSimilarityIndex,
SimilarityHit,
build_text_backend,
)
from app.engine.structural import extract_lemmas
from app.engine.taxonomy import Taxonomy, load_taxonomy
logger = logging.getLogger(__name__)
class PlagiarismDetector:
#: 참조 특징 프로세스 캐시 상한. 초과하면 통째로 비운다(단순 LRU 대용).
_FEATURE_CACHE_MAX = 20_000
def __init__(self, settings: Settings | None = None, extractor: Extractor | None = None):
self.settings = settings or get_settings()
self._feature_cache: dict[str, tuple[list[str], "ExtractedElements"]] = {}
self._extractor: Extractor = extractor or get_extractor(self.settings)
self.taxonomy: Taxonomy | None = load_taxonomy(self.settings.taxonomy_path)
legal_judge = None
if self.settings.has_llm_legal_judge:
legal_judge = OpenAILegalJudge(
api_key=self.settings.openai_api_key,
model=self.settings.openai_judge_model,
timeout_seconds=self.settings.llm_judge_timeout_seconds,
max_evidence_chars=self.settings.llm_judge_max_evidence_chars,
)
logger.info("Using OpenAI legal judge (model=%s)", self.settings.openai_judge_model)
elif self.settings.use_llm_legal_judge:
logger.warning(
"USE_LLM_LEGAL_JUDGE=true but OPENAI_API_KEY is empty; using rule-based legal risk"
)
self._legal_engine = LegalRiskEngine(
load_precedents(self.settings.precedent_path), judge=legal_judge,
)
self._ai_detector = get_ai_detector(
self.settings.ai_detector_model_path,
allow_heuristic=self.settings.ai_detector_allow_heuristic,
use_pos=self.settings.ai_detector_use_pos,
low_cut=self.settings.ai_detector_low_cut,
high_cut=self.settings.ai_detector_high_cut,
)
self._persistent: PersistentCorpusIndex | None = None
if self.settings.use_persistent_index:
candidate = PersistentCorpusIndex(
self.settings.corpus_db, self.settings.persistent_index_path,
)
if candidate.ready:
self._persistent = candidate.load()
self._corpus = []
self._corpus_preprocessed_texts = []
self._corpus_elements = []
self._corpus_lemmas = []
self._index = None
self._lsh = None
self._cluster = None
self._docs_by_id = {}
logger.info("Loaded persistent index: %d segments", self._persistent.size)
return
logger.warning(
"USE_PERSISTENT_INDEX=true but index is absent; falling back to text corpus: %s",
self.settings.persistent_index_path,
)
self._corpus = load_corpus(self.settings.corpus_path)
# 자서전 모드면 코퍼스도 동일 전처리 적용 후 인덱싱
logger.info("Building corpus indexes (autobiography_mode=%s)", self.settings.autobiography_mode)
if self.settings.autobiography_mode:
self._corpus_preprocessed_texts = [
preprocess_for_autobiography(
d.text,
self.settings.autobiography_patterns_path,
self.settings.enable_entity_masking,
)
for d in self._corpus
]
else:
self._corpus_preprocessed_texts = [d.text for d in self._corpus]
# 정밀 비교용 인덱스 (전처리된 텍스트 사용)
from app.engine.corpus import ReferenceDoc
preprocessed_docs = [
ReferenceDoc(doc_id=d.doc_id, title=d.title, text=pt)
for d, pt in zip(self._corpus, self._corpus_preprocessed_texts)
]
self._corpus_elements = [self._extractor.extract(t) for t in self._corpus_preprocessed_texts]
self._corpus_lemmas = [extract_lemmas(t) for t in self._corpus_preprocessed_texts]
text_backend = build_text_backend(preprocessed_docs, self.settings)
self._index = DualSimilarityIndex(
docs=preprocessed_docs,
doc_elements=self._corpus_elements,
doc_lemmas=self._corpus_lemmas,
settings=self.settings,
text_backend=text_backend,
)
# 1차 LSH 필터 (PDF VII-3)
self._lsh: LshIndex | None = None
if self.settings.use_lsh_filter:
self._lsh = LshIndex(preprocessed_docs, threshold=self.settings.lsh_threshold)
# 2단계 고도화: 요소 군집화 인덱스 (요소 교체 부분 표절 신호)
self._cluster: ClusterIndex | None = None
if self.settings.use_clustering:
self._cluster = ClusterIndex(
doc_ids=[d.doc_id for d in self._corpus],
doc_elements=self._corpus_elements,
doc_lemmas=self._corpus_lemmas,
link_threshold=self.settings.cluster_link_threshold,
)
# source_doc → ReferenceDoc 매핑
self._docs_by_id = {d.doc_id: d for d in self._corpus}
@property
def corpus_size(self) -> int:
return self._persistent.size if self._persistent else len(self._corpus)
@property
def corpus_document_count(self) -> int:
if self._persistent:
return self._persistent.document_count
return len(self._corpus)
@property
def index_backend(self) -> str:
return "persistent-hashing-char-3-4" if self._persistent else "legacy-in-memory"
@property
def precedent_count(self) -> int:
return len(self._legal_engine.precedents)
@property
def precedents(self):
return tuple(self._legal_engine.precedents)
@property
def ai_model_ready(self) -> bool:
return self._ai_detector.mode == "trained"
@property
def uses_persistent_index(self) -> bool:
return self._persistent is not None
def list_persistent_documents(self) -> list[dict]:
return self._persistent.store.list_documents() if self._persistent else []
def detect(
self,
doc_id: str,
text: str,
metadata: DocumentMetadata | None = None,
options: DetectOptions | None = None,
include_ai_segments: bool = True,
legal_context: LegalContext | None = None,
*,
exclude_document_ids: set[str] | None = None,
exclude_source_groups: set[str] | None = None,
include_ai_detection: bool = True,
) -> DetectResponse:
opts = options or DetectOptions()
default_threshold = (
self.settings.persistent_similarity_threshold
if self._persistent else self.settings.similarity_threshold
)
threshold = opts.threshold if opts.threshold is not None else default_threshold
# 요청 단위 자서전 모드 override
autobio_mode = (
self.settings.autobiography_mode if opts.autobiography_mode is None
else opts.autobiography_mode
)
# 자서전 모드 전처리
query_text = (
preprocess_for_autobiography(
text, self.settings.autobiography_patterns_path,
self.settings.enable_entity_masking,
)
if autobio_mode else text
)
# 요소 추출 (원본 텍스트 기준 — 사용자 검토용)
elements = self._extractor.extract(text)
# 영속 CPU 인덱스: 전량 행렬곱으로 후보를 구하고 상위 후보만 증거 비교한다.
persistent_hits: list[PersistentHit] = []
union_coverage = 0.0
covered_chars = 0
evidence_truncated = False
document_coverage: dict[str, float] = {}
if self._persistent:
persistent_query_lemmas = extract_lemmas(text)
result = self._persistent.search(
text,
top_k=max(opts.top_k, self.settings.persistent_rerank_top_k),
evidence_limit=self.settings.persistent_rerank_top_k,
exclude_document_ids=exclude_document_ids,
exclude_source_groups=exclude_source_groups,
)
persistent_hits = result.hits
union_coverage = result.union_coverage
covered_chars = result.covered_chars
evidence_truncated = result.evidence_truncated
document_coverage = result.document_coverage
# 정밀 특징 비교도 rerank 대상(reranked=True)에만 수행한다 (#5).
hits = [
self._persistent_to_similarity_hit(h, persistent_query_lemmas, elements)
for h in persistent_hits if h.reranked
]
self._backfill_segment_features(persistent_hits)
hits.sort(key=lambda h: h.score, reverse=True)
candidates_count = len(persistent_hits)
lsh_jaccards: dict[str, float] = {}
else:
hits = []
candidates_count = None
lsh_jaccards = {}
# 1차 LSH 필터 (레거시 옵션)
candidate_ids: set[str] | None = None
if not self._persistent and self._lsh:
cands = self._lsh.query(query_text, top_k=self.settings.lsh_top_k)
candidate_ids = {c.doc_id for c in cands}
candidates_count = len(cands)
lsh_jaccards = {c.doc_id: c.jaccard for c in cands}
# 정밀 비교 (LSH 후보가 있으면 그것만, 없으면 풀스캔)
if not self._persistent:
hits = self._index.query(query_text, elements, top_k=opts.top_k)
if candidate_ids is not None:
hits = [h for h in hits if h.doc_id in candidate_ids]
# 군집화 부분 표절 신호용 query lemma (전처리 텍스트 기준)
query_lemmas = extract_lemmas(query_text) if self._cluster else None
persistent_by_id = {h.segment_id: h for h in persistent_hits}
matches = []
for h in hits:
if len(matches) >= opts.top_k:
break
provenance_hit = persistent_by_id.get(h.doc_id)
# 채택 이유를 명시적으로 남긴다 (#9). 임계 초과가 아니라 연속 일치나
# 커버리지 때문에 올라온 후보를 검토자가 구분할 수 있어야 한다.
reasons: list[str] = []
if h.score >= threshold:
reasons.append("score_threshold")
if provenance_hit:
if provenance_hit.longest_span >= self.settings.persistent_min_exact_span:
reasons.append("exact_span")
# 커버리지 조건은 문서 단위 union 으로 판단한다. 세그먼트 단독
# 비율은 긴 원고에서 구조적으로 작아 조건이 성립하지 않는다 (#3).
if document_coverage.get(provenance_hit.document_id, 0.0) >= self.settings.persistent_min_coverage:
reasons.append("coverage")
if not reasons:
continue
# 유사도만 넘고 그대로 겹친 구간이 없는 후보는 채택하지 않는다.
# 같은 주제를 다룬 글은 임베딩 유사도가 함께 오르므로, 유사도 단독
# 판정은 오탐을 만든다. 실데이터 106건 중 29건이 이 경우였고 검토에서
# 전부 오탐으로 확인됐다. 반대로 이 조건을 걸어도 새로 놓치는 건은
# 없었다(미탐지 7,680건 재판정 결과 0건).
if self.settings.require_exact_span_evidence and "exact_span" not in reasons:
continue
match = self._to_match(
h, opts.return_evidence, lsh_jaccards.get(h.doc_id),
self._partial_signal(h.doc_id, elements, query_lemmas),
)
if provenance_hit:
match = self._add_provenance(match, provenance_hit)
matches.append(match.model_copy(update={"match_reasons": reasons}))
confidence = matches[0].similarity if matches else (hits[0].score if hits else 0.0)
is_infringement = bool(matches) # 후방호환 필드. 법적 확정이 아니라 임계 초과 매칭.
ccl_basis = self._build_ccl_basis(matches) if is_infringement else None
top_longest = max((m.longest_span for m in matches), default=0)
legal_tags = [t.tag for m in matches for t in m.tags]
ctx = legal_context or LegalContext()
legal = self._legal_engine.assess(
max_similarity=matches[0].similarity if matches else 0.0,
# 문서 단위 union coverage 를 쓴다. 세그먼트 최대값을 쓰면 긴 원고에서
# 항상 0에 가까워 strong_copy 판정이 성립하지 않았다 (#3).
coverage=union_coverage,
longest_span=top_longest,
legal_tags=legal_tags,
work_type=ctx.work_type,
access_evidence=ctx.access_evidence,
protected_expression_reviewed=ctx.protected_expression_reviewed,
rights_verified=ctx.rights_verified,
evidence=[
{
"source_document_id": m.source_document_id or m.source_doc,
"similarity": m.similarity,
"matched_coverage": m.matched_coverage,
"longest_span": m.longest_span,
"infringement_type": m.infringement_type,
"legal_tags": [t.tag for t in m.tags],
"match_reasons": m.match_reasons,
"excerpts": [span.matched for span in m.evidence_spans],
}
for m in matches[:5]
],
query_text=text,
)
# AI 탐지는 전처리 전 raw text에서만 실행한다.
if include_ai_detection:
ai_result = self._ai_detector.detect(text, with_segments=include_ai_segments)
ai_signal = AiGenerationSignal(
suspicion_level=ai_result.suspicion_level or "unknown",
score=ai_result.score,
available=ai_result.available,
provenance=ai_result.provenance,
is_stub=ai_result.is_stub,
model_version=ai_result.model_version,
feature_set_version=ai_result.feature_set_version,
pos_available=ai_result.pos_available,
warnings=ai_result.warnings,
segments=[{
"index": s.index, "start": s.start, "end": s.end,
"char_count": s.char_count, "score": s.score,
"suspicion_level": s.suspicion_level, "scored": s.scored,
"note": s.note,
} for s in ai_result.segments],
top_contributions=[
{"feature": name, "contribution": round(value, 4)}
for name, value in ai_result.top_contributions
],
note=ai_result.note,
)
else:
ai_signal = AiGenerationSignal(
suspicion_level="unknown",
available=False,
note="침해 판별 배치에서는 AI 생성 의심도 채점을 생략했습니다.",
)
sim_pct = _calibrate_similarity(confidence, threshold)
review = ReviewSummary(
originality_percent=100 - sim_pct,
similarity_percent=sim_pct,
similar_sentence_count=len(matches),
compared_count=self.corpus_size,
has_suspicion=is_infringement,
ai_suspicion_level=ai_signal.suspicion_level,
)
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),
extracted_elements=elements,
matches=matches,
ccl_basis=ccl_basis,
review_summary=review,
ai_generation=ai_signal,
has_similarity_match=bool(matches),
corpus_scope_note=(
f"현재 등록된 {self.corpus_document_count}개 원천 문서의 "
f"{self.corpus_size}개 검색 세그먼트만 대조했습니다. 미매칭은 비침해 확정이 아닙니다."
),
legal_risk=LegalRiskSignal(
status=legal.status,
risk_level=legal.risk_level,
similarity_evidence=legal.similarity_evidence,
protected_expression=legal.protected_expression,
access_evidence=legal.access_evidence,
missing_factors=list(legal.missing_factors),
precedent_ids=list(legal.precedent_ids),
precedent_grades=dict(legal.precedent_grades),
judgment_method=legal.judgment_method,
llm_verdict=legal.llm_verdict,
llm_confidence=legal.llm_confidence,
llm_review_required=legal.llm_review_required,
llm_matched_precedent_ids=list(legal.llm_matched_precedent_ids),
supporting_reasons=list(legal.supporting_reasons),
counter_reasons=list(legal.counter_reasons),
judge_model=legal.judge_model,
judge_prompt_version=legal.judge_prompt_version,
judge_note=legal.judge_note,
judgment_summary=legal.judgment_summary,
disclaimer=legal.disclaimer,
),
score_semantics=ScoreSemantics(
combined_score=round(confidence, 4),
threshold_used=threshold,
threshold_source=(
"request_override" if opts.threshold is not None else "server_default"
),
threshold_calibrated=self.settings.similarity_threshold_calibrated,
provisional=not self.settings.similarity_threshold_calibrated,
union_coverage=round(union_coverage, 4),
covered_chars=covered_chars,
query_chars=len(text),
evidence_truncated=evidence_truncated,
),
autobiography_mode=autobio_mode,
candidates_before_filter=candidates_count,
engine_version=self.settings.engine_version,
analyzed_at=datetime.now(timezone.utc),
)
def detect_request(self, req: DetectRequest) -> DetectResponse:
return self.detect(
req.doc_id, req.text, req.metadata, req.options,
legal_context=req.legal_context,
)
def reference_features(self, hit: PersistentHit) -> tuple[list[str], ExtractedElements]:
"""참조 세그먼트의 lemma/요소. 인덱스 캐시 → 프로세스 캐시 → 계산 순 (#5).
인덱싱 때 채워둔 값이 있으면 형태소 분석을 아예 하지 않는다. v1 DB 나
API 업로드분처럼 캐시가 없으면 계산하되 프로세스 캐시에 담고, 호출자가
DB 로 백필한다.
"""
from app.api.schemas import ExtractedElements as _EE
if hit.reference_lemmas is not None and hit.reference_elements is not None:
return hit.reference_lemmas, _EE(**hit.reference_elements)
cached = self._feature_cache.get(hit.segment_id)
if cached is not None:
return cached
lemmas = extract_lemmas(hit.reference_text)
elements = self._extractor.extract(hit.reference_text)
if len(self._feature_cache) >= self._FEATURE_CACHE_MAX:
self._feature_cache.clear()
self._feature_cache[hit.segment_id] = (lemmas, elements)
return lemmas, elements
def _backfill_segment_features(self, hits: list[PersistentHit]) -> None:
"""질의 중 계산한 참조 특징을 DB 에 되돌려 다음 요청부터 재사용."""
if not self._persistent:
return
pending = [
(h.segment_id, *self._feature_cache[h.segment_id])
for h in hits
if h.reranked
and h.reference_lemmas is None
and h.segment_id in self._feature_cache
]
if not pending:
return
try:
self._persistent.store.update_segment_features(
(sid, lemmas, elements.model_dump()) for sid, lemmas, elements in pending
)
except Exception as exc: # 백필 실패가 탐지 응답을 막아서는 안 된다
logger.warning("Segment feature backfill failed: %s", exc)
def _persistent_to_similarity_hit(
self, hit: PersistentHit, query_lemmas: list[str], query_elements,
) -> SimilarityHit:
from app.api.schemas import EvidenceSpan
from app.engine.similarity import _element_similarities
from app.engine.structural import lemma_overlap_ratio
reference_lemmas, reference_elements = self.reference_features(hit)
element_sim = _element_similarities(query_elements, reference_elements)
lemma_sim = lemma_overlap_ratio(query_lemmas, reference_lemmas)
s = self.settings
combined = (
s.weight_text_sim * hit.score
+ s.weight_lemma_sim * lemma_sim
+ s.weight_char_sim * element_sim["characters"]
+ s.weight_motif_sim * element_sim["motifs"]
)
return SimilarityHit(
doc_id=hit.segment_id,
title=hit.title,
score=combined,
text_sim=hit.score,
lemma_sim=lemma_sim,
element_sim=element_sim,
evidence=[EvidenceSpan(**span) for span in hit.evidence],
)
@staticmethod
def _add_provenance(match: MatchResult, hit: PersistentHit) -> MatchResult:
return match.model_copy(update={
"source_document_id": hit.document_id,
"source_segment_id": hit.segment_id,
"source_locator": hit.source_locator,
"coordinate_scope": hit.coordinate_scope,
"page_number": hit.page_number,
"paragraph_number": hit.paragraph_number,
"source_char_start": hit.source_char_start,
"source_char_end": hit.source_char_end,
"matched_coverage": round(hit.coverage, 4),
"longest_span": hit.longest_span,
})
def add_persistent_document(self, doc_id: str | None, title: str, text: str) -> str:
if not self._persistent:
raise RuntimeError("persistent index is not enabled")
document_id = (doc_id or stable_id("doc", title)).strip()
self._persistent.store.upsert_document(DocumentRecord(document_id=document_id, title=title))
clean_text = text.strip()
segments = [SegmentRecord(
segment_id=stable_id("seg", document_id, str(start), chunk),
document_id=document_id, text=chunk, ordinal=str(i),
coordinate_scope="document", char_start=start, char_end=end,
source_locator=f"api://corpus/{document_id}#chars={start}-{end}",
metadata={"provenance_quality": "api_document"},
) for i, (start, end, chunk) in enumerate(
chunk_with_offsets(clean_text, size=1000, stride=500), 1
)]
inserted, duplicates = self._persistent.store.add_segments(segments)
if duplicates and not inserted:
raise FileExistsError(f"doc_id '{document_id}' 또는 동일 본문이 이미 존재합니다")
self._replace_persistent_index()
return document_id
def delete_persistent_document(self, doc_id: str) -> bool:
if not self._persistent:
raise RuntimeError("persistent index is not enabled")
deleted = self._persistent.store.delete_document(doc_id)
if deleted:
self._replace_persistent_index()
return deleted
def _replace_persistent_index(self) -> None:
"""새 객체를 완성한 뒤 포인터를 한 번에 교체해 동시 질의 정합성을 보장."""
replacement = PersistentCorpusIndex(
self.settings.corpus_db, self.settings.persistent_index_path,
)
replacement.sync()
replacement.load()
self._persistent = replacement
def _partial_signal(self, doc_id, query_elements, query_lemmas) -> PartialPlagiarismSignal | None:
"""군집화 기반 요소별 부분 표절 분해 (옵션)."""
if not self._cluster:
return None
sig = self._cluster.partial_signal(doc_id, query_elements, query_lemmas)
if sig is None:
return None
return PartialPlagiarismSignal(
cluster_id=sig.cluster_id,
verdict=sig.verdict,
signature_score=sig.signature_score,
per_element=sig.per_element,
retained_elements=sig.retained_elements,
changed_elements=sig.changed_elements,
)
def _to_match(
self,
hit: SimilarityHit,
return_evidence: bool,
lsh_j: float | None,
partial: PartialPlagiarismSignal | None = None,
) -> MatchResult:
legacy_type = _classify_legacy(hit)
tags = self._assign_tags(hit, legacy_type)
cases = (self.taxonomy.find_cases([t.tag for t in tags if t.role == "primary"])
if self.taxonomy else ())
candidates = [
CaseCandidate(
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
],
precedent_note=c.representative_precedent_note,
)
for c in cases
]
return MatchResult(
source_doc=hit.doc_id,
source_title=hit.title,
similarity=round(hit.score, 4),
tags=tags,
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(
text_sim=round(hit.text_sim, 4),
lemma_sim=round(hit.lemma_sim, 4),
character_sim=round(hit.element_sim.get("characters", 0.0), 4),
motif_sim=round(hit.element_sim.get("motifs", 0.0), 4),
lsh_jaccard=round(lsh_j, 4) if lsh_j is not None else None,
),
partial_signal=partial,
)
def _assign_tags(self, hit: SimilarityHit, legacy: InfringementType) -> list[InfringementTag]:
"""삼중 유사도 분포 → 10종 법령 태그(주/보조) 매핑.
규칙(PDF IX장 매핑표 기반):
- copy/패러프레이즈 수준 표절 (lemma↑ or text↑) → 복제권(주) + 공중송신권(보조)
- lemma만 매우 높음 → 인용 표시 누락(주 보조)
- 인물 일치도 매우 높음(서사·구조 차용) → 2차적저작물작성권(주) + 자기창작인양표시(보조)
- 구조 미달 가공 신호 (text 낮음 + lemma 중간) → 2차적저작물 미달 가공
주 태그는 케이스 후보를 좁히는 유일한 입력이므로, 유사도 분포로 판단할 수
있는 쟁점은 보조가 아니라 주 태그로 낸다. 반대로 게재 사실·편집 개입·성명
표시처럼 텍스트에서 알 수 없는 사실은 태그로 만들지 않는다. 그래서 B3·C1·
D1 은 이 함수만으로 후보에 오르지 않으며, LegalContext 로 사람이 확인한
사실이 들어와야 한다.
"""
text_sim = hit.text_sim
lemma_sim = hit.lemma_sim
char_sim = hit.element_sim.get("characters", 0.0)
motif_sim = hit.element_sim.get("motifs", 0.0)
primary: list[str] = []
secondary: list[str] = []
# 복제권: 표면 또는 lemma가 강하게 일치
if lemma_sim >= 0.70 or text_sim >= 0.70:
primary.append("reproduction")
secondary.append("public_transmission") # 전자책 게재 가정
# 표절 실무 - 인용 누락
primary.append("citation_missing")
# 표면 복제 + 구조 차용 동시 성립 (재수록·편집 수록형) → A10
elif lemma_sim >= 0.50 and char_sim >= 0.40:
primary.append("reproduction")
primary.append("derivative_work")
secondary.append("publication")
secondary.append("attribution")
# 2차적저작물작성권: 구조·서사 차용 (인물/모티프 일치 + 표면은 낮음)
elif (char_sim >= 0.40 or motif_sim >= 0.50) and text_sim < 0.40:
primary.append("derivative_work")
secondary.append("attribution")
# 구조를 차용하고 출처 표시가 없으면 인용 누락이 주 쟁점이다 → A16
if lemma_sim >= 0.40:
primary.append("citation_missing")
else:
secondary.append("citation_missing")
# 미달 가공: 원문 표현을 거의 남기지 않은 구조 차용 → A7/B2
if text_sim < 0.30 and lemma_sim < 0.50:
primary.append("substandard_derivative")
# 부분 변형 (text 중간 + 인물 일치)
elif text_sim >= 0.40 and char_sim >= 0.30:
primary.append("reproduction")
secondary.append("derivative_work")
secondary.append("citation_missing")
# 낮은 매칭이지만 임계 통과한 경우
else:
secondary.append("reproduction")
# 중복 제거 + 태그 객체화
primary = list(dict.fromkeys(primary))
secondary = [s for s in dict.fromkeys(secondary) if s not in primary]
out: list[InfringementTag] = []
for t in primary:
out.append(InfringementTag(tag=t, role="primary", label_ko=TAG_LABEL_KO[t]))
for t in secondary:
out.append(InfringementTag(tag=t, role="secondary", label_ko=TAG_LABEL_KO[t]))
return out
def _build_ccl_basis(self, matches: list[MatchResult]) -> str:
top = matches[0]
sb = top.score_breakdown
breakdown = ""
if sb:
breakdown = (
f" [text={sb.text_sim:.2f} / lemma={sb.lemma_sim:.2f} "
f"/ char={sb.character_sim:.2f} / motif={sb.motif_sim:.2f}]"
)
primary_labels = [t.label_ko for t in top.tags if t.role == "primary"]
tag_summary = ", ".join(primary_labels) if primary_labels else "확인 필요"
if len(top.case_candidates) > 1:
ids = ", ".join(c.case_id for c in top.case_candidates)
case_part = (f" 추정 케이스 후보 {ids} — 태그만으로는 구별되지 않으므로 "
f"베껴온 원본의 종류를 확인해 확정해야 합니다.")
elif top.case_id:
case_part = f" 추정 케이스 {top.case_id} ({top.case_title})."
else:
case_part = ""
return (
f"'{top.source_title}'와 검색 유사도 {top.similarity:.2%}로 후보 매칭. "
f"검토 가설 태그: {tag_summary}.{case_part}{breakdown} "
"법적 침해 여부는 보호되는 표현·의거관계·권리관계를 별도 검토해야 합니다."
)
def _calibrate_similarity(raw: float, threshold: float) -> int:
"""결합 유사도(0~1) → 사용자 표시용 '유사도 %' 캘리브레이션.
임베딩 성분 때문에 무관한 글도 raw 0.5 전후가 나오므로, 그대로 %로 쓰면
깨끗한 글이 '유사도 50%'로 보인다. 아래 구간 변환으로 무관한 글은 0~5%,
임계 초과(실제 표절)만 40% 이상으로 눌러준다.
"""
floor = 0.55 # 무관한 글의 전형적 결합 유사도 상한
if raw <= floor:
disp = (raw / floor) * 5.0 if floor else 0.0
elif raw <= threshold:
disp = 5.0 + (raw - floor) / max(1e-6, threshold - floor) * 35.0
else:
disp = 40.0 + (raw - threshold) / max(1e-6, 1.0 - threshold) * 60.0
return max(0, min(100, round(disp)))
def _classify_legacy(hit: SimilarityHit) -> InfringementType:
"""후방 호환 - 단일 enum 분류 (UI/기존 통합 코드용)."""
elem = hit.element_sim
char_sim = elem.get("characters", 0.0)
motif_sim = elem.get("motifs", 0.0)
if hit.lemma_sim >= 0.70:
return "copy"
if hit.text_sim >= 0.70:
return "copy"
if hit.text_sim >= 0.40 and char_sim >= 0.30:
return "transform"
if hit.lemma_sim >= 0.40 and char_sim < 0.20:
return "plot"
if motif_sim >= 0.50 and char_sim < 0.20:
return "plot"
if char_sim >= 0.40:
return "character"
return "unknown"
# 후방 호환 alias (테스트가 _classify import)
_classify = _classify_legacy