From e3298d149cf328a55617dfd45bdb1609e8d08475 Mon Sep 17 00:00:00 2001 From: changukyu Date: Tue, 22 Sep 2026 12:08:00 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=ED=95=9C=20=EC=A4=84=20=EC=9A=94?= =?UTF-8?q?=EC=95=BD(headline)=20=E2=80=94=20=EC=9A=B4=EC=98=81=EC=9E=90?= =?UTF-8?q?=EC=9D=98=20=EC=B2=AB=201=EC=B4=88=EB=A5=BC=20=EC=82=AC?= =?UTF-8?q?=EC=9C=A0=20=EC=9B=90=EB=AC=B8=20=EB=8D=A4=ED=94=84=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EC=82=AC=EC=8B=A4=C2=B7=EA=B7=9C=EB=AA=A8=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 코퍼스 DB 로 실 콘솔을 띄워 보니 큐의 '상위 근거' 칸이 '구글 visually s... 구글 페이지 with ...'(사유 원문 둘을 잘라 붙인 것)이었고 증거 제목도 '구글 full image match found' 로 절반 영어였다. 스코어러가 점수 계산과 별개로 집계(_Tally)해 riskBreakdown.headline 에 '웹 완전일치 사본 4곳 · 식별 가능 인물 · 이름 있는 검색 승격 2건 · 라벨 9개' 를 싣는다. 순서는 예산표, 웹은 가장 강한 뷰만 호스트 수(곳)로, 등록 일치·클리어 이력·미수행·실패· 신뢰 출처 제외까지 한 줄. 큐 행과 케이스 요약 보드의 첫 줄이 이것으로 바뀌고 headline 없는 옛 케이스만 예전 배치. formatReason 에 Vision 매치 4종·검색결과 사본· 기준 DB 유사도 한국어 매핑. 요약 보드 첫 줄은 word-break: keep-all 로 구 단위 줄바꿈. 점수·밴드 변화 없음(SCORER_VERSION 유지). 실렌더로 큐·케이스 확인. verify.ps1: ruff clean, 663 passed. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01BStHFrjQj2VypecSG9ex1V --- docs/INDEX.md | 2 + docs/PROJECT_MAP.md | 2 +- src/rights_filter/analysis/risk_scoring.py | 103 ++++++++++++++++-- tests/operator_gui/test_static_workbench.py | 16 +++ .../analysis/test_risk_scoring.py | 53 +++++++++ web/operator-gui/app-format.js | 10 ++ web/operator-gui/app-render-evidence.js | 9 +- web/operator-gui/app-render-queue.js | 16 ++- web/operator-gui/styles.css | 3 + 9 files changed, 200 insertions(+), 14 deletions(-) diff --git a/docs/INDEX.md b/docs/INDEX.md index f2510f1..640cfe5 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -1,5 +1,7 @@ # Project INDEX +- 2026-09-22 · **한 줄 요약(headline) — 운영자의 첫 1초를 사유 원문 덤프에서 사실·규모로.** 코퍼스 DB 로 실 콘솔을 띄워 보니 큐의 '상위 근거' 칸이 `구글 visually s... 구글 페이지 with ...`(사유 원문 둘을 잘라 붙인 것)이었고 증거 제목도 `구글 full image match found` 로 절반 영어였다. 스코어러가 점수 계산과 별개로 집계(`_Tally`)해 `riskBreakdown.headline` 에 `웹 완전일치 사본 4곳 · 식별 가능 인물 · 이름 있는 검색 승격 2건 · 라벨 9개` 를 싣는다 — 순서는 예산표, 웹은 가장 강한 뷰만 **호스트 수(곳)** 로, 등록 일치·클리어 이력·미수행·실패·신뢰 출처 제외까지 한 줄. 큐 행과 케이스 요약 보드의 첫 줄이 이것으로 바뀌고 옛 케이스(headline 없음)만 예전 배치. `formatReason` 에 Vision 매치 4종·검색결과 사본·기준 DB 유사도 한국어 매핑 추가. 실렌더(claude-in-chrome, 코퍼스 20장)로 큐·케이스 확인 — 점수·밴드 변화 없음(SCORER_VERSION 유지). + - 2026-09-22 · **스코어러 v6 — 외부 검색 미수행의 조용한 통과를 막는다.** 배포 판단에서 파일럿 전 유일한 차단으로 꼽은 구멍: 네이버 일일 한도 100 = 20장이라 21장째부터 검색이 `SEARCH_SKIPPED` 0점·무사유로 빠져 점수가 **낮게** 나왔다(키 만료·비상 차단도 같은 경로). 정책 거절 사유 5종 중 하루 안에서 케이스마다 달라지고 어디에도 안 보이는 하나(`usage limit reached`)만 v5 의 실패 하한 30 에 합류시키고 사유에 ` 미수행 — <사유>` 를 남긴다. `disabled` 는 키 미설정 배포와 비상 차단이 같은 사유를 쓰고 둘 다 콘솔 providerState 에 보이므로 그대로 0점·무사유(하한을 걸면 키 없는 배포의 큐 전체가 medium 이 된다 — 기본 스토어 테스트가 잡아냈다); 거버넌스 스위치 셋(compliance · metadata logging · online mode)도 배포 전체에 균일해 0점·무사유. 라운드트립은 신선 점수를 재현(테스트). 다이어그램 태그 v6 재생성. - 2026-09-22 · **스코어러 v5 — 축 예산을 총합 기준으로 재배분, 실이미지 100점 포화 20/20 → 0/20.** 첫 실측이 보인 원인은 축 예산의 **합**(245 vs 상한 100)이었고, 캡 없는 두 축만 캡하는 시뮬레이션(80/50 · 60/40)으로는 20/20 이 그대로 100 이라 축 하나 손보기로는 안 됐다. 새 예산: 등록 권리 대상 일치 80(단독 high) · 웹 존재 40(검색결과 사본 유사도·Vision 매치·CSE 페이지가 제공자 무관 **한 풀** — 같은 사실의 다른 뷰, 뷰 강도로만 40/30/10 차등) · 인물 30/10 · 이름 있는 검색 승격 15 · 라벨 10 → 등록 제외 최대 95. 외부 검색 실패는 +30/행 가산에서 medium **하한**으로(네이버 장애 하루가 웹에 있는 모든 이미지를 high 로 밀던 경로 차단). 검색결과 사본 행은 `knowledge_entry_id` 부재 + `url`/`source_evidence_ids` 로 판별(둘 다 라운드트립 생존). 격자 432: high 92→75%, 포화 80→44%(남은 포화는 전부 등록 강한 일치 조합), 웹 신호만 high 13/24→**0/24** — v2 원칙 복원. 실이미지 20장 리스코어: 100점 20→**0**, 구별 점수 1→4(75/80/92/95), 밴드는 전부 high 유지 — 전부 웹에 있는 인물 사진이라 그것이 맞는 답이고 medium/low 임계는 여전히 미검증(저위험 기대 이미지 0장). 스코어러 테스트 45(신규 10) · 다른 파일의 인물 단독 35 고정값 4곳은 상수 참조로 · 다이어그램 spec 3종 v5(+카드 문구) · `calibration-corpus.md` 전후표. 다이어그램 4종 재생성(archify 2.17.0-dev.1) — `operator-review-workflow` 는 HEAD spec 그대로 새 `desktop-readability` 검사에 걸려(viewBox 1112 > 1085, 7px 라벨 투영 5.89px) 노드 폭 132→124 로 통과시켰으나, 그만큼 확대되어 1440×900 에서 세로 13px 넘침(visual-check containment fail; HEAD 는 같은 뷰포트에서 readability fail). 검증 9/9·showcase 는 통과, 사실 게이트·매니페스트도 통과 — 잔여는 툴체인 드리프트로 기록. diff --git a/docs/PROJECT_MAP.md b/docs/PROJECT_MAP.md index d738f38..c4fa4c7 100644 --- a/docs/PROJECT_MAP.md +++ b/docs/PROJECT_MAP.md @@ -41,7 +41,7 @@ python run_copyrighter_server.py # 기본 127.0.0.1:9500 | `fingerprints.py` | 이미지 지문. `_perceptual_hash`는 **DCT pHash가 아니라 8x8 평균해시(aHash)** — 이름과 실제가 다름(알려진 부채) | | `face_person_detection.py` | YuNet(ONNX) 얼굴 검출 + 인물 등급(`identifiable`/`background`). 가중치는 `models/`(gitignore) | | `internal_analyzer.py` | 내부 분석 오케스트레이션 | -| `risk_scoring.py` | `RiskScorer.score()` — 점수·밴드 산출. **`SCORER_VERSION` 은 가중치/버킷/밴드 변경 시 반드시 올린다**. `RiskScorer(trusted_hosts)` 는 지식DB 의 활성 `trusted_host` 항목(이름·별칭이 도메인)을 받아 그 호스트에서 발견된 웹 탐지 행을 0점 처리한다 — **행 단위 제외**라 자사 사본과 스크래퍼 게시가 같이 있으면 후자는 그대로 센다. v5/v6 축 예산표(등록 일치 80 · 웹 존재 40 한 풀 · 인물 30/10 · 검색 승격 15 · 라벨 10 · 미수행(일일 한도)·실패는 하한 30, disabled·거버넌스 스위치 미수행은 0)는 파일 상단 주석이 정본 — 검색결과 사본 행은 `knowledge_entry_id` 부재 + `url`/`source_evidence_ids` 로 웹 풀에 간다 | +| `risk_scoring.py` | `RiskScorer.score()` — 점수·밴드 산출. **`SCORER_VERSION` 은 가중치/버킷/밴드 변경 시 반드시 올린다**. `RiskScorer(trusted_hosts)` 는 지식DB 의 활성 `trusted_host` 항목(이름·별칭이 도메인)을 받아 그 호스트에서 발견된 웹 탐지 행을 0점 처리한다 — **행 단위 제외**라 자사 사본과 스크래퍼 게시가 같이 있으면 후자는 그대로 센다. v5/v6 축 예산표(등록 일치 80 · 웹 존재 40 한 풀 · 인물 30/10 · 검색 승격 15 · 라벨 10 · 미수행(일일 한도)·실패는 하한 30, disabled·거버넌스 스위치 미수행은 0)는 파일 상단 주석이 정본 — 검색결과 사본 행은 `knowledge_entry_id` 부재 + `url`/`source_evidence_ids` 로 웹 풀에 간다. `breakdown.headline` 은 점수와 무관한 한 줄 요약(`_Tally`·`_headline`) — 큐 '상위 근거' 칸과 케이스 요약 보드 첫 줄이 이것을 쓴다 | | `evidence_normalizer.py`, `evidence_enrichment.py` | 증거 정규화·보강 | | `search_query_generation.py`, `search_result_promoter.py` | 자동 검색 쿼리 생성, 검색 결과의 증거 승격 | | `llm_assistance.py`, `llm_prompts.py` | LLM을 **요약기가 아닌 증거 판독기**로 사용. 입력 화이트리스트 9필드 + 상위 12건 상한, `format=json` 구조화 인용, 라벨 집합 포함 검사로 미인용 claim 폐기 | diff --git a/src/rights_filter/analysis/risk_scoring.py b/src/rights_filter/analysis/risk_scoring.py index 21d7d8c..982e7ec 100644 --- a/src/rights_filter/analysis/risk_scoring.py +++ b/src/rights_filter/analysis/risk_scoring.py @@ -1,5 +1,6 @@ from __future__ import annotations +from dataclasses import dataclass, field from typing import Any from urllib.parse import urlparse @@ -148,6 +149,9 @@ class RiskScorer: # 말하든 축의 예산을 넘지 못한다. pools: dict[str, int] = {} excluded: dict[str, int] = {} + # 한 줄 요약(headline)용 집계. 점수에는 관여하지 않는다. 사유 목록은 신호 + # 원문의 덤프라 운영자가 첫 1초에 "무엇이 나왔나"를 읽는 역할을 못 한다. + tally = _Tally() def pool(key: str, points: int) -> None: pools[key] = pools.get(key, 0) + points @@ -163,6 +167,7 @@ class RiskScorer: # add risk. Surface it as a reason so the operator sees the prior # clearance, but contribute no points. if item.data.get("knowledge_entry_type") == "cleared_reference": + tally.cleared = True if item.reason: reasons.append(item.reason) continue @@ -179,15 +184,18 @@ class RiskScorer: reasons.append(f"신뢰 출처 {trusted} — 위험 점수에서 제외") excluded[trusted] = excluded.get(trusted, 0) + 1 continue - pool("web_presence", _search_result_copy_points(similarity)) - else: + copy_points = _search_result_copy_points(similarity) + pool("web_presence", copy_points) + tally.presence[_presence_tier(copy_points)].add(_presence_key(item)) + elif similarity >= 0.9: # A match against an operator-registered rights subject. Not a # fact about the web but a judgement the team recorded, so it # is the one axis that reaches high on its own. - pool( - "knowledge", - KNOWLEDGE_MATCH_STRONG_POINTS if similarity >= 0.9 else KNOWLEDGE_MATCH_WEAK_POINTS, - ) + pool("knowledge", KNOWLEDGE_MATCH_STRONG_POINTS) + tally.knowledge_best = max(tally.knowledge_best, similarity) + else: + pool("knowledge", KNOWLEDGE_MATCH_WEAK_POINTS) + tally.knowledge_weak = True reasons.append(item.reason) elif item.source == EvidenceSource.FACE_PERSON: # "A person is present" is one signal no matter how many rows @@ -199,6 +207,7 @@ class RiskScorer: continue counted_face_person = True face_points = _face_person_points(item) + tally.face = _face_label(item) reasons.append(item.reason) elif item.source == EvidenceSource.WEB_DETECTION: # Found on a host the team controls or licenses. "It is on the @@ -211,12 +220,18 @@ class RiskScorer: reasons.append(f"신뢰 출처 {trusted} — 위험 점수에서 제외") excluded[trusted] = excluded.get(trusted, 0) + 1 continue + points, kind = _web_detection_points(item) + if kind == _WEB_PRESENCE and points: + # Tallied per host BEFORE the dedup bucket: the bucket keeps + # one match type from scoring twice, but "found on 5 hosts" + # is exactly what the headline has to say. + tally.presence[_presence_tier(points)].add(_presence_key(item)) bucket = _web_detection_bucket(item) if bucket and bucket in counted_web_buckets: continue - points, kind = _web_detection_points(item) if kind == _WEB_LABEL: pool("web_entity", points) + tally.labels += 1 elif kind == _WEB_PRESENCE: pool("web_presence", points) if points: @@ -233,6 +248,7 @@ class RiskScorer: if bucket and bucket in counted_naver_buckets: continue pool("naver", points) + tally.naver_hits += 1 reasons.append(item.reason) if bucket: counted_naver_buckets.add(bucket) @@ -261,6 +277,7 @@ class RiskScorer: continue if item.source in {EvidenceSource.FAILURE, EvidenceSource.ENRICHMENT_FAILURE}: failure_seen = True + tally.failures += 1 reasons.append(item.reason) elif _is_transient_skip(item): # A provider that normally runs hit its daily quota before @@ -269,6 +286,7 @@ class RiskScorer: # image of the day scored lower than the 20th for no reason # anyone could see. failure_seen = True + tally.skips += 1 reasons.append(f"{_skip_provider(item) or '외부 검색'} 미수행 — {item.reason}") # Every other skip applies to every case alike while it lasts - # "disabled" (no key in this deployment, or the emergency stop the @@ -325,11 +343,82 @@ class RiskScorer: {"label": f"신뢰 출처 {host}", "rows": rows} for host, rows in sorted(excluded.items()) ], + "headline": _headline(tally, sum(excluded.values())), } return ScoreResult(score=score, band=band, reasons=_unique(reasons), breakdown=breakdown) +@dataclass +class _Tally: + """What the headline counts. Scoring never reads it.""" + + knowledge_best: float = 0.0 + knowledge_weak: bool = False + cleared: bool = False + presence: dict[str, set[str]] = field(default_factory=lambda: {"exact": set(), "near": set(), "weak": set()}) + face: str = "" + naver_hits: int = 0 + labels: int = 0 + failures: int = 0 + skips: int = 0 + + +def _presence_tier(points: int) -> str: + if points >= WEB_COPY_EXACT_POINTS: + return "exact" + if points >= WEB_COPY_NEAR_POINTS: + return "near" + return "weak" + + +def _presence_key(item: Evidence) -> str: + # One "place" per host. A row that names no URL at all counts as its own place. + return _evidence_host(item) or str(item.data.get("url") or item.data.get("result_url") or id(item)) + + +def _face_label(item: Evidence) -> str: + tier = str(item.data.get("tier", "")).strip() + if tier == TIER_BACKGROUND: + return "배경 인물" + return "식별 가능 인물" if tier else "인물" + + +def _headline(tally: _Tally, excluded_rows: int) -> str: + """운영자가 첫 1초에 읽는 한 줄. 사유 목록의 압축이고 순서는 위 예산표를 따른다. + + 실 콘솔에서 큐의 '상위 근거' 칸은 `구글 visually s... 구글 페이지 with ...` 처럼 + 잘린 영어 조각 둘이었다 - 신호 원문은 개발자용이지 운영자용이 아니다. 여기서는 + "웹 완전일치 사본 3곳 · 식별 가능 인물" 처럼 발견된 **사실과 규모**만 말한다. + """ + parts: list[str] = [] + if tally.knowledge_best: + parts.append(f"등록 권리 대상 일치 {tally.knowledge_best:.2f}") + elif tally.knowledge_weak: + parts.append("등록 권리 대상 약한 일치") + if tally.cleared: + parts.append("클리어 이력 일치") + if tally.presence["exact"]: + parts.append(f"웹 완전일치 사본 {len(tally.presence['exact'])}곳") + elif tally.presence["near"]: + parts.append(f"웹 부분·페이지 일치 {len(tally.presence['near'])}곳") + elif tally.presence["weak"]: + parts.append(f"웹 유사 이미지 {len(tally.presence['weak'])}건") + if tally.face: + parts.append(tally.face) + if tally.naver_hits: + parts.append(f"이름 있는 검색 승격 {tally.naver_hits}건") + if tally.labels: + parts.append(f"라벨 {tally.labels}개") + if tally.failures: + parts.append("외부 검색 실패") + if tally.skips: + parts.append("일일 한도로 일부 미수행") + if excluded_rows: + parts.append(f"신뢰 출처 제외 {excluded_rows}행") + return " · ".join(parts) if parts else "신호 없음" + + def _is_search_result_copy(item: Evidence) -> bool: """A FINGERPRINT row that matched a SEARCH RESULT image, not a knowledge entry. diff --git a/tests/operator_gui/test_static_workbench.py b/tests/operator_gui/test_static_workbench.py index 9e48e27..948825a 100644 --- a/tests/operator_gui/test_static_workbench.py +++ b/tests/operator_gui/test_static_workbench.py @@ -1037,6 +1037,22 @@ def test_operator_gui_explains_how_the_score_was_reached(): assert "원래" in script and "상한" in script # 100 포화는 그 자체로 알려야 한다 - 그 구간은 서로 구별되지 않는다. assert "score-breakdown-clamped" in script +def test_operator_gui_leads_with_the_scorer_headline_instead_of_raw_reason_fragments(): + # 실 코퍼스 콘솔에서 큐의 '상위 근거' 칸은 `구글 visually s... 구글 페이지 with ...` + # 였다 - 사유 원문 둘을 잘라 붙인 것. 스코어러가 주는 한 줄 요약(riskBreakdown.headline, + # "웹 완전일치 사본 3곳 · 식별 가능 인물")이 큐와 케이스 요약의 첫 줄이 되고, 요약이 + # 없는 옛 케이스만 예전 배치로 떨어진다. + script = _read_app() + + assert "riskBreakdown.headline" in script + assert script.count("riskBreakdown.headline") >= 2 # 큐 행 + 케이스 요약 보드 + assert ".slice(0, 2)" in script # 옛 케이스 폴백은 남아 있어야 한다 + + # 증거 제목도 절반 영어(`구글 full image match found`)로 남지 않는다. + for korean in ("구글 동일 이미지 발견", "구글 게시 페이지 발견", "검색 결과 사본 유사도"): + assert korean in script + + def test_operator_gui_completes_the_rejection_path_from_the_keyboard(): # 승인·보류는 1타인데 반려만 메모 필수라 손이 마우스로 갔다 — 법적으로 가장 # 중요한 경로가 가장 느렸다. R 로 사유를 고르면 두 키로 끝난다. diff --git a/tests/rights_filter/analysis/test_risk_scoring.py b/tests/rights_filter/analysis/test_risk_scoring.py index 9f27bfe..144f31a 100644 --- a/tests/rights_filter/analysis/test_risk_scoring.py +++ b/tests/rights_filter/analysis/test_risk_scoring.py @@ -807,6 +807,59 @@ def test_promoted_hits_from_distinct_queries_pool_under_one_ceiling(): assert len(result.reasons) == 3 +# --- headline: 운영자가 첫 1초에 읽는 한 줄 --------------------------------------- + + +def _headline_of(evidence: list[Evidence], trusted: frozenset[str] = frozenset()) -> str: + return RiskScorer(trusted).score(evidence).breakdown["headline"] + + +def test_headline_compresses_the_reasons_into_one_korean_line_in_budget_order(): + # 실 콘솔의 큐 '상위 근거' 칸은 `구글 visually s... 구글 페이지 with ...` 였다. + evidence = [ + _search_result_copy(1.0, "https://a.test/1.jpg"), + _search_result_copy(1.0, "https://b.test/1.jpg"), + _vision("Full match", "full", 0.9), # vision.example.test - a third place + _face(FACE_PERSON_IDENTIFIABLE_POINTS), + _promoted("q1"), + _promoted("q2"), + _label("A", 0.9), + _label("B", 0.9), + _label("C", 0.9), + ] + + assert _headline_of(evidence) == "웹 완전일치 사본 3곳 · 식별 가능 인물 · 이름 있는 검색 승격 2건 · 라벨 3개" + + +def test_headline_counts_places_not_rows(): + # 같은 호스트에서 찾은 사본 3행은 1곳이다. + same_host = [_search_result_copy(1.0, f"https://a.test/{i}.jpg") for i in range(3)] + + assert _headline_of(same_host) == "웹 완전일치 사본 1곳" + + +def test_headline_names_only_the_strongest_web_view(): + assert _headline_of([_vision("Page hosts image", "page", 0.8)]) == "웹 부분·페이지 일치 1곳" + assert _headline_of([_vision("Visually similar", "visual", 0.6)]) == "웹 유사 이미지 1건" + assert _headline_of(_presence_set()) == "웹 완전일치 사본 1곳" + + +def test_headline_leads_with_a_registered_match_and_ends_with_the_gaps(): + evidence = [ + _fingerprint(0.95), + _face(FACE_PERSON_BACKGROUND_POINTS), + _failure(), + _skipped("search API usage limit reached"), + ] + + assert _headline_of(evidence) == "등록 권리 대상 일치 0.95 · 배경 인물 · 외부 검색 실패 · 일일 한도로 일부 미수행" + + +def test_headline_reports_trusted_exclusions_and_silence(): + assert _headline_of([_published_on("posar.info")], frozenset({"posar.info"})) == "신뢰 출처 제외 1행" + assert _headline_of([]) == "신호 없음" + + def test_a_provider_failure_is_a_review_floor_not_a_bonus(): # "확인 못 했다"는 사람이 봐야 하므로 medium 까지 올린다. 그러나 이미 그 위에 # 있는 케이스에 더하면 네이버 장애 하루가 웹에 있는 모든 이미지를 high 로 diff --git a/web/operator-gui/app-format.js b/web/operator-gui/app-format.js index 50493d0..e4d5ee9 100644 --- a/web/operator-gui/app-format.js +++ b/web/operator-gui/app-format.js @@ -341,6 +341,16 @@ function formatReason(reason) { if (text.startsWith("Google face crop web evidence: ")) { return `얼굴 영역 웹 근거: ${formatReason(text.replace("Google face crop web evidence: ", ""))}`; } + if (text === "Google full image match found") return "구글 동일 이미지 발견"; + if (text === "Google partial image match found") return "구글 부분 일치 이미지 발견"; + if (text === "Google page with matching image found") return "구글 게시 페이지 발견"; + if (text === "Google visually similar image found") return "구글 유사 이미지 발견"; + if (text.startsWith("Search result image similarity ")) { + return `검색 결과 사본 유사도 ${text.replace("Search result image similarity ", "")}`; + } + if (text.startsWith("Knowledge base image similarity ")) { + return `기준 DB 이미지 유사도 ${text.replace("Knowledge base image similarity ", "")}`; + } if (text.startsWith("Web entity matched ")) return `웹 엔티티 일치: ${text.replace("Web entity matched ", "")}`; if (text.startsWith("Google weak label ")) return `구글 약한 라벨 ${text.replace("Google weak label ", "")}`; if (text.startsWith("Best guess label ")) return `구글 추정 라벨 ${text.replace("Best guess label ", "")}`; diff --git a/web/operator-gui/app-render-evidence.js b/web/operator-gui/app-render-evidence.js index c96ed0d..8270ce1 100644 --- a/web/operator-gui/app-render-evidence.js +++ b/web/operator-gui/app-render-evidence.js @@ -174,14 +174,19 @@ function renderEvidenceSummary(submission) { ["네이버", count("naver")], ["인물", count("face")], ]; + // 무엇이 나왔나(headline)가 첫 줄, 무엇을 할지(primaryJudgment)와 규모가 둘째 줄. + // headline 이 없는 옛 케이스는 예전 배치 그대로. + const headline = submission.riskBreakdown && submission.riskBreakdown.headline; + const lead = headline ? escapeHtml(headline) : escapeHtml(primaryJudgment); + const sub = `${headline ? `${escapeHtml(primaryJudgment)} · ` : ""}${searchCount}개 검색 근거 · 전체 ${submission.evidence.length}개 증거`; return `
${riskLabels[submission.riskBand]} ${submission.riskScore}
- ${escapeHtml(primaryJudgment)} - ${searchCount}개 검색 근거 · 전체 ${submission.evidence.length}개 증거 + ${lead} + ${sub}
diff --git a/web/operator-gui/app-render-queue.js b/web/operator-gui/app-render-queue.js index 56aef34..c98cb2b 100644 --- a/web/operator-gui/app-render-queue.js +++ b/web/operator-gui/app-render-queue.js @@ -86,13 +86,21 @@ function renderQueue() { .join(""); - const reasonHtml = submission.reasons + // 스코어러가 준 한 줄 요약("웹 완전일치 사본 3곳 · 식별 가능 인물")이 있으면 + // 그것이 상위 근거다. 사유 원문 둘을 잘라 붙이면 `구글 visually s...` 가 된다. + const headline = submission.riskBreakdown && submission.riskBreakdown.headline; - .slice(0, 2) + const reasonHtml = headline - .map((reason) => `${escapeHtml(formatReason(reason))}`) + ? `${escapeHtml(headline)}` - .join(""); + : submission.reasons + + .slice(0, 2) + + .map((reason) => `${escapeHtml(formatReason(reason))}`) + + .join(""); const submittedTime = formatQueueTimestamp(submission.submittedAt); diff --git a/web/operator-gui/styles.css b/web/operator-gui/styles.css index 3dbe0d1..f1f3fb1 100644 --- a/web/operator-gui/styles.css +++ b/web/operator-gui/styles.css @@ -1635,7 +1635,10 @@ tbody tr.selected-row, .summary-lead strong { display: block; font-size: 17px; + line-height: 1.35; letter-spacing: -0.01em; + /* 한 줄 요약은 "라벨 10개" 같은 구를 줄 사이에서 쪼개지 않는다 — 구분점(·) 단위로만 접힌다. */ + word-break: keep-all; } .summary-lead span:not(.risk-badge) {