feat: finalize report at 9-eojeol threshold

기준을 9어절(35자)로 확정하고 문서를 그 기준으로 다시 만든다.
중복 제거 + 9어절 재실행 결과는 검사 대상 6087건, 침해의심 80건이다.

9어절 근거는 문서쌍 전수 대조다. 545문서 148,240쌍을 모두 대조하니
100어절(390자)까지 올려도 18쌍이 계속 겹친다. 겹침이 0이 되는 어절 수는
없다. 10~15어절에서 27쌍으로 고정되어 더 줄지 않는데, 오탐이라면 기준을
올릴수록 계속 줄어야 한다. 즉 그 지점부터 남는 것은 실제 유사 원고다.
상투어 노이즈는 4->5어절에서 대부분 걷히므로 9어절이 실질적 하한이다.

배포 파일을 하나로 합친다. 원문 대조 시트를 결과보고에 넣어 의심 80건의
원문을 같은 파일에서 볼 수 있게 하고, 숫자가 맞지 않는 중복 제거 전
파일 두 개는 배포 위치에서 내린다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hbyang 2026-09-03 14:29:30 +09:00
parent bf3bd5b99b
commit 94a660dc35
3 changed files with 6130 additions and 5 deletions

File diff suppressed because it is too large Load Diff

View File

@ -179,8 +179,8 @@ def build_summary_sheet(wb: Workbook, rows: list[dict], *, backend: str, notes:
CRITERIA_ROWS = [
(1, "결합유사도", "≥ 0.65", "persistent_similarity_threshold",
"네 가지 유사도를 가중 합산한 값이 임계값 이상"),
(2, "최장 연속 일치 길이", "≥ 80자", "persistent_min_exact_span",
"두 글이 끊기지 않고 그대로 이어지는 최장 구간"),
(2, "최장 연속 일치 길이", "≥ 35자 (9어절)", "persistent_min_exact_span",
"두 글이 끊기지 않고 그대로 이어지는 최장 구간. 근거는 '어절 기준 비교' 시트"),
(3, "문서 단위 일치 커버리지", "≥ 0.30 (30%)", "persistent_min_coverage",
"질의 문서 전체에서 일치 구간이 차지하는 비율(중복 제외)"),
]
@ -264,7 +264,7 @@ def build_criteria_sheet(wb: Workbook, rows: list[dict], excerpts: list[dict] |
note("아래 세 줄은 중복 집계입니다. 한 건이 여러 조건을 동시에 충족할 수 있어 합계가 103을 넘습니다.")
sheet.append(["조건 ① 유사도 (0.65 이상) 충족", condition_counts["①유사도"], ""])
if excerpts:
sheet.append(["조건 ② 연속일치 (80자 이상) 충족", condition_counts["②연속일치"], "원문 대조로 실측"])
sheet.append(["조건 ② 연속일치 (35자·9어절 이상) 충족", condition_counts["②연속일치"], "원문 대조로 실측"])
sheet.append(["조건 ③ 커버리지 (30% 이상) 충족", condition_counts["③커버리지"], ""])
sheet.append([])
sheet.append(["판정 사유 조합별 건수", "", "'원문 대조' 시트의 판정 사유 컬럼과 같은 값. 합계 = 103"])
@ -390,7 +390,11 @@ def build_duplicate_sheet(wb: Workbook, excerpts: list[dict], records: dict[str,
def build_sweep_sheet(wb: Workbook, sweep: list[dict], rows: list[dict]) -> None:
TOTAL_DOC_PAIRS = 148240 # 545문서 전수 조합
def build_sweep_sheet(wb: Workbook, sweep: list[dict], rows: list[dict],
exhaustive: list[dict] | None = None) -> None:
"""어절 기준을 3~9로 바꿔가며 재판정한 결과.
기준값은 검색 후보를 바꾸지 않고 채택 여부만 정하므로, 한 번 실행해 남긴
@ -436,6 +440,36 @@ def build_sweep_sheet(wb: Workbook, sweep: list[dict], rows: list[dict]) -> None
)
sheet.append([f"기준과 무관하게 걸리는 건 (유사도 0.65 또는 커버리지 30% 충족): {base}건"])
sheet.cell(row=sheet.max_row, column=1).font = Font(bold=True)
if exhaustive:
sheet.append([])
sheet.append(["기준을 계속 올리면 겹침이 사라지는가 — 전수 확인"])
sheet.cell(row=sheet.max_row, column=1).font = Font(bold=True, size=12)
sheet.append([f"표본이 아니라 545개 문서의 전체 조합 {TOTAL_DOC_PAIRS:,}쌍을 모두 대조한 결과입니다."])
sheet.cell(row=sheet.max_row, column=1).font = NOTE_FONT
header2 = ["어절", "중복 제거 전 겹치는 문서쌍", "중복 제거 후", "전체 문서쌍 대비"]
sheet.append(header2)
for index in range(1, len(header2) + 1):
cell = sheet.cell(row=sheet.max_row, column=index)
cell.font = Font(bold=True)
cell.fill = HEADER_FILL
for item in exhaustive:
sheet.append([
f"{item['어절']}어절", item["제거전"], item["제거후"],
item["제거후"] / TOTAL_DOC_PAIRS,
])
sheet.cell(row=sheet.max_row, column=4).number_format = "0.00000%"
for line in (
"100어절(약 390자)까지 올려도 18개 문서쌍은 계속 겹칩니다. 겹침이 0이 되는 어절 수는 존재하지 않습니다.",
"10~15어절 구간에서 27쌍으로 고정되어 더 줄지 않습니다. 오탐이라면 기준을 올릴수록 계속 줄어야 합니다.",
"서로 다른 문서에 390자가 연속으로 같다는 것은 우연이 아니라 실제로 같은 내용이 들어 있다는 뜻입니다.",
"따라서 기준은 '겹침이 0이 되는 값'이 아니라, 상투어 노이즈가 걷히는 지점으로 정해야 합니다.",
"상투어 노이즈는 4→5어절에서 대부분 걷히고, 9어절부터는 남는 것이 전부 실제 유사 원고입니다.",
"이 근거로 9어절(35자)을 적용했습니다.",
):
sheet.append([line])
sheet.cell(row=sheet.max_row, column=1).font = NOTE_FONT
sheet.append([])
for line in (
"어절 기준을 아무리 낮춰도 이 건수는 줄지 않습니다. 어절 기준이 더하는 몫이 '연속일치로만 걸린 건'입니다.",
"3어절과 4어절은 임의 조합의 99% 이상이 겹쳐, 침해 신호로 쓸 수 없습니다. 겹친 표현은 '할 수 있는', '할 수 있을 것' 같은 상투어였습니다.",
@ -450,7 +484,7 @@ def build_sweep_sheet(wb: Workbook, sweep: list[dict], rows: list[dict]) -> None
SCORE_MIN = 0.65
SPAN_MIN = 80
SPAN_MIN = 35
COVERAGE_MIN = 0.30
@ -591,6 +625,8 @@ def main() -> int:
parser.add_argument("--backend", default="", help="검색 백엔드 표기 (미지정 시 JSONL 값 사용)")
parser.add_argument("--note", action="append", default=[], help="요약 시트에 남길 유의사항")
parser.add_argument("--sweep-json", type=Path, help="어절 기준 비교 시트용 데이터")
parser.add_argument("--exhaustive-json", type=Path,
help="문서쌍 전수 대조 결과 (겹침이 0이 되는지 확인한 표)")
parser.add_argument("--dup-batch-jsonl", type=Path,
help="중복 원고 시트를 만들 때 참조할 이전 회차 배치 JSONL")
parser.add_argument("--no-excerpt-sheet", action="store_true",
@ -621,6 +657,8 @@ def main() -> int:
if args.sweep_json:
build_sweep_sheet(
wb, json.loads(args.sweep_json.read_text(encoding="utf-8")), rows,
json.loads(args.exhaustive_json.read_text(encoding="utf-8"))
if args.exhaustive_json else None,
)
build_criteria_sheet(wb, rows, excerpts if args.excerpts_jsonl else None)
build_summary_sheet(wb, rows, backend=backend, notes=args.note)