월례회의 자료(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>
114 lines
5.6 KiB
Python
114 lines
5.6 KiB
Python
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')
|