Commit graph

15 commits

Author SHA1 Message Date
changukyu
4e8e4b3181 feat: 선례 승계 — 재제출을 다시 심사하지 않는다
8/8 실효성 평가의 결론은 "검색·기록 노동은 줄여주나 심사 건수는 전혀 못 줄인다"
였다. 이 커밋은 그 축을 직접 건드리는 첫 기능이다.

중복 탐지는 이 도구에서 실증으로 검증된 유일한 판별 기능이다 — 재저장(JPEG q90)
/80%축소/50%축소/밝기+10% 변형 24건 중 24건 그룹핑, 같은 24건에서 SHA-256
완전일치는 0건. 그런데 그 결과를 쓰는 곳이 없었다. 서버는 store_duplicates.py
에서 priorDecisions 와 conflicting 을 계산해 내려보내는데, 콘솔은 그것을 큐
배지의 title 속성에만 넣었다. 마우스를 올린 사람만 "이거 지난주에 반려한
이미지"임을 알 수 있었고, 재제출은 처음부터 다시 심사됐다.

서버는 건드리지 않았다. 자리만 옮겼다.

  큐 배지        "동일 이미지 3건 · 선례 반려" (title 이 아니라 본문에)
  판정 패널      결정 메모 바로 위에 선례 배너 + [선례 승계] 1타

승계는 별도 판정 경로를 만들지 않는다. 메모를 채워 기존 setDecision 을 그대로
타므로 반려 메모 필수 검사 · 판정 시점 스냅샷 · 감사 이벤트가 유지되고, 승계
사실은 메모로 감사에 남는다("선례 승계: 동일 이미지 SUB-OLD1, SUB-OLD2 와 같은
판정").

판정 불일치는 승계 대상이 아니다. 같은 이미지에 서로 다른 판정이 있다는 것은
자동 처리할 상황이 아니라 사람이 봐야 하는 신호이므로, conflicting 이면 버튼을
아예 내지 않고 색을 갈라 표시한다.

실렌더 확인(Playwright 1440x900, 실제 서버 + 부트스트랩 스텁):
세 상태(승계 가능 / 판정 불일치 / 선례 없음)의 배너 문구와 버튼 유무가 의도대로,
승계 클릭 시 POST 본문이 위 메모 그대로, 콘솔 에러 0, 가로 오버플로 없음.

검증: ruff clean, pytest 622 passed (정적 3건 추가).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BStHFrjQj2VypecSG9ex1V
2026-09-21 12:05:38 +09:00
changukyu
e38dad83c9 fix: 증거 행 레이아웃·호스트 노출·보존 케이스 설명, 프롬프트 스탬프 갱신
1) 긴 호스트가 제목을 세로 한 줄짜리 컬럼으로 만들었다

   .evidence-title 은 flex-wrap 없는 flex 행이고 source-chip·host-chip 둘 다
   축소를 거부한다(flex: 0 0 auto). 그래서 제목에 남는 폭이 수십 px 이 되고
   overflow-wrap: anywhere 가 그걸 한 단어씩 세로로 쪼갰다. 컨테이너에
   flex-wrap, 제목에 min-width: 14ch 를 줘서 제목이 자기 줄로 내려간다.

   실렌더 측정 (같은 DOM 에 수정 전 CSS 런타임 주입, Playwright):
     360px  제목 51x95px (5줄) -> 246x19px (1줄), 행 높이 95 -> 50
     1280px 제목 9x380px (~20줄) -> 205x19px, 행 높이 380 -> 50

   주석은 데스크톱에서는 해결됐다고 적혀 있었지만, 45자 CDN 호스트로는
   1280px 에서도 재현된다 — 폰 전용 문제가 아니었다.

2) 20ch 로 잘린 호스트를 읽을 방법이 없었다

   칩 title 이 "게시 호스트" 고정 문구여서, 20자 접두어가 같은 두 CDN 호스트를
   링크를 열지 않고 구분할 수 없었다 — 모노스페이스를 고른 이유가 그 판별이다.
   툴팁에 실제 호스트를 싣는다.

3) 클라이언트 호스트 폴백이 서버보다 짧았다

   _result_host 는 (url, result_url, page_url, source_page_url, blogger_link,
   image_url) 를 순회하는데 evidenceHost 는 3개만 봤다. host 키가 생기기 전에
   저장된 네이버 블로그 행이 bloggerLink 만 가진 경우 칩이 조용히 사라졌다 —
   "레거시 증거가 자기 출처에 눈멀지 않게" 라는 폴백의 목적과 반대다.

4) 보존된 케이스가 설명 없는 깨진 이미지였다

   assetMissing 을 읽는 소비자가 GUI·API·내보내기 어디에도 없었다. 판정은 DB 에
   남지만 콘솔에서는 "판정 후 파일 아카이브" 와 "업로드 손상" 이 구분되지
   않았다. 큐 썸네일과 케이스 이미지에 표시 + 파생물 노트에 사유를 붙인다.

5) PROMPT_VERSION 갱신 (ko-v3-structured -> ko-v4-structured-host)

   847f7a8 이 PROMPT_DATA_FIELDS 의 domain -> host 로 모든 증거 줄의 내용을
   바꿨는데 스탬프는 그대로여서, 한 버전이 서로 다른 두 프롬프트를 가리켰다.
   화이트리스트가 다른 모듈에 있어도 프롬프트의 일부라는 점을 llm_prompts
   독스트링의 규칙에 명시했다. 렌더된 줄이 host= 를 담는지 보는 행위 테스트도
   추가했다(튜플 멤버십 검사로는 안 잡힌다).

   CLAUDE.md 요구대로 실모델 프로브(qwen2.5:7b, 로컬 ollama): grounded 3 /
   dropped 0 / verified True, 형식 예시 반향 없음, 인용 라벨 정확(E1=full match).
   다만 모델은 host 를 문장에 쓰지 않고 page_title 로 서술했다 — 호스트는
   운영자 칩에는 닿지만 서술에는 아직 반영되지 않는다(SYSTEM_PROMPT 과제).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BStHFrjQj2VypecSG9ex1V
2026-09-18 23:54:00 +09:00
changukyu
847f7a852d fix: 게시 호스트를 영속화에서 복구하고 콘솔·LLM 에 노출
P1 3번("매칭 호스트 기반 신뢰출처 구분")을 착수하며 전제를 검증했더니 틀렸다.
호스트는 "이미 저장돼 있고 점수만 안 쓰는" 게 아니라 저장 시점에 파괴되고
있었다.

  google_custom_search.py:240,296 이 실호스트(displayLink)를 data["domain"] 에
  넣는데, store_serialization.py:298 이 data["provider"] 를 우선하므로 그 값이
  API 이름으로 덮어써진다. Vision·Naver 는 호스트를 아예 안 넣고 URL 만 준다.
  결과적으로 저장된 모든 행의 domain 이 "google" 이고, LLM 프롬프트 12줄이 전부
  domain=google 이었다 — 저작권 심사에서 가장 결정적인 "어디에 떠 있나"를 모델도
  운영자도 볼 수 없었다. 같은 케이스의 payload 55행 중 41행이 url 을 갖고 있는데도.

domain 필드는 재사용할 수 없다. provider 커버리지 동기화와 CSE 쿼리 시그니처
매칭이 그 값을 키로 쓰고(store_serialization.py:195, 599) 콘솔도 provider
라벨로 렌더한다. 그래서 host 를 별도 키로 신설했다.

  _result_host(): data["domain"] 이 provider 와 다르면 그것을, 아니면 결과/페이지
  URL 에서 호스트를 파싱한다. www 제거·소문자화. 파싱 실패는 "호스트 없음"이지
  영속화 실패가 아니다.

  점수에는 영향이 없다 — RiskScorer 는 domain 도 host 도 읽지 않는다. 증거 id 도
  안 바뀐다: _evidence_id 는 도메인 객체의 data 를 해싱하고 payload 를 해싱하지
  않으므로 payload 키 추가가 id 를 움직이지 않는다.

  PROMPT_DATA_FIELDS 의 domain -> host. 화이트리스트 크기는 그대로.

  콘솔은 증거 카드 제목 줄에 monospace host-chip 을 붙인다. 기존 저장 행에는 host
  키가 없으므로 evidenceHost() 가 저장된 URL 에서 파생한다 — 292행 마이그레이션도,
  bootstrap 마다 행당 파싱도 필요 없다(렌더되는 행만 지나간다).

실렌더 검증(127.0.0.1:9512, 실 DB)에서만 드러난 레이아웃 결함 2건을 고쳤다.
코드 리딩으로는 보이지 않았다.
  1) 긴 CDN 호스트(cloudfront-ap-northeast-1.images.arcpublishing.com)가 제목 줄
     폭을 다 먹어, evidence-title 의 overflow-wrap:anywhere 와 결합해 제목이 한
     글자씩 세로로 깨졌다. 칩을 max-width 20ch 로 묶고 제목에 flex:1 1 auto 를 줘
     남는 폭을 갖게 했다.
  2) 그 수정 후에는 반대로 biz.chosun.com(14자)까지 잘렸다. 칩을 flex:0 0 auto 로
     고정했다 — 제목("구글 full image match found")은 전 행이 동일한 상용구이고
     호스트가 그 행의 실제 내용이므로, 들어갈 수 있는 호스트가 잘리는 쪽이 잘못이다.
  최종: 칩 82개 전부 유효 호스트 형태(TLD com/edu/hk/jp/kr/net/org), 가시 칩 41개 중
  잘리는 것 4개(모두 20자 초과 CDN), 콘솔 에러 0.

PROVENANCE 배점은 보류한다. 두 조건이 다 안 맞는다. (a) detect_screen_capture 의
카테고리가 geometry 와 subtitle_band 둘뿐인데 2개를 요구하므로 라벨의 실제 의미는
"geometry AND 자막"이고, 자막 없는 방송 캡처는 구조적으로 도달 불가다 — 임계를
낮추는 게 아니라 독립적인 세 번째 신호가 필요하고 그건 새 탐지기다. 한계를 실측과
함께 상수 옆에 기록했다. (b) "새 탐지 신호는 0점 라벨로 출시한 뒤 decision_stats
상관을 보고 가중치 부여"가 2026-07-25 결정이고, 판정 0건인 지금 배점하면 그 결정을
위반한다.

신뢰출처 판정 자체는 "어느 도메인이 우리 것/라이선스 계약처인가"라는 운영자
데이터가 있어야 한다. 호스트가 살아났으므로 목록만 정해지면 cleared_reference 와
같은 패턴(점수 0 + 사유 노출)으로 붙는다.

테스트 4건 추가. pytest 574 passed(착수 전 570), 린트 클린.
2026-08-09 08:51:12 +09:00
changukyu
60903ec1f7 test: 업로드 E2E 플레이크 해소 (대기 10초 -> 30초 상수)
test_browser_uploads_image_and_selects_new_submission 이 전체 스위트에서만
실패하고 단독 실행에서는 통과했다. 업로드는 케이스가 큐에 나타나기 전에
서버에서 실분석(디코드/지문/YuNet)을 돌리므로, 다른 스펙과 CPU 를 다투는
전체 실행에서 10초를 넘겼다.

UPLOAD_ANALYSIS_TIMEOUT_MS(30초)로 올리고 이유를 상수 옆에 남겼다. 모킹된
부트스트랩 대기와 달리 이 경로만 여유가 필요하다는 점이 코드에서 보이게 했다.
upload_and_open 의 기본값도 같은 상수를 쓴다.
2026-08-08 09:49:55 +09:00
changukyu
fc4e04f65c refactor: app.js 5,428줄을 관심사별 app-*.js 15개로 분할
단일 파일이 프로젝트 상한(800줄)의 6.8배였다. 분할은 순수 라인범위 추출이며
어떤 구문도 수정·재배치·재포맷하지 않았다 — 15개 파트의 연결 결과가 원본
app.js 와 바이트 단위로 동일함을 확인하고 나눴다(125,689B).

클래식 스크립트(모듈 아님)라 하나의 전역 스코프를 공유하므로 로드 순서가 곧
계약이다. 원본에서 로드 시점에 실행되는 최상위 구문은 파일 끝의 리스너 등록
2개뿐이었고(DOMContentLoaded/hashchange), 나머지 참조는 전부 함수 본문 안이라
순서만 지키면 의미가 보존된다. 그 순서를 index.html 에 명시하고 테스트로 고정:

- test_index_loads_every_app_part_exactly_once_in_declared_order
  각 파트가 정확히 1회, 선언 순서대로 로드되는지 + 디스크의 app-*.js 가
  전부 배선됐는지 + 구 app.js 가 남아있지 않은지
- test_app_parts_stay_within_the_file_size_budget (800줄 상한)

정적 스펙 31곳은 app.js 본문을 문자열로 검사하고 있었다. 이 스펙들의 관심사는
'어느 파일에 있는가'가 아니라 '콘솔에 그 동작이 있는가'이므로, 파트를 로드
순서대로 이어붙여 읽는 _read_app() 으로 바꿨다.

검증: Playwright E2E 6건이 실브라우저에서 통과(콘솔 에러 0 단언 포함).
운영 콘솔 실렌더 확인 — 큐/케이스 심사 정상, 스크립트 15개 전부 200,
새로고침 후 콘솔 메시지 0건.
2026-08-08 09:48:52 +09:00
changukyu
a2d0ef7a36 feat: operator throughput features (keyboard review, batch, dupes, export, backup)
- Keyboard review mode: J/K to move, A/H to decide and jump to the next
  unreviewed case, R to focus the memo, N for next unreviewed, ? for help, with
  a live position/backlog indicator; disabled while typing.
- Batch decisions: POST /api/submissions/decision-batch applies one decision to
  many selected cases through per-case transactions/audit/watchlist, reports
  per-case success and failure, caps at 200, requires a memo for reject and
  confirms when high-risk cases are included. Lifts the v1 "no bulk
  approve/reject" spec restriction under those guardrails (spec revised, static
  layout test updated to assert the guardrails instead of the absence).
- Decision-memo presets: GET/POST /api/decision-memo-presets (upsert by label),
  one-click chips in the decision pane, "save current memo" action.
- Submission duplicate / prior-case index: seed backfills fingerprints onto the
  submission record (idempotent), exact + pHash>=0.97 clustering, queue badge
  and group select, prior-decision chips and a conflicting-decision flag.
- Decision export: GET /api/decisions/export with separate internal and
  applicant field whitelists (applicant view never carries score/evidence),
  CSV with a BOM.
- Online backup/restore: POST /api/admin/backup snapshots the WAL database via
  the sqlite backup API plus the media dirs into a timestamped zip with
  retention rotation; `--restore` is an offline CLI that keeps the pre-restore
  database. GET /api/admin/backups lists snapshots.

Also folds in the co-located HTTP hardening (non-object/non-UTF8 JSON body ->
400, non-ASCII Authorization/cookie compared as bytes -> 401, media OSError ->
404) and the line-buffered startup logging that share these files.

Verified against a live server (auth gate, seeding, duplicate index, batch
decision guardrails, presets, export, backup + restore round-trip). pytest
tests/rights_filter tests/operator_gui: 457 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E6GD9gxUtfUiVB3JHejGpU
2026-07-24 14:18:11 +09:00
changukyu
0bfa30d7f5 test: add Playwright E2E harness for operator GUI with decision-flow spec
Introduce a reusable browser-test harness under tests/operator_gui/:
- conftest.py: shared fixtures (operator_server, browser with graceful skip,
  operator_page with screenshot-on-failure artifacts)
- pages/: OperatorWorkbench Page Object encapsulating selectors and actions
- refactor test_browser_smoke.py onto the fixtures + POM (drops duplicated
  server/browser plumbing)
- test_decision_flow.py: approve/hold/reject E2E against the real server,
  covering the reject-requires-memo guard and decision persistence

Pin playwright in requirements-dev.txt (Chromium bundled offline for the
air-gapped target) and ignore the artifacts/ screenshot dir.
2026-06-21 21:10:22 +09:00
유창욱
37294dc140 fix: resolve multi-agent review findings for workbench efficiency round 2026-06-12 18:44:35 +09:00
유창욱
4d98582ed3 feat: rerun enrichment evidence diff with score delta and new-evidence badges 2026-06-12 18:00:43 +09:00
유창욱
1e0f4f8690 feat: persist and display detected face crop thumbnails in workbench 2026-06-12 17:56:09 +09:00
유창욱
646b871b76 feat: knowledge base search/filter, inline edit, and server-backed lifecycle actions 2026-06-12 17:51:36 +09:00
유창욱
cf342425c5 feat: expose google_search as operator manual text-query provider 2026-06-12 17:46:45 +09:00
유창욱
4abb837aaa feat: one-click and batch execution for suggested evidence queries 2026-06-12 17:44:48 +09:00
유창욱
7cac0b3835 fix: resolve code-review findings from the clean-review restyle
Correctness:
- Make the local-artifact audit test skip on fresh clones (data/ is
  gitignored), so the suite passes outside this workstation
- Drop the transform from the viewRise entrance animation: an animated
  transform made .view.active a containing block for 320ms and threw
  the fixed decision panel off-screen on every workbench entry
- Collapse the queue toolbar at 1380px instead of 1180px; 1280x800
  laptops no longer get a horizontal scrollbar (verified live)
- Serve .woff2 as font/woff2 with an immutable cache header so the
  2MB bundled font is fetched once, not per page load (with test)
- Clip overflow on top-bar status chips (long apiError strings spilled
  over neighbors at 981-1180px)
- Give queue-row selection a selector that outranks the even-row
  zebra stripe (selection background was parity-dependent)

Cleanup:
- Replace the stale old-palette focus ring and ::selection literals
  with color-mix over var(--teal)
- Delete dead tokens: unused back-compat aliases (the comment claiming
  they were referenced was false), --rail-bot, --ochre-deep, and
  --font-stamp (identical to --font-ui since the Pretendard switch)
- Tokenize scattered raw colors: rail ink scale, soft tint levels,
  inset-well and bevel shadows, naver/internal source-chip triplets
- Remove the asset-preload div and three orphan SVGs nothing renders;
  tests now reject reintroducing them

Verified: 359 tests pass; Playwright audit at 1440/1280/390 shows zero
horizontal overflow on all views, Pretendard active, decision panel
fixed at the viewport corner mid-animation.
2026-06-11 11:13:46 +09:00
유창욱
3f7b3a9cf2 chore: initial commit of copyrighter (rights_filter)
Image rights / copyright detection system: SQLite store, HTTP app,
search integrations (Naver, Google Custom Search, Google Cloud Vision
web detection), image analysis (fingerprints, face/person detection,
evidence enrichment, risk scoring), an admin/review layer, governance
and retention policies, batch jobs, and a browser-based operator GUI.

This baseline incorporates a full code-review remediation pass
(46 fixes; 358 tests passing). Highlights:

CRITICAL
- Prevent evidence cascade-delete during the schema-constraint
  migration by disabling FK enforcement around the table rebuild.

Security
- Sandbox served media (neutralize stored XSS from uploaded/collected
  SVGs) via CSP + nosniff on the untrusted media routes.
- Strip embedded EXIF/GPS from external image derivatives before they
  are sent to third-party APIs.
- Return a clean 404 (not an uncaught StopIteration) for PATCH on an
  unknown provider.

Correctness
- LLM-summary failures no longer add +30 to the risk score.
- Decode only explicit JS escapes so Korean image URLs are not mangled.
- Consume search quota only after a successful request.
- Naver/Google adapters map responses inside the failure boundary, so a
  malformed response degrades to evidence instead of crashing enrichment.
- Domain-aware provider attribution; face-box IoU de-duplication; count
  searches (not result items); per-box crop isolation; clamp evidence
  confidence and Google CSE num; real submittedEpoch; and more.

Robustness
- Offline LLM connect fast-fails (short connect timeout) so seed/reload
  requests are not stalled; full read timeout preserved for generation.
- Malformed numeric env vars fall back to defaults instead of crashing
  startup.

Performance
- Per-submission evidence reads (no full-table scan per rescore),
  audit-log LIMIT, lazy active-store lookup, hoisted timestamps.

Tests
- ~24 regression tests added pinning the above fixes.

Runtime data (data/, outputs/, *.sqlite3, *.log), secrets (.env), and
node_modules are gitignored.
2026-06-09 09:50:31 +09:00