Compare commits

..

No commits in common. "76a06f2ff13a1a8d870932e6d3a3ceddf84bff21" and "fa00f382e28460256abeafe99ad5699eacb06689" have entirely different histories.

39 changed files with 5504 additions and 6623 deletions

View file

@ -1,36 +0,0 @@
# POSA Copyrighter
이미지 저작권 위험 자동 분석 + 운영자 판정 콘솔. **폐쇄망(air-gapped) 납품 대상.**
## 먼저 읽기
`docs/PROJECT_MAP.md` (구조·라우트·스키마 위치) → `docs/INDEX.md` (작업 이력).
전체 소스 재스캔 금지 — MAP 에서 위치를 찾고 필요한 부분만 연다.
## 검증
```powershell
.\verify.ps1 # ruff + pytest. 커밋 전 게이트, CI 도 이 명령 하나
```
모든 패치를 적용한 뒤 **마지막에 1회**만 실행한다.
## 이 프로젝트의 규칙
- **폐쇄망 우선**: 새 런타임 의존성 추가는 기본적으로 금지. 필요하면 오프라인 휠 반입 절차
(`docs/operations/offline-install.md`)까지 함께 처리한다. CDN·원격 fetch·자동업데이트 금지.
- **`app-*.js` 로드 순서는 계약**: 클래식 스크립트 15개가 전역 스코프를 공유한다. 추가·제거·
재배치 시 `index.html` 과 `test_static_workbench.py` 의 `APP_SCRIPT_NAMES` 를 함께 고친다.
- **점수를 바꾸면 `SCORER_VERSION` 을 올린다** (`analysis/risk_scoring.py`). 이 스탬프가
가중치 변경과 스코어링 버그를 사후 구분하는 유일한 근거다.
- **직렬화 라운드트립은 원 점수를 재현해야 한다** (`store_serialization.py`). 과거에
`result_url` → `url` 별칭이 리스코어 시 0점을 45점으로 바꾼 사고가 있었다.
- **LLM 은 요약기가 아니라 증거 판독기**다. 인용 없는 claim 은 폐기한다. 프롬프트나 모델을
바꾸면 가짜 transport 가 아니라 **실모델로 프로브**한다 — 형식 예시 반향 결함은 실모델에서만
드러났다.
- **비ASCII 출력 주의**: 콘솔이 cp949 라 em-dash 등이 `UnicodeEncodeError` 로 프로세스를
죽인 이력이 있다. 스크립트 stdout 에 넣지 말 것.
## 커밋
의미 있는 묶음으로 1커밋 → `docs/INDEX.md` 에 결과 기록. 원격은 Forgejo(`tea` CLI, `gh` 아님).

View file

@ -47,8 +47,6 @@ python run_copyrighter_server.py --host 127.0.0.1 --port 9500
기본값으로 실행하면 `http://127.0.0.1:9500` 에서 운영자 콘솔이 열립니다. 기본값으로 실행하면 `http://127.0.0.1:9500` 에서 운영자 콘솔이 열립니다.
> 보안: `COPYRIGHTER_AUTH_TOKEN`이 비어 있으면 데이터 라우트가 **인증 없이** 열립니다. 단일 호스트 개발 환경 외에는 반드시 토큰을 설정하세요. > 보안: `COPYRIGHTER_AUTH_TOKEN`이 비어 있으면 데이터 라우트가 **인증 없이** 열립니다. 단일 호스트 개발 환경 외에는 반드시 토큰을 설정하세요.
>
> 토큰을 설정하면 **틀린 토큰을 제시한 시도**는 클라이언트 주소별로 60초당 10회로 제한되고, 초과 시 `429`와 `Retry-After`를 반환합니다. 토큰을 아직 입력하지 않은 요청(콘솔 최초 로드)은 시도로 세지 않으므로 정상 운영자가 잠기지 않습니다.
## 프로젝트 구조 ## 프로젝트 구조
@ -57,32 +55,17 @@ src/rights_filter/ 분석·서버·통합·도메인 로직 (Python)
server/ http.server 기반 API + 정적 파일 서빙, SQLite 저장소 server/ http.server 기반 API + 정적 파일 서빙, SQLite 저장소
analysis/ 이미지 분석·위험도 산출 analysis/ 이미지 분석·위험도 산출
integrations/ Naver / Google / LLM 어댑터 integrations/ Naver / Google / LLM 어댑터
web/operator-gui/ 운영자 콘솔 (정적 SPA: index.html, app-*.js, styles.css) web/operator-gui/ 운영자 콘솔 (정적 SPA: index.html, app.js, styles.css)
tests/ pytest 테스트 tests/ pytest 테스트
operator_gui/ 운영자 GUI 정적 검증 + Playwright E2E operator_gui/ 운영자 GUI 정적 검증 + Playwright E2E
docs/ 기획·설계·운영 문서 docs/ 기획·설계·운영 문서
``` ```
콘솔 스크립트는 관심사별로 `app-*.js` 15개로 나뉘어 있고, **`index.html` 의 로드 순서가 ## 테스트
의미를 갖습니다**(클래식 스크립트가 하나의 전역 스코프를 공유). 순서와 목록은
[`docs/PROJECT_MAP.md`](docs/PROJECT_MAP.md) 에 있으며 정적 테스트가 이를 고정합니다.
## 검증 / 테스트
커밋 전 게이트는 **단일 명령**입니다(CI 도 같은 명령을 호출합니다). ruff 린트와 pytest 전체를
순서대로 실행합니다.
```powershell
.\verify.ps1 # 린트 + 테스트 전체
.\verify.ps1 -SkipLint # 빠른 내부 루프 (테스트만)
.\verify.ps1 -Coverage # src/ 커버리지 리포트 포함
```
개별 실행이 필요할 때:
```bash ```bash
python -m pytest # 또는 그냥 pytest — pytest.ini 가 경로를 설정합니다 # 전체
python -m ruff check src tests # 설정: ruff.toml python -m pytest
# 운영자 GUI E2E (Playwright) # 운영자 GUI E2E (Playwright)
playwright install chromium # 빌드 머신에서 1회 — 브라우저 바이너리는 오프라인 번들에 포함 playwright install chromium # 빌드 머신에서 1회 — 브라우저 바이너리는 오프라인 번들에 포함
@ -94,8 +77,6 @@ python -m pytest tests/operator_gui/
## 문서 ## 문서
- [프로젝트 구조 지도 (PROJECT_MAP)](docs/PROJECT_MAP.md) — 파일·라우트·스키마 위치, 로드 순서, 알려진 부채
- [작업 이력 (INDEX)](docs/INDEX.md)
- [프로젝트 소개 및 기술 구현](docs/project-introduction-and-technical-implementation.md) - [프로젝트 소개 및 기술 구현](docs/project-introduction-and-technical-implementation.md)
- [운영 워크리스트](docs/operations/copyrighter-operation-worklist.md) - [운영 워크리스트](docs/operations/copyrighter-operation-worklist.md)
- [오프라인 설치 절차](docs/operations/offline-install.md) - [오프라인 설치 절차](docs/operations/offline-install.md)

View file

@ -1,7 +1,5 @@
# Project INDEX # Project INDEX
- 2026-08-08 · 프로젝트 분석 후 인프라·구조·보안 정비 — **원격 백업 복구**(20커밋이 로컬에만 있었음: `main` 2 + 작업브랜치 18 → Forgejo push), **단일 verify 게이트 신설**(`verify.ps1` + `pytest.ini` + `ruff.toml`, ruff 0.12.0 dev 핀; 린트 18건 정리), **`app.js` 5,428줄 → `app-*.js` 15개 분할**(순수 라인범위 추출로 바이트 단위 라운드트립 검증, 로드 순서를 정적 테스트로 고정), **보안 2종 실수정**(SSRF DNS-rebinding TOCTOU — 단일 DNS 해석 후 그 답변으로만 연결하는 `_guarded_create_connection`; 실패 인증 스로틀 — 자격증명 제시 실패만 10회/60초, `429`+`Retry-After`), 7/11 체크리스트 재확인(6건은 이미 수정돼 있던 낡은 항목, Batch 8 은 Batch 6 과 중복이라 정리), `CLAUDE.md`·`docs/PROJECT_MAP.md` 신설. E2E 플레이크(업로드 대기 10초 → `UPLOAD_ANALYSIS_TIMEOUT_MS` 30초) 해소. **pytest 562 passed**(착수 전 533/1 failed), 린트 클린. 실렌더 확인: 큐·케이스 심사 정상 렌더, 스크립트 15개 전부 200, 새로고침 후 콘솔 메시지 0
- 2026-07-24 · 멀티에이전트 프로젝트 점검(에이전트 109, 적대적 검증 포함) — pytest 377/379, 런타임 스모크 9/9 통과, 신규 CONFIRMED 43건(HIGH 7) + 7/11 체크리스트 중 3건 실수정 확인 + 기능 제안 상위 5 선정 → [docs/reviews/2026-07-24-project-review.md](reviews/2026-07-24-project-review.md) - 2026-07-24 · 멀티에이전트 프로젝트 점검(에이전트 109, 적대적 검증 포함) — pytest 377/379, 런타임 스모크 9/9 통과, 신규 CONFIRMED 43건(HIGH 7) + 7/11 체크리스트 중 3건 실수정 확인 + 기능 제안 상위 5 선정 → [docs/reviews/2026-07-24-project-review.md](reviews/2026-07-24-project-review.md)
- 2026-07-26 · 운영자 콘솔 실렌더 검증 + 결함 5종 수정 (`d20e6d6`) — 서버 기동 불가(cp949에서 em-dash `UnicodeEncodeError`로 프로세스 사망, 납품 환경 치명) · 요약이 모델이 못 본 78건을 "연결 근거"로 주장(B단계가 절반만 막았음) · `grounded/droppedClaims` 미영속화 · app.js가 서버 그룹 분류를 자체 매핑으로 덮어씀 · 기본 모델이 클라이언트/배선에서 불일치(미설치 모델 지목). 실렌더 확인: 출처 표기 그룹 자체 섹션 렌더, `[E1,E2...]` 인용 2단 요약, `sourceIds` 78→10, qwen2.5:7b 6.0초. **pytest 534 passed** - 2026-07-26 · 운영자 콘솔 실렌더 검증 + 결함 5종 수정 (`d20e6d6`) — 서버 기동 불가(cp949에서 em-dash `UnicodeEncodeError`로 프로세스 사망, 납품 환경 치명) · 요약이 모델이 못 본 78건을 "연결 근거"로 주장(B단계가 절반만 막았음) · `grounded/droppedClaims` 미영속화 · app.js가 서버 그룹 분류를 자체 매핑으로 덮어씀 · 기본 모델이 클라이언트/배선에서 불일치(미설치 모델 지목). 실렌더 확인: 출처 표기 그룹 자체 섹션 렌더, `[E1,E2...]` 인용 2단 요약, `sourceIds` 78→10, qwen2.5:7b 6.0초. **pytest 534 passed**
- 2026-07-25 · C단계: 출처 신호 2종 (`5a9fcc0`) — EXIF/IPTC 권리 표기 추출(화이트리스트 6필드, GPS·시리얼·Make/Model 미판독) + 방송캡처 포렌식(레터박스·자막밴드·프레임크기, 독립 카테고리 2개 이상). 하나의 `EvidenceSource.PROVENANCE`·하나의 GUI 그룹으로 배관 12곳 비용 1회 지불. **둘 다 0점 라벨**(캘리브레이션 전). 실측: 실제 이미지 6장에서 캡처 오탐 0·권리메타 히트 0(정밀도 확인, 가치는 이 코퍼스에서 미입증). XMP/C2PA는 `defusedxml` 미반입으로 제외. **pytest 531 passed** - 2026-07-25 · C단계: 출처 신호 2종 (`5a9fcc0`) — EXIF/IPTC 권리 표기 추출(화이트리스트 6필드, GPS·시리얼·Make/Model 미판독) + 방송캡처 포렌식(레터박스·자막밴드·프레임크기, 독립 카테고리 2개 이상). 하나의 `EvidenceSource.PROVENANCE`·하나의 GUI 그룹으로 배관 12곳 비용 1회 지불. **둘 다 0점 라벨**(캘리브레이션 전). 실측: 실제 이미지 6장에서 캡처 오탐 0·권리메타 히트 0(정밀도 확인, 가치는 이 코퍼스에서 미입증). XMP/C2PA는 `defusedxml` 미반입으로 제외. **pytest 531 passed**

View file

@ -1,177 +0,0 @@
# PROJECT MAP
> 어디에 무엇이 있는지에 대한 참조 문서. **작업 전 여기서 위치를 찾고 필요한 부분만 연다**
> (전체 소스 재스캔 금지). 새 파일·라우트·스키마가 생기면 해당 섹션을 갱신한다.
>
> 무엇을 했는지의 기록은 [INDEX.md](INDEX.md)에 있다. 이 문서는 **현재 구조**만 다룬다.
## 한 줄 요약
이미지 저작권 위험을 자동 분석하고, 운영자가 콘솔에서 근거를 확인해 승인/보류/반려를
판정하는 폐쇄망(air-gapped) 도구. Python 3.13 표준 `http.server` + SQLite + 정적 SPA.
런타임 서드파티는 이미지 처리 3종(`numpy`/`opencv-python-headless`/`pillow`)뿐.
## 검증 (단일 명령)
```powershell
.\verify.ps1 # ruff + pytest 전체 — 커밋 전 게이트, CI도 이 명령
.\verify.ps1 -SkipLint # 빠른 내부 루프
.\verify.ps1 -Coverage # src/ 커버리지 포함
```
설정은 `pytest.ini`(testpaths·pythonpath·timeout)와 `ruff.toml`(py313·line-length 120)에 있다.
## 실행
```powershell
python run_copyrighter_server.py # 기본 127.0.0.1:9500
```
런처가 `PYTHONPATH=src`와 작업 디렉터리를 고정한다. 옵션: `--host --port --db --images --static --env`.
---
## 소스 지도 (`src/rights_filter/`)
### `analysis/` — 이미지 분석과 위험도 산출
| 파일 | 역할 |
|---|---|
| `preprocessing.py` | 디코드, 외부 전송용 파생물 생성(메타데이터 제거), 얼굴 크롭 파생물 |
| `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` 은 가중치/버킷/밴드 변경 시 반드시 올린다** |
| `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 폐기 |
| `provenance.py` | 출처 신호 — EXIF/IPTC 권리 표기 추출(화이트리스트 6필드), 방송캡처 포렌식. **둘 다 현재 0점 라벨**(캘리브레이션 전) |
| `derivatives.py`, `reason_builder.py` | 파생물 기록, 사유 문자열 조립 |
### `server/` — HTTP + 저장소
`http_app.py` (559줄) 가 라우팅·인증·보안 헤더를 담당하고, 저장소는 `CopyrighterStore`
하나를 **믹스인 8개**로 조립한다 (`sqlite_store.py:77`).
| 파일 | 역할 |
|---|---|
| `http_app.py` | `build_server()`, `CopyrighterHandler`. 라우팅 / Bearer+세션쿠키 인증 / CSP·CSRF / 바디 상한 10MB |
| `auth_rate_limit.py` | 실패한 인증 시도 스로틀(고정 윈도우 10회/60초, 클라이언트 주소별) |
| `sqlite_store.py` | `CopyrighterStore` 조립 + 시드·리스코어 |
| `store_schema.py` | DDL·제약·타입 컬럼 마이그레이션. `SCHEMA_VERSION` |
| `store_persistence.py` | `_put`/`_all` 등 저수준 upsert (PK 충돌 시 update) |
| `store_serialization.py` | 도메인 ↔ DB payload 왕복. **라운드트립은 원 점수를 재현해야 한다**(과거 위반 이력 있음) |
| `store_enrichment.py` | provider 상태 동기화, 얼굴 증거/크롭, 재분석, 자동 Naver/Google 검색 드라이버 |
| `store_operations.py` | 운영자 액션(판정·메모·재분석 요청) |
| `store_workflow.py` | 일괄 판정(`MAX_BATCH_DECISIONS`), 워크플로 |
| `store_search_candidates.py` | 검색 후보 수집·유사도 비교 |
| `store_page_scrape.py` | 후보 페이지/스타일시트 파싱 |
| `store_remote_fetch.py` | **네트워크 경계**. SSRF 가드 — 사전 URL 검사 + `_guarded_create_connection`(단일 DNS 해석으로 rebinding TOCTOU 차단) + 리다이렉트 홉 재검증 |
| `store_queue.py`, `store_duplicates.py`, `store_backup.py`, `store_text.py`, `store_url_utils.py`, `store_constants.py` | 큐, 지문 중복, 백업/복구, 텍스트·URL 유틸, 상수 |
| `image_store.py` | 제출 이미지 파일 저장소 + manifest |
| `env_file.py` | `.env` 로더 (utf-8-sig — BOM이 첫 키에 붙지 않도록) |
### `integrations/` — 외부 어댑터
`naver_search.py`, `google_custom_search.py`, `cloud_vision_web_detection.py`,
`web_detection_result_mapper.py`, `http_json.py`.
쿼터/정책은 `search_policy.py`(`SearchApiPolicy` — `try_reserve`/`release` 로 원자적 예약)와
`external_policy.py`(`ExternalApiPolicy`). **둘 다 날짜 경계 윈도우 리셋 있음**.
`env_clients.py` 가 `.env` → 어댑터/정책 조립(`build_provider_runtime`).
### `domain/` · `governance/` · `jobs/` · `admin/`
- `domain/records.py` — `Evidence`, `EvidenceSource`, `ScoreResult` 등 핵심 레코드
- `governance/` — 접근·보정·보존 정책 (얼굴 크롭 보존기간 등)
- `jobs/batch_analyzer.py` — 배치 분석, `retry_policy.py`, `usage_limits.py`
- `admin/` — 운영자 리뷰 핸들러·프레젠터
---
## 운영자 콘솔 (`web/operator-gui/`)
빌드 스텝 없음. 전부 클래식 스크립트(모듈 아님)라 **하나의 전역 스코프를 공유**한다.
`index.html` → `styles.css` + 보조 스크립트 4개(`operator-labels.js`, `submission-import.js`,
`evidence-guidance.js`, `operator-search.js`) → **`app-*.js` 15개**.
### `app-*.js` 로드 순서 (변경 금지 — 순서가 곧 계약)
```
app-state.js 상태·라벨 임포트 (나머지 전부가 읽는다)
app-api.js 토큰 프롬프트, fetch 래퍼, bootstrap/refresh
app-format.js 커버리지 계산, HTML/URL 이스케이프, 라벨 포맷터
app-selectors.js 상태 파생 조회 (선택 케이스, 위험/출처 클래스, 증거 필터)
app-render-queue.js 큐 목록, 임포트 상태, 케이스 리뷰 셸
app-render-evidence.js 증거 그룹화·정렬·마크업
app-render-panels.js 지식DB·후보·보정·provider·감사 패널, renderAll
app-navigation.js 뷰/탭 전환, 케이스 선택, 판정, 수동 검색, 재분석
app-keyboard-batch.js 키보드 심사 모드, 중복 그룹, 일괄 판정
app-decision-tools.js 메모 프리셋, 판정 내보내기, 백업, cleared reference, LLM 초안
app-stats.js 판정 통계·캘리브레이션
app-submissions.js 제출물 리로드/임포트/업로드, 지식 항목 생성
app-collections.js 후보 수집·기각·선택·승격
app-admin.js 지식/보정/provider/증거상태 관리 액션
app-bootstrap.js bindEvents, init, DOM 리스너 ← 유일하게 로드 시점에 실행됨
```
이 순서는 `tests/operator_gui/test_static_workbench.py` 의
`test_index_loads_every_app_part_exactly_once_in_declared_order` 가 고정한다.
파일을 추가·제거·재배치하면 그 테스트의 `APP_SCRIPT_NAMES` 와 `index.html` 을 함께 고친다.
---
## HTTP 라우트 (`http_app.py`)
인증: `COPYRIGHTER_AUTH_TOKEN` 설정 시 `/api/*` 와 미디어 라우트는 Bearer 헤더 또는 세션 쿠키
필요. 정적 셸과 `/health` 는 열려 있다(운영자가 토큰을 입력할 수 있어야 하므로).
**GET** — `/health` · `/api/bootstrap` · `/api/review-queue` · `/api/submissions/{id}/review` ·
`/api/providers` · `/api/providers/health` · `/api/audit-events` · `/api/decision-memo-presets` ·
`/api/decisions/export` · `/api/admin/backups` · `/api/stats/decisions` ·
`/media/` · `/knowledge-media/` · `/collected-media/` · `/face-crop-media/`
**POST** — `/api/submissions/{id}/search-auto|rerun-enrichment|decision|clear-reference` ·
`/api/submissions/reload|import-folder|upload-image|decision-batch` ·
`/api/evidence/{id}/status` · `/api/search/manual` · `/api/knowledge/manual` ·
`/api/knowledge/{id}/promote-watchlist|exclude-watchlist|deactivate|reactivate` ·
`/api/collections/keyword` · `/api/collections/candidates/promote-batch` ·
`/api/collections/candidates/{id}/promote|dismiss` ·
`/api/decision-memo-presets` (+ `/{id}/delete`) · `/api/admin/backup` ·
`/api/providers/emergency-disable`
**PATCH** — `/api/knowledge/{id}`
---
## SQLite 스키마 (`store_schema.py`, `SCHEMA_VERSION = 2`)
제약 있는 테이블 7개 — `submissions`, `evidence`, `providers`, `knowledge_entries`,
`collection_candidates`, `corrections`, `audit_events`. 추가로 `submission_queues`.
각 테이블은 조회·정렬용 타입 컬럼 + 전체 도메인 객체를 담은 `payload` JSON 컬럼 구조.
`evidence.submission_id` 는 `submissions(id)` 에 `on delete cascade`.
WAL 적용됨. DB 기본 경로는 `data/copyrighter.sqlite3`(gitignore).
---
## 테스트 (`tests/`, 562건)
- `tests/rights_filter/` — 단위·통합. `conftest.py` 가 `src` 를 sys.path 에 넣는다
- `tests/operator_gui/` — 정적 검증 + Playwright E2E.
- Page Object: `pages/workbench_page.py` (`OperatorWorkbench`). 업로드 대기는
`UPLOAD_ANALYSIS_TIMEOUT_MS`(30초) — 업로드는 서버에서 실분석을 돌리므로 일반 대기보다 길다
- Chromium 없으면 **자동 skip**(`importorskip`)
- 정적 테스트는 `app-*.js` 전체를 이어붙여(`_read_app()`) 내용을 검사한다 —
어느 파일에 있는지가 아니라 콘솔에 그 동작이 있는지를 본다
---
## 알려진 부채
| 항목 | 위치 | 비고 |
|---|---|---|
| `_perceptual_hash` 가 pHash가 아닌 aHash | `analysis/fingerprints.py:47` | 균일/저디테일 이미지 유사도 오탐. 교체 시 0.9 임계 재보정 필요 |
| src 4파일이 800줄 초과 | `store_page_scrape.py`(972) `store_operations.py`(968) `sqlite_store.py`(904) `store_enrichment.py`(853) | 저장소 코어라 분할 리스크 있음. 별도 작업으로 |
| 출처 신호 2종 0점 | `analysis/provenance.py` | 캘리브레이션 전. 실측 코퍼스에서 가치 미입증 |
| 7/11 리뷰 연기 항목 | [reviews/2026-07-11-remediation-checklist.md](reviews/2026-07-11-remediation-checklist.md) | 각 항목에 연기 근거 기재 |

View file

@ -3,11 +3,6 @@
> 기준 리뷰: `docs/reviews/2026-07-11-project-review.md` (HEAD 89c1fe4) > 기준 리뷰: `docs/reviews/2026-07-11-project-review.md` (HEAD 89c1fe4)
> 신규 HIGH 9 + 유닛 추가 HIGH 3(통합/프론트) + MEDIUM ~28 + LOW ~14. > 신규 HIGH 9 + 유닛 추가 HIGH 3(통합/프론트) + MEDIUM ~28 + LOW ~14.
> 배치는 risk-per-effort 순. 각 배치 후 `pytest tests/rights_filter -q` 통과 확인. > 배치는 risk-per-effort 순. 각 배치 후 `pytest tests/rights_filter -q` 통과 확인.
>
> **2026-08-08 재확인**: 미체크로 남아 있던 항목을 코드와 대조했다. 6건은 이미 수정돼 있었고
> (체크리스트가 낡았던 것 — 각 항목에 확인 근거 기재), 보안 2건(#8 rate limit, SSRF
> DNS-rebinding TOCTOU)은 실제로 열려 있어 이번에 수정했다. 남은 미체크는 전부 **명시적 연기**
> 이며 각 줄에 근거가 있다.
## Batch 1 — 스코어링 파이프라인 복구 (최우선) ✅ 완료 ## Batch 1 — 스코어링 파이프라인 복구 (최우선) ✅ 완료
직렬화 라운드트립 필드 손실 1개 패턴이 HIGH 3건을 만듦. 프로모터 3건은 배포 즉시 노출되므로 동일 배치. 직렬화 라운드트립 필드 손실 1개 패턴이 HIGH 3건을 만듦. 프로모터 3건은 배포 즉시 노출되므로 동일 배치.
@ -51,8 +46,8 @@
- [x] LOW `store_remote_fetch.py` — SSRF `not is_global`(CGNAT 등 특수대역 차단) - [x] LOW `store_remote_fetch.py` — SSRF `not is_global`(CGNAT 등 특수대역 차단)
- [x] LOW `env_clients.py` — NAVER display 3종 클램프(1~100) - [x] LOW `env_clients.py` — NAVER display 3종 클램프(1~100)
- [x] M `env_clients.py` — daily-limit malformed → 보수적 100(fail-open 제거) + 테스트 갱신 - [x] M `env_clients.py` — daily-limit malformed → 보수적 100(fail-open 제거) + 테스트 갱신
- [x] M `naver_search.py` — 멀티페이지 쿼터 실제 요청 수 (2026-08-08 확인: `_requested_calls` + `try_reserve(requested_calls=…)`/`release` 로 이미 구현돼 있었음. 항목이 낡았던 것) - [ ] M `naver_search.py` — 멀티페이지 쿼터 실제 요청 수 (미착수)
- [x] M `store_remote_fetch.py` — SSRF DNS-rebinding TOCTOU (2026-08-08 수정: `_guarded_create_connection` 이 getaddrinfo 를 **1회만** 수행하고 그 답변의 모든 주소를 검증한 뒤 그중 하나로만 연결. `_Guarded*Connection`/`*Handler` 로 opener 에 배선 — 사전검사와 실제 다이얼의 해석이 갈라지지 않는다. 리바인딩 회귀 테스트 포함 14건) - [ ] M `store_remote_fetch.py` — SSRF DNS-rebinding TOCTOU (미착수 — 커스텀 opener 필요)
- [ ] LOW: EUC-KR 스니핑, dead code 삭제(레이아웃 테스트가 고정 → 유지) - [ ] LOW: EUC-KR 스니핑, dead code 삭제(레이아웃 테스트가 고정 → 유지)
## Batch 5 — HTTP 보안 ✅ (M10/M11/#6) ## Batch 5 — HTTP 보안 ✅ (M10/M11/#6)
@ -61,8 +56,8 @@
- [x] #6 앱셸 CSP/X-Frame-Options/nosniff + 테스트 - [x] #6 앱셸 CSP/X-Frame-Options/nosniff + 테스트
- [x] #8(부분) 토큰 길이<32 경고 + static 부재 경고(`__main__`) - [x] #8(부분) 토큰 길이<32 경고 + static 부재 경고(`__main__`)
- [ ] #7 쿠키 분리+Secure (연기 — Secure는 localhost HTTP서 쿠키 미전송→인증 붕괴; HTTPS 전제 필요) - [ ] #7 쿠키 분리+Secure (연기 — Secure는 localhost HTTP서 쿠키 미전송→인증 붕괴; HTTPS 전제 필요)
- [x] #8 rate limit (2026-08-08 수정: `server/auth_rate_limit.py` — **틀린 자격증명을 제시한** 시도만 클라이언트 주소별 고정윈도우 10회/60초로 제한, 초과 시 `429` + `Retry-After`. 성공하면 카운터 초기화. 토큰 미입력 요청(콘솔 최초 로드가 여러 건 발생시킴)은 세지 않으므로 정상 운영자가 잠기지 않는다. 테이블은 1024 클라이언트로 상한. 테스트 12건) - [ ] #8 rate limit (미착수)
- [x] #10 Origin/Referer CSRF 폴백 (2026-08-08 확인: `_origin_ok()` 로 이미 구현돼 있었음 — 7/24 리뷰가 지적한 낡은 항목) - [ ] #10 Origin/Referer CSRF 폴백 (미착수)
## Batch 6 — 프론트엔드 ✅ ## Batch 6 — 프론트엔드 ✅
- [x] H8 이미지 뷰어 버튼 구현 + 케이스별 리셋 - [x] H8 이미지 뷰어 버튼 구현 + 케이스별 리셋
@ -97,22 +92,16 @@
- [ ] #20 ScoreResult.reasons tuple (연기 — 테스트 다수 `== []` 단언 충돌) - [ ] #20 ScoreResult.reasons tuple (연기 — 테스트 다수 `== []` 단언 충돌)
## Batch 7 — env/config/docs ## Batch 7 — env/config/docs
- [x] `env_file.py:12` — UTF-8-sig BOM (2026-08-08 확인: `read_text(encoding="utf-8-sig")` 로 이미 적용됨) - [ ] `env_file.py:12` — UTF-8-sig BOM
- [x] daily-limit fail-open → 보수적 기본 (2026-08-08 확인: `_daily_limit()` 이 malformed 값에 warning + 100 적용. Batch 4 의 동일 항목과 중복 기재였음) - [ ] daily-limit fail-open → 보수적 기본
- [ ] `env_clients.py:239` — LLM assistant OLLAMA_* 게이팅 (연기 — env={} 로 LLM 을 기대하는 테스트 다수가 의존) - [ ] `env_clients.py:239` — LLM assistant OLLAMA_* 게이팅
- [x] `__main__.py` — 경로 ROOT 앵커링 + static 부재 경고 (2026-08-08 확인: `6ab6da3` 에서 적용됨) - [ ] `__main__.py` — 경로 ROOT 앵커링 + static 부재 경고
- [ ] offline-install.md — Ollama/Playwright/lock 해시 - [ ] offline-install.md — Ollama/Playwright/lock 해시
- [ ] python 3.13 가드, .gitignore, package.json 정리 - [ ] python 3.13 가드, .gitignore, package.json 정리
## Batch 8 — 에러핸들링/기타 백로그 ## Batch 8 — 에러핸들링/기타 백로그
- [ ] #14 무음 예외 로깅
> **2026-08-08: 이 배치는 Batch 6 과 중복된 착수 전 계획 목록이었다.** #14·#16·#18·#19 는 - [ ] #16 skip 사유 reasons 오염
> Batch 6 에서 이미 처리돼 [x] 로 기재돼 있고, #20 은 같은 곳에 연기 근거와 함께 남아 있다. - [ ] #19 EXIF/GPS PIL 폴백 경고
> 여기서 중복 추적하지 않는다 — 상태는 Batch 6 이 기준. - [ ] #20 ScoreResult.reasons tuple
- [ ] #18 dedup 핫패스 타깃 쿼리
- [x] #14 무음 예외 로깅 → Batch 6 참조 (잔여 swallow 는 Batch 6 에 별도 미체크로 남음)
- [x] #16 skip 사유 reasons 오염 → Batch 6 참조
- [x] #19 EXIF/GPS PIL 폴백 경고 → Batch 6 참조 (테스트 실행 로그에서 실제 warning 발생 확인:
`preprocessing.py:52 external derivative fell back to original bytes…`)
- [x] #18 dedup 핫패스 타깃 쿼리 → Batch 6 참조
- [ ] #20 ScoreResult.reasons tuple → Batch 6 의 연기 항목과 동일 건

View file

@ -1,15 +0,0 @@
[pytest]
testpaths = tests
# `tests.rights_filter.server.test_sqlite_store` is imported by sibling specs for
# shared fakes, and every module under test lives in src/. Declaring both here
# means bare `pytest` works; without it only `python -m pytest` did (that form
# puts the CWD on sys.path as a side effect, which is easy to get wrong in CI).
pythonpath = . src
# Hang guard, not a performance budget. The slowest single spec (browser upload
# + real analysis) runs well under a minute; this only fires when something
# deadlocks, so an unattended `verify` run fails instead of hanging forever.
timeout = 300
addopts = --strict-markers

View file

@ -3,9 +3,6 @@
pytest==8.4.2 pytest==8.4.2
pytest-cov==6.0.0 pytest-cov==6.0.0
pytest-timeout==2.4.0 pytest-timeout==2.4.0
# Lint gate in verify.ps1. Pinned because ruff's default rule set moves between
# releases; ruff.toml states the selection, this states the version.
ruff==0.12.0
# Operator-GUI browser (E2E) tests. The Chromium binary is NOT pip-installed: # Operator-GUI browser (E2E) tests. The Chromium binary is NOT pip-installed:
# on the build/staging host run `playwright install chromium`, then bundle the # on the build/staging host run `playwright install chromium`, then bundle the
# browser cache (PLAYWRIGHT_BROWSERS_PATH) into the offline artifact for the # browser cache (PLAYWRIGHT_BROWSERS_PATH) into the offline artifact for the

View file

@ -1,10 +0,0 @@
# Pinned so `verify` produces the same result on every machine. Without a config
# file ruff falls back to its built-in defaults, which shift between releases —
# a lint gate that changes under you is not a gate.
target-version = "py313"
line-length = 120
[lint]
# Ruff's default set (pycodestyle errors + pyflakes). Stated explicitly rather
# than inherited so a ruff upgrade cannot silently widen or narrow the gate.
select = ["E4", "E7", "E9", "F"]

View file

@ -5,6 +5,8 @@ from dataclasses import dataclass
from datetime import datetime from datetime import datetime
from typing import Any, Mapping from typing import Any, Mapping
logger = logging.getLogger(__name__)
from rights_filter.analysis.llm_assistance import ( from rights_filter.analysis.llm_assistance import (
DEFAULT_MODEL, DEFAULT_MODEL,
InternalLlmAssistant, InternalLlmAssistant,
@ -22,8 +24,6 @@ from rights_filter.integrations.google_custom_search import (
from rights_filter.integrations.naver_search import NaverOpenApiSearchClient, NaverSearchAdapter from rights_filter.integrations.naver_search import NaverOpenApiSearchClient, NaverSearchAdapter
from rights_filter.integrations.search_policy import SearchApiPolicy from rights_filter.integrations.search_policy import SearchApiPolicy
logger = logging.getLogger(__name__)
# Chosen by measurement against real stored evidence (54 rows, 12 selected) on # Chosen by measurement against real stored evidence (54 rows, 12 selected) on
# the GPU target, not by parameter count: # the GPU target, not by parameter count:
# qwen2.5:0.5b-instruct 55.7s — echoed the shape example as its claim text # qwen2.5:0.5b-instruct 55.7s — echoed the shape example as its claim text

View file

@ -1,107 +0,0 @@
"""Failed-authentication throttling for the operator server.
``COPYRIGHTER_AUTH_TOKEN`` is the only gate in front of the data routes, and
nothing else slows down guessing it: every wrong bearer token costs an attacker
one cheap request. This adds a fixed-window cap on *failed* attempts per client
address, so an online brute force stops being viable while a working operator —
whose attempts succeed, clearing the counter — never meets it.
Deliberately only failures are counted. Throttling successful traffic would
punish an operator working through a review queue for no security gain.
Deployment note: the client identity is the peer address. On the intended
single-host / closed-network deployment that is the real client. Behind a
reverse proxy every request would share one address, and one bad actor could
then throttle the operator too — put the proxy in front of a token it controls,
or terminate closer to the console, if that ever becomes the topology.
"""
from __future__ import annotations
import threading
import time
from dataclasses import dataclass
# A wrong token ten times in a minute is not an operator typo pattern; it is a
# script. Generous enough that a person fumbling a paste never notices.
MAX_FAILURES_PER_WINDOW = 10
WINDOW_SECONDS = 60
# Bound the table so a spoofed-source flood cannot grow it without limit. Far
# above the number of clients a single-operator console legitimately sees.
MAX_TRACKED_CLIENTS = 1024
@dataclass
class _Window:
failures: int
started_at: float
class FailedAuthRateLimiter:
"""Fixed-window cap on failed auth attempts, keyed by client address."""
def __init__(
self,
max_failures: int = MAX_FAILURES_PER_WINDOW,
window_seconds: int = WINDOW_SECONDS,
max_tracked_clients: int = MAX_TRACKED_CLIENTS,
clock=time.monotonic,
) -> None:
self._max_failures = max(1, max_failures)
self._window_seconds = max(1, window_seconds)
self._max_tracked_clients = max(1, max_tracked_clients)
self._clock = clock
self._lock = threading.Lock()
self._windows: dict[str, _Window] = {}
def retry_after(self, client: str) -> int:
"""Seconds the client must wait, or 0 when it may attempt now."""
with self._lock:
window = self._windows.get(client)
if window is None:
return 0
elapsed = self._clock() - window.started_at
if elapsed >= self._window_seconds:
# Window rolled; the client starts clean.
del self._windows[client]
return 0
if window.failures < self._max_failures:
return 0
# Round up so a caller that waits exactly this long is past the edge
# rather than one fractional second short of it.
return max(1, int(self._window_seconds - elapsed) + 1)
def record_failure(self, client: str) -> None:
with self._lock:
now = self._clock()
window = self._windows.get(client)
if window is None or now - window.started_at >= self._window_seconds:
self._evict_if_full_locked(now)
self._windows[client] = _Window(failures=1, started_at=now)
return
window.failures += 1
def record_success(self, client: str) -> None:
"""A correct token clears the client's record."""
with self._lock:
self._windows.pop(client, None)
def _evict_if_full_locked(self, now: float) -> None:
if len(self._windows) < self._max_tracked_clients:
return
expired = [
client
for client, window in self._windows.items()
if now - window.started_at >= self._window_seconds
]
for client in expired:
del self._windows[client]
if len(self._windows) < self._max_tracked_clients:
return
# Still full of live windows: drop the oldest so the table stays bounded.
oldest = min(self._windows, key=lambda client: self._windows[client].started_at)
del self._windows[oldest]
__all__ = ["FailedAuthRateLimiter", "MAX_FAILURES_PER_WINDOW", "WINDOW_SECONDS"]

View file

@ -13,7 +13,6 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path from pathlib import Path
from urllib.parse import parse_qs, unquote, urlparse from urllib.parse import parse_qs, unquote, urlparse
from rights_filter.server.auth_rate_limit import FailedAuthRateLimiter
from rights_filter.server.image_store import LocalSubmissionImageStore, SUPPORTED_IMAGE_SUFFIXES from rights_filter.server.image_store import LocalSubmissionImageStore, SUPPORTED_IMAGE_SUFFIXES
from rights_filter.server.sqlite_store import CopyrighterStore from rights_filter.server.sqlite_store import CopyrighterStore
@ -87,9 +86,6 @@ def build_server(
auth_token: str | None = None, auth_token: str | None = None,
) -> ThreadingHTTPServer: ) -> ThreadingHTTPServer:
static_root = Path(static_dir).resolve() static_root = Path(static_dir).resolve()
# Shared across every worker thread: the token is the only gate in front of
# the data routes, so failed attempts have to be counted somewhere central.
auth_rate_limiter = FailedAuthRateLimiter()
class CopyrighterHandler(BaseHTTPRequestHandler): class CopyrighterHandler(BaseHTTPRequestHandler):
server_version = "CopyrighterHTTP/0.1" server_version = "CopyrighterHTTP/0.1"
@ -339,15 +335,6 @@ def build_server(
# routes are protected. # routes are protected.
if not any(path.startswith(prefix) for prefix in _PROTECTED_PREFIXES): if not any(path.startswith(prefix) for prefix in _PROTECTED_PREFIXES):
return True return True
client = self.client_address[0] if self.client_address else "unknown"
retry_after = auth_rate_limiter.retry_after(client)
if retry_after:
self._json(
{"error": "too many failed authentication attempts"},
HTTPStatus.TOO_MANY_REQUESTS,
extra_headers={"Retry-After": str(retry_after)},
)
return False
header = self.headers.get("Authorization", "") header = self.headers.get("Authorization", "")
# Compare as bytes: hmac.compare_digest raises TypeError on str inputs # Compare as bytes: hmac.compare_digest raises TypeError on str inputs
# containing non-ASCII, and that exception escapes before any response # containing non-ASCII, and that exception escapes before any response
@ -360,15 +347,12 @@ def build_server(
# cookie so the browser can load <img>/media (which cannot send an # cookie so the browser can load <img>/media (which cannot send an
# Authorization header) without ever putting the token in a URL. # Authorization header) without ever putting the token in a URL.
self._issue_session_cookie = True self._issue_session_cookie = True
auth_rate_limiter.record_success(client)
return True return True
cookie_header = self.headers.get("Cookie", "") cookie_header = self.headers.get("Cookie", "")
presented_session_cookie = False
if cookie_header: if cookie_header:
jar = SimpleCookie() jar = SimpleCookie()
jar.load(cookie_header) jar.load(cookie_header)
morsel = jar.get(_SESSION_COOKIE) morsel = jar.get(_SESSION_COOKIE)
presented_session_cookie = morsel is not None
if morsel is not None and hmac.compare_digest( if morsel is not None and hmac.compare_digest(
morsel.value.encode("utf-8", "replace"), morsel.value.encode("utf-8", "replace"),
_session_tag(auth_token).encode("utf-8"), _session_tag(auth_token).encode("utf-8"),
@ -378,16 +362,7 @@ def build_server(
if self.command in _STATE_CHANGING_METHODS and not self._origin_ok(): if self.command in _STATE_CHANGING_METHODS and not self._origin_ok():
self._json({"error": "invalid origin"}, HTTPStatus.FORBIDDEN) self._json({"error": "invalid origin"}, HTTPStatus.FORBIDDEN)
return False return False
auth_rate_limiter.record_success(client)
return True return True
# Count only attempts that actually presented a credential and got it
# wrong. A request carrying neither header nor cookie is the console
# bootstrapping before the operator has typed the token — the GUI
# fires several of those per load, and counting them would let two
# honest page loads lock the operator out. A rejected origin above is
# likewise not a guess: that is a valid session failing a CSRF check.
if header or presented_session_cookie:
auth_rate_limiter.record_failure(client)
self._json({"error": "unauthorized"}, HTTPStatus.UNAUTHORIZED) self._json({"error": "unauthorized"}, HTTPStatus.UNAUTHORIZED)
return False return False
@ -441,18 +416,11 @@ def build_server(
f"{_SESSION_COOKIE}={_session_tag(auth_token)}; HttpOnly; SameSite=Strict; Path=/", f"{_SESSION_COOKIE}={_session_tag(auth_token)}; HttpOnly; SameSite=Strict; Path=/",
) )
def _json( def _json(self, payload: object, status: HTTPStatus = HTTPStatus.OK) -> None:
self,
payload: object,
status: HTTPStatus = HTTPStatus.OK,
extra_headers: dict[str, str] | None = None,
) -> None:
data = json.dumps(payload, ensure_ascii=False).encode("utf-8") data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
self.send_response(status) self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8") self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(data))) self.send_header("Content-Length", str(len(data)))
for name, value in (extra_headers or {}).items():
self.send_header(name, value)
self._maybe_set_session_cookie() self._maybe_set_session_cookie()
self.end_headers() self.end_headers()
self.wfile.write(data) self.wfile.write(data)

View file

@ -15,6 +15,8 @@ from dataclasses import replace
from datetime import datetime from datetime import datetime
from typing import Any from typing import Any
logger = logging.getLogger(__name__)
from rights_filter.analysis.face_person_detection import ( from rights_filter.analysis.face_person_detection import (
HeuristicFacePersonDetector, HeuristicFacePersonDetector,
face_person_evidence_data, face_person_evidence_data,
@ -46,8 +48,6 @@ from rights_filter.server.store_serialization import (
_query_history_status, _query_history_status,
) )
logger = logging.getLogger(__name__)
class StoreEnrichmentMixin: class StoreEnrichmentMixin:
def _sync_provider_payloads(self) -> None: def _sync_provider_payloads(self) -> None:

View file

@ -9,9 +9,8 @@ from __future__ import annotations
import ipaddress import ipaddress
import socket import socket
from http.client import HTTPConnection, HTTPSConnection
from urllib.parse import urlparse from urllib.parse import urlparse
from urllib.request import HTTPHandler, HTTPRedirectHandler, HTTPSHandler, Request, build_opener from urllib.request import HTTPRedirectHandler, Request, build_opener
from rights_filter.server.store_url_utils import _is_http_url from rights_filter.server.store_url_utils import _is_http_url
@ -64,23 +63,11 @@ def _fetch_stylesheet_url_bytes(url: str, timeout: int = 10, limit: int = CANDID
return _fetch_url_bytes_with_headers(url, STYLESHEET_FETCH_HEADERS, timeout, limit) return _fetch_url_bytes_with_headers(url, STYLESHEET_FETCH_HEADERS, timeout, limit)
def _assert_public_ip(raw_ip: str) -> None:
# `not is_global` rejects every special-purpose range at once — including
# CGNAT 100.64.0.0/10, which an explicit is_private/is_loopback/... list
# missed — and only lets genuinely globally-routable addresses through.
if not ipaddress.ip_address(raw_ip).is_global:
raise ValueError("refusing to fetch internal address")
def _assert_public_http_url(url: str) -> None: def _assert_public_http_url(url: str) -> None:
# SSRF guard: candidate image / page / stylesheet URLs come from external # SSRF guard: candidate image / page / stylesheet URLs come from external
# search-result content (attacker-influenceable), so refuse to fetch # search-result content (attacker-influenceable), so refuse to fetch
# anything that resolves to a non-public address (loopback, RFC1918, # anything that resolves to a non-public address (loopback, RFC1918,
# link-local cloud-metadata 169.254.169.254, etc.). # link-local cloud-metadata 169.254.169.254, etc.).
#
# This is the pre-flight rejection. It is NOT what makes the fetch safe on
# its own — see _guarded_create_connection for the check that binds to the
# address actually connected to.
parsed = urlparse(url) parsed = urlparse(url)
if parsed.scheme not in {"http", "https"} or not parsed.hostname: if parsed.scheme not in {"http", "https"} or not parsed.hostname:
raise ValueError("only public http(s) URLs may be fetched") raise ValueError("only public http(s) URLs may be fetched")
@ -90,76 +77,12 @@ def _assert_public_http_url(url: str) -> None:
except OSError as exc: except OSError as exc:
raise ValueError("could not resolve fetch host") from exc raise ValueError("could not resolve fetch host") from exc
for *_, sockaddr in infos: for *_, sockaddr in infos:
_assert_public_ip(sockaddr[0]) ip = ipaddress.ip_address(sockaddr[0])
# `not is_global` rejects every special-purpose range at once — including
# CGNAT 100.64.0.0/10, which an explicit is_private/is_loopback/... list
def _guarded_create_connection(address, timeout=None, source_address=None): # missed — and only lets genuinely globally-routable addresses through.
"""``socket.create_connection`` that vets the address it actually dials. if not ip.is_global:
raise ValueError("refusing to fetch internal address")
Without this, the guard is a TOCTOU: ``_assert_public_http_url`` resolves the
host to decide whether the fetch is allowed, and then http.client resolves it
a *second* time to open the socket. A DNS-rebinding host answers the first
lookup with a public address and the second with 127.0.0.1 — the guard passes
and the connection still lands inside the network. Resolving once here and
dialing only an address from that same answer closes the window.
Every address in the answer must be public, not merely the one we dial: a
host resolving to both a routable and an internal address is the rebinding
pattern itself, and which entry ``connect`` reaches is not ours to predict.
"""
host, port = address[0], address[1]
try:
infos = socket.getaddrinfo(host, port, proto=socket.IPPROTO_TCP)
except OSError as exc:
raise ValueError("could not resolve fetch host") from exc
if not infos:
raise ValueError("could not resolve fetch host")
for *_, sockaddr in infos:
_assert_public_ip(sockaddr[0])
last_error: Exception | None = None
for family, socktype, proto, _canonname, sockaddr in infos:
sock = socket.socket(family, socktype, proto)
try:
# http.client passes socket._GLOBAL_DEFAULT_TIMEOUT (a sentinel
# object, not a number) when no timeout was set; only a real number
# is meaningful to settimeout.
if isinstance(timeout, (int, float)):
sock.settimeout(timeout)
if source_address:
sock.bind(source_address)
sock.connect(sockaddr)
return sock
except OSError as exc:
last_error = exc
sock.close()
raise last_error if last_error is not None else ValueError("could not connect to fetch host")
class _GuardedHTTPConnection(HTTPConnection):
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
# `_create_connection` is the seam HTTPConnection.connect dials through,
# so overriding it keeps the rest of connect() (TCP_NODELAY, tunnelling)
# and, for HTTPS, the TLS wrap that still verifies against self.host.
self._create_connection = _guarded_create_connection
class _GuardedHTTPSConnection(HTTPSConnection):
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
self._create_connection = _guarded_create_connection
class _GuardedHTTPHandler(HTTPHandler):
def http_open(self, req):
return self.do_open(_GuardedHTTPConnection, req)
class _GuardedHTTPSHandler(HTTPSHandler):
def https_open(self, req):
return self.do_open(_GuardedHTTPSConnection, req, context=self._context)
class _ValidatingRedirectHandler(HTTPRedirectHandler): class _ValidatingRedirectHandler(HTTPRedirectHandler):
@ -170,11 +93,7 @@ class _ValidatingRedirectHandler(HTTPRedirectHandler):
return super().redirect_request(req, fp, code, msg, headers, newurl) return super().redirect_request(req, fp, code, msg, headers, newurl)
_SSRF_SAFE_OPENER = build_opener( _SSRF_SAFE_OPENER = build_opener(_ValidatingRedirectHandler())
_GuardedHTTPHandler(),
_GuardedHTTPSHandler(),
_ValidatingRedirectHandler(),
)
def _open_url(request: Request, timeout: int): def _open_url(request: Request, timeout: int):

View file

@ -20,12 +20,6 @@ SUGGESTED_QUERY_LOADED_STATUS = "추천 쿼리를 입력했습니다. 실행 버
# Client-side guard message when a reject/correct decision is missing its memo. # Client-side guard message when a reject/correct decision is missing its memo.
MEMO_REQUIRED_ERROR = "반려 또는 보정 결정에는 메모가 필요합니다." MEMO_REQUIRED_ERROR = "반려 또는 보정 결정에는 메모가 필요합니다."
# Uploading runs the real analysis pipeline server-side (decode, fingerprints,
# YuNet face detection) before the case appears in the queue, so this path needs
# far more headroom than a mocked-bootstrap wait. At 10s it passed standalone but
# failed inside the full suite, where the other specs contend for the same CPU.
UPLOAD_ANALYSIS_TIMEOUT_MS = 30000
class OperatorWorkbench: class OperatorWorkbench:
"""Drives the operator GUI for browser-level (E2E) assertions.""" """Drives the operator GUI for browser-level (E2E) assertions."""
@ -129,9 +123,7 @@ class OperatorWorkbench:
self.page.get_by_role("button", name="사진 넣기").click() self.page.get_by_role("button", name="사진 넣기").click()
return self return self
def upload_and_open( def upload_and_open(self, file_path: str, case_id: str, timeout: int = 10000) -> "OperatorWorkbench":
self, file_path: str, case_id: str, timeout: int = UPLOAD_ANALYSIS_TIMEOUT_MS
) -> "OperatorWorkbench":
"""Upload an image and wait until its new case is queued and the workbench opens.""" """Upload an image and wait until its new case is queued and the workbench opens."""
self.upload_submission(file_path) self.upload_submission(file_path)
self.confirm_upload() self.confirm_upload()

View file

@ -3,7 +3,7 @@ from __future__ import annotations
import pytest import pytest
from .pages import OperatorWorkbench from .pages import OperatorWorkbench
from .pages.workbench_page import SUGGESTED_QUERY_LOADED_STATUS, UPLOAD_ANALYSIS_TIMEOUT_MS from .pages.workbench_page import SUGGESTED_QUERY_LOADED_STATUS
def _bootstrap_payload(): def _bootstrap_payload():
@ -104,8 +104,8 @@ def test_browser_uploads_image_and_selects_new_submission(operator_server, opera
assert workbench.submission_image_name.inner_text() == "smoke upload.svg" assert workbench.submission_image_name.inner_text() == "smoke upload.svg"
workbench.confirm_upload() workbench.confirm_upload()
workbench.wait_for_case("smoke-upload", state="attached", timeout=UPLOAD_ANALYSIS_TIMEOUT_MS) workbench.wait_for_case("smoke-upload", state="attached", timeout=10000)
workbench.workbench_view.wait_for(state="visible", timeout=UPLOAD_ANALYSIS_TIMEOUT_MS) workbench.workbench_view.wait_for(state="visible", timeout=10000)
assert (operator_server.image_root / "images" / "smoke-upload.svg").exists() assert (operator_server.image_root / "images" / "smoke-upload.svg").exists()
assert ( assert (

View file

@ -8,27 +8,7 @@ ROOT = Path(__file__).resolve().parents[2]
APP_DIR = ROOT / "web" / "operator-gui" APP_DIR = ROOT / "web" / "operator-gui"
INDEX = APP_DIR / "index.html" INDEX = APP_DIR / "index.html"
STYLES = APP_DIR / "styles.css" STYLES = APP_DIR / "styles.css"
# The former single app.js, split by concern. Order is load-bearing (classic APP_JS = APP_DIR / "app.js"
# scripts sharing one global scope), so this list doubles as the expectation the
# index.html ordering test asserts against.
APP_SCRIPT_NAMES = [
"app-state.js",
"app-api.js",
"app-format.js",
"app-selectors.js",
"app-render-queue.js",
"app-render-evidence.js",
"app-render-panels.js",
"app-navigation.js",
"app-keyboard-batch.js",
"app-decision-tools.js",
"app-stats.js",
"app-submissions.js",
"app-collections.js",
"app-admin.js",
"app-bootstrap.js",
]
APP_SCRIPTS = [APP_DIR / name for name in APP_SCRIPT_NAMES]
OPERATOR_LABELS_JS = APP_DIR / "operator-labels.js" OPERATOR_LABELS_JS = APP_DIR / "operator-labels.js"
SUBMISSION_IMPORT_JS = APP_DIR / "submission-import.js" SUBMISSION_IMPORT_JS = APP_DIR / "submission-import.js"
EVIDENCE_GUIDANCE_JS = APP_DIR / "evidence-guidance.js" EVIDENCE_GUIDANCE_JS = APP_DIR / "evidence-guidance.js"
@ -53,20 +33,10 @@ def _read(path: Path) -> str:
return path.read_text(encoding="utf-8") return path.read_text(encoding="utf-8")
def _read_app() -> str:
"""The whole operator app script, whichever part a given snippet now lives in.
These specs assert that a behavior is present in the console, not which file
holds it, so they read the parts as one text in load order.
"""
return "\n".join(_read(path) for path in APP_SCRIPTS)
def test_operator_workbench_files_exist(): def test_operator_workbench_files_exist():
assert INDEX.exists() assert INDEX.exists()
assert STYLES.exists() assert STYLES.exists()
for path in APP_SCRIPTS: assert APP_JS.exists()
assert path.exists(), path.name
assert OPERATOR_LABELS_JS.exists() assert OPERATOR_LABELS_JS.exists()
assert SUBMISSION_IMPORT_JS.exists() assert SUBMISSION_IMPORT_JS.exists()
assert EVIDENCE_GUIDANCE_JS.exists() assert EVIDENCE_GUIDANCE_JS.exists()
@ -93,7 +63,7 @@ def test_operator_gui_text_does_not_include_mojibake_fragments():
"?", "?",
] ]
for path in [INDEX, *APP_SCRIPTS, OPERATOR_LABELS_JS, SUBMISSION_IMPORT_JS, EVIDENCE_GUIDANCE_JS, OPERATOR_SEARCH_JS]: for path in [INDEX, APP_JS, OPERATOR_LABELS_JS, SUBMISSION_IMPORT_JS, EVIDENCE_GUIDANCE_JS, OPERATOR_SEARCH_JS]:
text = _read(path) text = _read(path)
for fragment in suspect_fragments: for fragment in suspect_fragments:
assert fragment not in text, f"{path.name} contains mojibake fragment {fragment!r}" assert fragment not in text, f"{path.name} contains mojibake fragment {fragment!r}"
@ -184,7 +154,7 @@ def test_workbench_shell_exposes_all_internal_operator_views():
def test_static_app_models_core_review_operations(): def test_static_app_models_core_review_operations():
script = _read_app() script = _read(APP_JS)
for state_name in [ for state_name in [
"submissions", "submissions",
@ -223,56 +193,18 @@ def test_static_app_models_core_review_operations():
assert f"function {operation}" in script assert f"function {operation}" in script
def test_index_loads_every_app_part_exactly_once_in_declared_order():
"""The split is only safe while the load order holds.
The parts are classic scripts sharing one global scope: app-state.js declares
the state the rest read, and app-bootstrap.js is the only part that executes
anything at load. Reordering, dropping, or adding a part without updating
index.html would break the console at runtime but leave every content-level
assertion in this module green, so pin the sequence itself.
"""
html = _read(INDEX)
positions = []
for name in APP_SCRIPT_NAMES:
tag = f'<script src="{name}" defer></script>'
assert html.count(tag) == 1, f"{name} must be loaded exactly once"
positions.append(html.index(tag))
assert positions == sorted(positions), "app-*.js tags are out of declared order"
assert APP_SCRIPT_NAMES[-1] == "app-bootstrap.js", "the part that starts the app must load last"
# No part may be orphaned: every app-*.js on disk has to be wired into the page.
on_disk = sorted(path.name for path in APP_DIR.glob("app-*.js"))
assert on_disk == sorted(APP_SCRIPT_NAMES)
# The pre-split single file must be gone, or a stale copy would silently
# double-define everything if anyone re-added a tag for it.
assert not (APP_DIR / "app.js").exists()
def test_app_parts_stay_within_the_file_size_budget():
"""800 lines per file is the project's stated ceiling (coding-style rules)."""
oversized = {
path.name: len(_read(path).splitlines()) for path in APP_SCRIPTS if len(_read(path).splitlines()) > 800
}
assert not oversized, f"app parts over the 800-line budget: {oversized}"
def test_operator_gui_extracts_submission_import_helpers_from_main_app(): def test_operator_gui_extracts_submission_import_helpers_from_main_app():
html = _read(INDEX) html = _read(INDEX)
script = _read_app() script = _read(APP_JS)
labels = _read(OPERATOR_LABELS_JS) labels = _read(OPERATOR_LABELS_JS)
helpers = _read(SUBMISSION_IMPORT_JS) helpers = _read(SUBMISSION_IMPORT_JS)
guidance = _read(EVIDENCE_GUIDANCE_JS) guidance = _read(EVIDENCE_GUIDANCE_JS)
search = _read(OPERATOR_SEARCH_JS) search = _read(OPERATOR_SEARCH_JS)
first_app_part = f'src="{APP_SCRIPT_NAMES[0]}"'
assert html.index('src="operator-labels.js"') < html.index('src="submission-import.js"') assert html.index('src="operator-labels.js"') < html.index('src="submission-import.js"')
assert html.index('src="submission-import.js"') < html.index(first_app_part) assert html.index('src="submission-import.js"') < html.index('src="app.js"')
assert html.index('src="evidence-guidance.js"') < html.index(first_app_part) assert html.index('src="evidence-guidance.js"') < html.index('src="app.js"')
assert html.index('src="operator-search.js"') < html.index(first_app_part) assert html.index('src="operator-search.js"') < html.index('src="app.js"')
assert "OperatorLabels" in labels assert "OperatorLabels" in labels
assert "riskLabels" in labels assert "riskLabels" in labels
assert "providerLabels" in labels assert "providerLabels" in labels
@ -299,7 +231,7 @@ def test_operator_gui_extracts_submission_import_helpers_from_main_app():
def test_operator_gui_does_not_boot_with_demo_review_data(): def test_operator_gui_does_not_boot_with_demo_review_data():
script = _read_app() script = _read(APP_JS)
for collection in [ for collection in [
"submissions", "submissions",
@ -371,7 +303,7 @@ def test_ui_overhaul_final_audit_has_no_overflow_and_required_screenshots():
def test_header_coverage_uses_horizontal_filter_tabs_not_scrolling_panel(): def test_header_coverage_uses_horizontal_filter_tabs_not_scrolling_panel():
html = _read(INDEX) html = _read(INDEX)
styles = _read(STYLES) styles = _read(STYLES)
script = _read_app() script = _read(APP_JS)
assert 'id="coverage-tabs"' in html assert 'id="coverage-tabs"' in html
assert 'id="search-coverage"' not in html assert 'id="search-coverage"' not in html
@ -386,7 +318,7 @@ def test_header_coverage_uses_horizontal_filter_tabs_not_scrolling_panel():
def test_queue_uses_grid_row_contract_for_dense_evidence_and_provider_columns(): def test_queue_uses_grid_row_contract_for_dense_evidence_and_provider_columns():
html = _read(INDEX) html = _read(INDEX)
styles = _read(STYLES) styles = _read(STYLES)
script = _read_app() script = _read(APP_JS)
assert "queue-grid" in html assert "queue-grid" in html
assert "queue-row" in script assert "queue-row" in script
@ -417,7 +349,7 @@ def test_visual_overhaul_removes_obsolete_header_cards_and_fixed_table_widths():
def test_case_workbench_owns_evidence_and_query_history_as_internal_tabs(): def test_case_workbench_owns_evidence_and_query_history_as_internal_tabs():
html = _read(INDEX) html = _read(INDEX)
script = _read_app() script = _read(APP_JS)
styles = _read(STYLES) styles = _read(STYLES)
assert 'id="workbench-view"' in html assert 'id="workbench-view"' in html
@ -455,7 +387,7 @@ def test_case_workbench_tabs_do_not_share_the_same_review_layout():
def test_case_decision_controls_float_while_reviewing_evidence(): def test_case_decision_controls_float_while_reviewing_evidence():
html = _read(INDEX) html = _read(INDEX)
script = _read_app() script = _read(APP_JS)
styles = _read(STYLES) styles = _read(STYLES)
assert "floating-decision-panel" in html assert "floating-decision-panel" in html
@ -472,7 +404,7 @@ def test_case_decision_controls_float_while_reviewing_evidence():
def test_knowledge_base_rows_separate_title_metadata_chips_and_actions(): def test_knowledge_base_rows_separate_title_metadata_chips_and_actions():
script = _read_app() script = _read(APP_JS)
styles = _read(STYLES) styles = _read(STYLES)
assert "knowledge-main" in script assert "knowledge-main" in script
@ -491,7 +423,7 @@ def test_knowledge_base_rows_separate_title_metadata_chips_and_actions():
def test_correction_history_lives_inside_knowledge_database_panel(): def test_correction_history_lives_inside_knowledge_database_panel():
html = _read(INDEX) html = _read(INDEX)
script = _read_app() script = _read(APP_JS)
styles = _read(STYLES) styles = _read(STYLES)
assert 'data-knowledge-tab="collect"' in html assert 'data-knowledge-tab="collect"' in html
@ -528,7 +460,7 @@ def test_knowledge_collection_and_registered_entries_are_separate_tabs():
def test_safety_rules_are_visible_in_ui_contract(): def test_safety_rules_are_visible_in_ui_contract():
html = _read(INDEX) html = _read(INDEX)
script = _read_app() script = _read(APP_JS)
assert 'id="decision-memo"' in html assert 'id="decision-memo"' in html
assert 'id="manual-query-provider"' in html assert 'id="manual-query-provider"' in html
@ -544,7 +476,7 @@ def test_safety_rules_are_visible_in_ui_contract():
def test_provider_quota_zero_does_not_render_nan_meter(): def test_provider_quota_zero_does_not_render_nan_meter():
script = _read_app() script = _read(APP_JS)
assert "provider.quota ? Math.min" in script assert "provider.quota ? Math.min" in script
assert "configuredEnv" in script assert "configuredEnv" in script
@ -552,7 +484,7 @@ def test_provider_quota_zero_does_not_render_nan_meter():
def test_query_history_rerun_preserves_original_search_provider(): def test_query_history_rerun_preserves_original_search_provider():
script = _read_app() script = _read(APP_JS)
search = _read(OPERATOR_SEARCH_JS) search = _read(OPERATOR_SEARCH_JS)
assert "data-rerun-provider" in script assert "data-rerun-provider" in script
@ -565,7 +497,7 @@ def test_query_history_rerun_preserves_original_search_provider():
def test_operator_gui_exposes_submission_reload_action(): def test_operator_gui_exposes_submission_reload_action():
html = _read(INDEX) html = _read(INDEX)
script = _read_app() script = _read(APP_JS)
assert 'id="submission-folder"' in html assert 'id="submission-folder"' in html
assert 'id="submission-image"' in html assert 'id="submission-image"' in html
@ -587,7 +519,7 @@ def test_operator_gui_exposes_submission_reload_action():
def test_operator_gui_uses_clear_korean_status_copy_for_queue_imports(): def test_operator_gui_uses_clear_korean_status_copy_for_queue_imports():
script = _read_app() + _read(SUBMISSION_IMPORT_JS) script = _read(APP_JS) + _read(SUBMISSION_IMPORT_JS)
for required_copy in [ for required_copy in [
"제출 폴더를 읽는 중입니다.", "제출 폴더를 읽는 중입니다.",
@ -606,7 +538,7 @@ def test_operator_gui_uses_clear_korean_status_copy_for_queue_imports():
def test_operator_gui_bulk_rerun_uses_checked_queue_rows(): def test_operator_gui_bulk_rerun_uses_checked_queue_rows():
script = _read_app() script = _read(APP_JS)
assert "function selectedBulkSubmissionIds" in script assert "function selectedBulkSubmissionIds" in script
assert 'input[data-bulk-id]:checked' in script assert 'input[data-bulk-id]:checked' in script
@ -616,7 +548,7 @@ def test_operator_gui_bulk_rerun_uses_checked_queue_rows():
def test_operator_gui_manual_knowledge_entry_accepts_reference_image(): def test_operator_gui_manual_knowledge_entry_accepts_reference_image():
html = _read(INDEX) html = _read(INDEX)
script = _read_app() script = _read(APP_JS)
assert 'id="knowledge-image"' in html assert 'id="knowledge-image"' in html
assert 'id="knowledge-image-name"' in html assert 'id="knowledge-image-name"' in html
@ -631,7 +563,7 @@ def test_operator_gui_manual_knowledge_entry_accepts_reference_image():
def test_operator_gui_exposes_keyword_candidate_collection_workflow(): def test_operator_gui_exposes_keyword_candidate_collection_workflow():
html = _read(INDEX) html = _read(INDEX)
script = _read_app() script = _read(APP_JS)
search = _read(OPERATOR_SEARCH_JS) search = _read(OPERATOR_SEARCH_JS)
assert 'id="candidate-collection-form"' in html assert 'id="candidate-collection-form"' in html
@ -680,7 +612,7 @@ def test_operator_gui_exposes_keyword_candidate_collection_workflow():
def test_google_custom_search_is_exposed_as_operator_text_query_choice(): def test_google_custom_search_is_exposed_as_operator_text_query_choice():
html = _read(INDEX) html = _read(INDEX)
script = _read_app() script = _read(APP_JS)
search = _read(OPERATOR_SEARCH_JS) search = _read(OPERATOR_SEARCH_JS)
labels = _read(OPERATOR_LABELS_JS) labels = _read(OPERATOR_LABELS_JS)
@ -706,7 +638,7 @@ def test_audit_target_and_change_columns_have_equal_widths():
def test_queue_provider_judgments_render_on_one_line_with_narrower_reason_column(): def test_queue_provider_judgments_render_on_one_line_with_narrower_reason_column():
script = _read_app() script = _read(APP_JS)
styles = _read(STYLES) styles = _read(STYLES)
assert "provider-strip" in script assert "provider-strip" in script
@ -723,7 +655,7 @@ def test_queue_provider_judgments_render_on_one_line_with_narrower_reason_column
def test_evidence_operator_status_actions_are_binary_use_or_ignore(): def test_evidence_operator_status_actions_are_binary_use_or_ignore():
script = _read_app() script = _read(APP_JS)
assert '["used_for_judgment", "사용"]' in script assert '["used_for_judgment", "사용"]' in script
assert '["ignored", "미사용"]' in script assert '["ignored", "미사용"]' in script
@ -735,7 +667,7 @@ def test_evidence_operator_status_actions_are_binary_use_or_ignore():
def test_operator_gui_uses_korean_operator_copy_for_visible_labels(): def test_operator_gui_uses_korean_operator_copy_for_visible_labels():
html = _read(INDEX) html = _read(INDEX)
script = _read_app() script = _read(APP_JS)
for english_label in [ for english_label in [
"Image Rights Operator Console", "Image Rights Operator Console",
@ -770,7 +702,7 @@ def test_operator_gui_uses_korean_operator_copy_for_visible_labels():
def test_operator_gui_renders_search_result_links_and_thumbnails(): def test_operator_gui_renders_search_result_links_and_thumbnails():
script = _read_app() script = _read(APP_JS)
assert "renderEvidenceLink" in script assert "renderEvidenceLink" in script
assert "thumbnailUrl" in script assert "thumbnailUrl" in script
@ -793,7 +725,7 @@ def test_operator_gui_renders_search_result_links_and_thumbnails():
def test_operator_gui_prioritizes_evidence_and_hides_overflow_details(): def test_operator_gui_prioritizes_evidence_and_hides_overflow_details():
script = _read_app() script = _read(APP_JS)
styles = _read(STYLES) styles = _read(STYLES)
assert "renderEvidenceSummary" in script assert "renderEvidenceSummary" in script
@ -808,7 +740,7 @@ def test_operator_gui_prioritizes_evidence_and_hides_overflow_details():
def test_operator_gui_suggests_followup_queries_for_insufficient_evidence(): def test_operator_gui_suggests_followup_queries_for_insufficient_evidence():
html = _read(INDEX) html = _read(INDEX)
script = _read_app() script = _read(APP_JS)
guidance = _read(EVIDENCE_GUIDANCE_JS) guidance = _read(EVIDENCE_GUIDANCE_JS)
combined_script = script + guidance combined_script = script + guidance
styles = _read(STYLES) styles = _read(STYLES)
@ -834,7 +766,7 @@ def test_operator_gui_suggests_followup_queries_for_insufficient_evidence():
def test_operator_gui_separates_face_crop_web_evidence_from_identity_matching(): def test_operator_gui_separates_face_crop_web_evidence_from_identity_matching():
script = _read_app() script = _read(APP_JS)
assert "faceCropSearch" in script assert "faceCropSearch" in script
assert "face_web" in script assert "face_web" in script
@ -843,7 +775,7 @@ def test_operator_gui_separates_face_crop_web_evidence_from_identity_matching():
def test_operator_gui_exposes_evidence_status_and_watchlist_controls(): def test_operator_gui_exposes_evidence_status_and_watchlist_controls():
script = _read_app() script = _read(APP_JS)
styles = _read(STYLES) styles = _read(STYLES)
assert "/api/evidence/" in script assert "/api/evidence/" in script
@ -864,7 +796,7 @@ def test_visual_assets_are_referenced_for_review_images():
assets_dir = APP_DIR / "assets" assets_dir = APP_DIR / "assets"
assert (assets_dir / "case-portrait.svg").exists() assert (assets_dir / "case-portrait.svg").exists()
assert "assets/case-portrait.svg" in html or "assets/case-portrait.svg" in _read_app() assert "assets/case-portrait.svg" in html or "assets/case-portrait.svg" in _read(APP_JS)
for orphan_asset in [ for orphan_asset in [
"case-character.svg", "case-character.svg",
@ -875,7 +807,7 @@ def test_visual_assets_are_referenced_for_review_images():
def test_suggested_queries_support_one_click_and_batch_execution(): def test_suggested_queries_support_one_click_and_batch_execution():
script = _read_app() script = _read(APP_JS)
styles = _read(STYLES) styles = _read(STYLES)
assert "executeSuggestedQueries" in script assert "executeSuggestedQueries" in script
@ -891,7 +823,7 @@ def test_suggested_queries_support_one_click_and_batch_execution():
def test_registered_knowledge_panel_supports_search_filter_edit_and_lifecycle(): def test_registered_knowledge_panel_supports_search_filter_edit_and_lifecycle():
html = _read(INDEX) html = _read(INDEX)
script = _read_app() script = _read(APP_JS)
registered_panel = html.split('data-knowledge-panel="registered"', 1)[1].split('data-knowledge-panel="manual"', 1)[0] registered_panel = html.split('data-knowledge-panel="registered"', 1)[1].split('data-knowledge-panel="manual"', 1)[0]
assert 'id="knowledge-search"' in registered_panel assert 'id="knowledge-search"' in registered_panel
@ -911,7 +843,7 @@ def test_registered_knowledge_panel_supports_search_filter_edit_and_lifecycle():
def test_workbench_shows_detected_face_crop_strip(): def test_workbench_shows_detected_face_crop_strip():
html = _read(INDEX) html = _read(INDEX)
script = _read_app() script = _read(APP_JS)
assert 'id="face-crop-strip"' in html assert 'id="face-crop-strip"' in html
assert "faceCrops" in script assert "faceCrops" in script
@ -924,7 +856,7 @@ def test_workbench_shows_detected_face_crop_strip():
def test_rerun_diff_summary_and_new_evidence_badges_are_rendered(): def test_rerun_diff_summary_and_new_evidence_badges_are_rendered():
html = _read(INDEX) html = _read(INDEX)
script = _read_app() script = _read(APP_JS)
styles = _read(STYLES) styles = _read(STYLES)
assert 'id="rerun-diff-summary"' in html assert 'id="rerun-diff-summary"' in html

View file

@ -1,123 +0,0 @@
"""Specs for the failed-authentication throttle."""
from __future__ import annotations
from rights_filter.server.auth_rate_limit import FailedAuthRateLimiter
class FakeClock:
def __init__(self) -> None:
self.now = 1000.0
def __call__(self) -> float:
return self.now
def advance(self, seconds: float) -> None:
self.now += seconds
def _limiter(clock: FakeClock, **kwargs) -> FailedAuthRateLimiter:
return FailedAuthRateLimiter(max_failures=3, window_seconds=60, clock=clock, **kwargs)
def test_client_may_attempt_before_any_failure():
limiter = _limiter(FakeClock())
assert limiter.retry_after("10.0.0.1") == 0
def test_failures_below_the_cap_do_not_block():
clock = FakeClock()
limiter = _limiter(clock)
limiter.record_failure("10.0.0.1")
limiter.record_failure("10.0.0.1")
assert limiter.retry_after("10.0.0.1") == 0
def test_reaching_the_cap_blocks_the_client():
clock = FakeClock()
limiter = _limiter(clock)
for _ in range(3):
limiter.record_failure("10.0.0.1")
retry_after = limiter.retry_after("10.0.0.1")
assert retry_after > 0
assert retry_after <= 61
def test_block_applies_only_to_the_offending_client():
limiter = _limiter(FakeClock())
for _ in range(3):
limiter.record_failure("10.0.0.1")
assert limiter.retry_after("10.0.0.1") > 0
assert limiter.retry_after("10.0.0.2") == 0
def test_window_rolls_after_it_expires():
clock = FakeClock()
limiter = _limiter(clock)
for _ in range(3):
limiter.record_failure("10.0.0.1")
assert limiter.retry_after("10.0.0.1") > 0
clock.advance(61)
assert limiter.retry_after("10.0.0.1") == 0
def test_waiting_the_advertised_retry_after_is_enough():
clock = FakeClock()
limiter = _limiter(clock)
for _ in range(3):
limiter.record_failure("10.0.0.1")
clock.advance(30)
retry_after = limiter.retry_after("10.0.0.1")
assert retry_after > 0
clock.advance(retry_after)
assert limiter.retry_after("10.0.0.1") == 0
def test_success_clears_the_record():
limiter = _limiter(FakeClock())
limiter.record_failure("10.0.0.1")
limiter.record_failure("10.0.0.1")
limiter.record_success("10.0.0.1")
limiter.record_failure("10.0.0.1")
# The counter restarted, so this client is nowhere near the cap.
assert limiter.retry_after("10.0.0.1") == 0
def test_tracking_table_stays_bounded_under_many_distinct_clients():
clock = FakeClock()
limiter = _limiter(clock, max_tracked_clients=8)
for index in range(200):
limiter.record_failure(f"10.0.0.{index}")
assert len(limiter._windows) <= 8
def test_expired_windows_are_reclaimed_before_live_ones_are_dropped():
clock = FakeClock()
limiter = _limiter(clock, max_tracked_clients=4)
for index in range(4):
limiter.record_failure(f"10.0.0.{index}")
clock.advance(61) # every tracked window is now expired
limiter.record_failure("10.0.9.9")
assert limiter.retry_after("10.0.9.9") == 0
assert "10.0.9.9" in limiter._windows
assert len(limiter._windows) <= 4

View file

@ -1179,11 +1179,7 @@ def test_http_server_runs_auto_search_for_submission(tmp_path: Path):
def test_operator_gui_uses_api_bootstrap(): def test_operator_gui_uses_api_bootstrap():
# app.js was split into ordered app-*.js parts; this spec cares that the app_js = Path("web/operator-gui/app.js").read_text(encoding="utf-8")
# console bootstraps off the API, not which part holds the call.
app_js = "\n".join(
path.read_text(encoding="utf-8") for path in sorted(Path("web/operator-gui").glob("app-*.js"))
)
assert "/api/bootstrap" in app_js assert "/api/bootstrap" in app_js
assert "refreshFromApi" in app_js assert "refreshFromApi" in app_js
@ -1407,98 +1403,6 @@ def test_protected_routes_require_token_when_configured(tmp_path: Path):
server.shutdown() server.shutdown()
def test_repeated_wrong_tokens_are_throttled_with_retry_after(tmp_path: Path):
# 2026-07-11 #8: the shared token is the only gate on the data routes, so a
# wrong guess must not stay free forever.
from rights_filter.server.auth_rate_limit import MAX_FAILURES_PER_WINDOW
static_dir, image_store, store = _fixtures(tmp_path)
server = build_server(
host="127.0.0.1", port=0, store=store, image_store=image_store,
static_dir=static_dir, auth_token="secret",
)
_start(server)
base = f"http://127.0.0.1:{server.server_port}"
try:
for _ in range(MAX_FAILURES_PER_WINDOW):
guess = Request(base + "/api/bootstrap", headers={"Authorization": "Bearer wrong"})
with pytest.raises(HTTPError) as exc:
urlopen(guess)
assert exc.value.code == 401
# Cap reached: further attempts are refused outright, with a wait hint.
blocked = Request(base + "/api/bootstrap", headers={"Authorization": "Bearer wrong"})
with pytest.raises(HTTPError) as exc_blocked:
urlopen(blocked)
assert exc_blocked.value.code == 429
assert int(exc_blocked.value.headers.get("Retry-After")) > 0
# The throttle covers the correct token too while the window is open —
# otherwise it would leak which guess was right.
correct = Request(base + "/api/bootstrap", headers={"Authorization": "Bearer secret"})
with pytest.raises(HTTPError) as exc_correct:
urlopen(correct)
assert exc_correct.value.code == 429
# The unauthenticated shell still loads, so the operator is not locked
# out of the page itself.
assert urlopen(base + "/health").status == 200
finally:
server.shutdown()
def test_credential_less_requests_do_not_count_toward_the_auth_throttle(tmp_path: Path):
# The console fires several API calls per load before the operator has typed
# the token. Those are 401s, not guesses — counting them would let two honest
# page loads lock the operator out.
from rights_filter.server.auth_rate_limit import MAX_FAILURES_PER_WINDOW
static_dir, image_store, store = _fixtures(tmp_path)
server = build_server(
host="127.0.0.1", port=0, store=store, image_store=image_store,
static_dir=static_dir, auth_token="secret",
)
_start(server)
base = f"http://127.0.0.1:{server.server_port}"
try:
for _ in range(MAX_FAILURES_PER_WINDOW * 2):
with pytest.raises(HTTPError) as exc:
urlopen(base + "/api/bootstrap")
assert exc.value.code == 401
# Never throttled, and the right token still works.
authed = Request(base + "/api/bootstrap", headers={"Authorization": "Bearer secret"})
assert urlopen(authed).status == 200
finally:
server.shutdown()
def test_successful_auth_clears_earlier_failures(tmp_path: Path):
from rights_filter.server.auth_rate_limit import MAX_FAILURES_PER_WINDOW
static_dir, image_store, store = _fixtures(tmp_path)
server = build_server(
host="127.0.0.1", port=0, store=store, image_store=image_store,
static_dir=static_dir, auth_token="secret",
)
_start(server)
base = f"http://127.0.0.1:{server.server_port}"
try:
# Fumble almost to the cap, then get it right.
for _ in range(MAX_FAILURES_PER_WINDOW - 1):
with pytest.raises(HTTPError):
urlopen(Request(base + "/api/bootstrap", headers={"Authorization": "Bearer wrong"}))
assert urlopen(Request(base + "/api/bootstrap", headers={"Authorization": "Bearer secret"})).status == 200
# The counter restarted, so the operator gets a full allowance again.
for _ in range(MAX_FAILURES_PER_WINDOW - 1):
with pytest.raises(HTTPError) as exc:
urlopen(Request(base + "/api/bootstrap", headers={"Authorization": "Bearer wrong"}))
assert exc.value.code == 401
finally:
server.shutdown()
def test_cookie_authed_state_change_requires_same_origin(tmp_path: Path): def test_cookie_authed_state_change_requires_same_origin(tmp_path: Path):
# 2026-07-11 #10: a cookie-authenticated POST from a foreign Origin (classic # 2026-07-11 #10: a cookie-authenticated POST from a foreign Origin (classic
# CSRF) is rejected; header-authed requests are not forgeable. # CSRF) is rejected; header-authed requests are not forgeable.

View file

@ -3171,9 +3171,7 @@ def test_search_result_candidate_image_similarity_respects_runtime_threshold(
): ):
image_store = _png_image_store(tmp_path / "submissions") image_store = _png_image_store(tmp_path / "submissions")
original_image = image_store.image_payload("SUB-PNG1").content original_image = image_store.image_payload("SUB-PNG1").content
fetcher = lambda _: original_image
def fetcher(_):
return original_image
runtime = build_provider_runtime( runtime = build_provider_runtime(
env={"COPYRIGHTER_SEARCH_RESULT_SIMILARITY_THRESHOLD": "0.95"}, env={"COPYRIGHTER_SEARCH_RESULT_SIMILARITY_THRESHOLD": "0.95"},
@ -3289,9 +3287,7 @@ def test_sqlite_store_compares_unwrapped_search_result_image_urls_against_submis
def test_search_result_similarity_collects_multiple_matches_within_limit(tmp_path: Path): def test_search_result_similarity_collects_multiple_matches_within_limit(tmp_path: Path):
image_store = _png_image_store(tmp_path / "submissions") image_store = _png_image_store(tmp_path / "submissions")
original_image = image_store.image_payload("SUB-PNG1").content original_image = image_store.image_payload("SUB-PNG1").content
fetcher = lambda _: original_image
def fetcher(_):
return original_image
runtime = build_provider_runtime( runtime = build_provider_runtime(
env={"COPYRIGHTER_SEARCH_RESULT_COMPARE_LIMIT": "3"}, env={"COPYRIGHTER_SEARCH_RESULT_COMPARE_LIMIT": "3"},

View file

@ -1,143 +0,0 @@
"""SSRF guard specs for the remote fetch boundary.
Candidate image / page / stylesheet URLs come out of external search results, so
the host on the other end is attacker-influenceable. These specs pin the two
halves of the guard: the pre-flight URL check, and the check on the address the
socket actually dials (which is what closes the DNS-rebinding window).
"""
from __future__ import annotations
import socket
from urllib.request import HTTPHandler, HTTPRedirectHandler, HTTPSHandler
import pytest
from rights_filter.server import store_remote_fetch as remote_fetch
from rights_filter.server.store_remote_fetch import (
_assert_public_http_url,
_guarded_create_connection,
_GuardedHTTPConnection,
_GuardedHTTPSConnection,
)
def _addrinfo(*ips: str, port: int = 80):
return [(socket.AF_INET, socket.SOCK_STREAM, socket.IPPROTO_TCP, "", (ip, port)) for ip in ips]
PUBLIC_IP = "93.184.216.34"
@pytest.mark.parametrize(
"internal_ip",
[
"127.0.0.1", # loopback
"10.0.0.5", # RFC1918
"192.168.1.10", # RFC1918
"169.254.169.254", # cloud metadata
"100.64.0.1", # CGNAT
],
)
def test_preflight_refuses_urls_resolving_to_internal_addresses(monkeypatch, internal_ip):
monkeypatch.setattr(socket, "getaddrinfo", lambda *a, **k: _addrinfo(internal_ip))
with pytest.raises(ValueError, match="refusing to fetch internal address"):
_assert_public_http_url("http://collector.example/image.png")
def test_preflight_refuses_non_http_schemes():
for url in ["file:///etc/passwd", "ftp://example.com/x", "gopher://example.com"]:
with pytest.raises(ValueError, match="only public http"):
_assert_public_http_url(url)
def test_preflight_allows_a_public_address(monkeypatch):
monkeypatch.setattr(socket, "getaddrinfo", lambda *a, **k: _addrinfo(PUBLIC_IP))
_assert_public_http_url("https://example.com/image.png") # does not raise
def test_connection_guard_refuses_an_internal_address(monkeypatch):
monkeypatch.setattr(socket, "getaddrinfo", lambda *a, **k: _addrinfo("127.0.0.1"))
with pytest.raises(ValueError, match="refusing to fetch internal address"):
_guarded_create_connection(("collector.example", 80), 5)
def test_connection_guard_refuses_a_mixed_public_and_internal_answer(monkeypatch):
# A host resolving to both a routable and an internal address is the
# rebinding pattern itself; which entry connect() reaches is not predictable,
# so the whole answer is refused rather than just the internal entry.
monkeypatch.setattr(socket, "getaddrinfo", lambda *a, **k: _addrinfo(PUBLIC_IP, "10.0.0.5"))
with pytest.raises(ValueError, match="refusing to fetch internal address"):
_guarded_create_connection(("collector.example", 80), 5)
def test_dns_rebinding_between_preflight_and_connect_is_refused(monkeypatch):
"""The regression this guard exists for.
The pre-flight resolves the host and sees a public address. By the time the
socket is opened the same name resolves to loopback. Before the connection
was guarded, the fetch would have been allowed and would have hit the
internal address; now the second answer is re-checked and refused.
"""
answers = [_addrinfo(PUBLIC_IP), _addrinfo("127.0.0.1")]
def rebinding_getaddrinfo(*_args, **_kwargs):
return answers.pop(0) if answers else _addrinfo("127.0.0.1")
monkeypatch.setattr(socket, "getaddrinfo", rebinding_getaddrinfo)
# First resolution: passes, exactly as the attacker intends.
_assert_public_http_url("http://rebind.example/image.png")
# Second resolution, at connect time: refused.
with pytest.raises(ValueError, match="refusing to fetch internal address"):
_guarded_create_connection(("rebind.example", 80), 5)
def test_connection_guard_reports_unresolvable_hosts_as_value_error(monkeypatch):
def failing_getaddrinfo(*_args, **_kwargs):
raise socket.gaierror("no such host")
monkeypatch.setattr(socket, "getaddrinfo", failing_getaddrinfo)
with pytest.raises(ValueError, match="could not resolve fetch host"):
_guarded_create_connection(("nope.example", 80), 5)
def test_guarded_connections_dial_through_the_validating_resolver():
# The guard only holds while http.client dials through it, so pin the seam
# rather than trusting that a later refactor keeps the override.
for connection_class in (_GuardedHTTPConnection, _GuardedHTTPSConnection):
connection = connection_class("example.com")
assert connection._create_connection is _guarded_create_connection
def test_shared_opener_installs_the_guarded_handlers():
handlers = remote_fetch._SSRF_SAFE_OPENER.handlers
assert any(isinstance(handler, remote_fetch._GuardedHTTPHandler) for handler in handlers)
assert any(isinstance(handler, remote_fetch._GuardedHTTPSHandler) for handler in handlers)
assert any(isinstance(handler, remote_fetch._ValidatingRedirectHandler) for handler in handlers)
def test_shared_opener_has_no_unguarded_http_handler_left():
"""An unguarded sibling handler would quietly reopen the hole.
build_opener drops a default class only when a subclass instance is passed
in; if the guarded handlers were ever registered under a different base, the
stock HTTPHandler would come back and serve http:// with no address check.
"""
handlers = remote_fetch._SSRF_SAFE_OPENER.handlers
http_handlers = [h for h in handlers if isinstance(h, HTTPHandler)]
https_handlers = [h for h in handlers if isinstance(h, HTTPSHandler)]
redirect_handlers = [h for h in handlers if isinstance(h, HTTPRedirectHandler)]
assert len(http_handlers) == 1 and type(http_handlers[0]) is remote_fetch._GuardedHTTPHandler
assert len(https_handlers) == 1 and type(https_handlers[0]) is remote_fetch._GuardedHTTPSHandler
assert len(redirect_handlers) == 1
assert type(redirect_handlers[0]) is remote_fetch._ValidatingRedirectHandler

View file

@ -7,7 +7,7 @@ from rights_filter.server.sqlite_store import CopyrighterStore
from rights_filter.server.store_backup import restore_backup from rights_filter.server.store_backup import restore_backup
from rights_filter.server.store_workflow import MAX_BATCH_DECISIONS from rights_filter.server.store_workflow import MAX_BATCH_DECISIONS
from tests.rights_filter.server.test_sqlite_store import FakeJsonTransport from tests.rights_filter.server.test_sqlite_store import _image_store, FakeJsonTransport
def _photo_store(root: Path, *, duplicate: bool = False): def _photo_store(root: Path, *, duplicate: bool = False):

View file

@ -1,57 +0,0 @@
<#
.SYNOPSIS
The single verification gate for POSA Copyrighter. Run this before commit,
before a delivery build, and from CI — same command, same result everywhere.
.DESCRIPTION
Runs, in order:
1. ruff — lint (config: ruff.toml)
2. pytest — full suite incl. operator-GUI E2E (config: pytest.ini)
Browser (Playwright) specs skip cleanly when Chromium is absent, so this
works on a host that has not run `playwright install chromium`.
.PARAMETER SkipLint
Run tests only. For a fast inner loop; the commit gate runs both.
.PARAMETER Coverage
Add a coverage report over src/.
.EXAMPLE
.\verify.ps1
.EXAMPLE
.\verify.ps1 -Coverage
#>
[CmdletBinding()]
param(
[switch]$SkipLint,
[switch]$Coverage
)
$ErrorActionPreference = 'Stop'
Set-Location -LiteralPath $PSScriptRoot
$failures = @()
if (-not $SkipLint) {
Write-Host "`n=== ruff check ===" -ForegroundColor Cyan
& python -m ruff check src tests
if ($LASTEXITCODE -ne 0) { $failures += 'ruff' }
}
Write-Host "`n=== pytest ===" -ForegroundColor Cyan
$pytestArgs = @()
if ($Coverage) {
$pytestArgs += @('--cov=src/rights_filter', '--cov-report=term-missing')
}
& python -m pytest @pytestArgs
if ($LASTEXITCODE -ne 0) { $failures += 'pytest' }
Write-Host ''
if ($failures.Count -gt 0) {
Write-Host "VERIFY FAILED: $($failures -join ', ')" -ForegroundColor Red
exit 1
}
Write-Host 'VERIFY OK' -ForegroundColor Green
exit 0

View file

@ -1,282 +0,0 @@
// Part 14 of 15 of the operator console app script.
//
// Knowledge/correction/provider/evidence-status admin actions.
//
// Load order is significant: these are classic (non-module) scripts sharing one
// global scope, and index.html must keep them in the order listed above/below.
// Splitting is a pure line-range extraction of the former single app.js.
function addKnowledgeEntryFromCase() {
// The control is labelled "주의 후보 보기" (view watchlist candidates), so show
// the registered list filtered to watchlist — it previously opened the blank
// manual-registration form, which is a different action entirely.
switchView("knowledge");
switchKnowledgeTab("registered");
knowledgeFilters.status = "watchlist";
const statusFilter = document.getElementById("knowledge-status-filter");
if (statusFilter) statusFilter.value = "watchlist";
renderAll();
}
async function deactivateCorrectionEntry(correctionId) {
const correction = corrections.find((item) => item.id === correctionId);
if (!correction) return;
// Deactivate the derived knowledge entries through the real backend endpoint
// so the change is persisted and audited server-side. The previous version
// only mutated local state and appended a fake audit event, so the action was
// silently discarded on the next refreshFromApi().
// Only entries that are still active need deactivating. Skipping already-
// inactive ones avoids the server's "이미 비활성 상태입니다" 400, which used to
// abort the whole loop on the first inactive entry.
const entryIds = (correction.derivedEntries || [])
.filter((entry) => entry && entry.id && entry.active)
.map((entry) => entry.id);
if (!entryIds.length) {
showApiError("이 보정 항목에는 비활성화할 활성 파생 지식 항목이 없습니다.");
return;
}
const failures = [];
try {
for (const entryId of entryIds) {
try {
await apiJson(`/api/knowledge/${encodeURIComponent(entryId)}/deactivate`, {
method: "POST",
body: JSON.stringify({ reason: "보정 파생 항목 일괄 비활성화" }),
});
} catch (_perEntryError) {
// One entry failing must not strand the rest.
failures.push(entryId);
}
}
} finally {
// Always refresh so the UI reflects whatever was actually persisted, even on
// a partial failure. Guard it so a refresh error isn't an unhandled rejection.
try {
await refreshFromApi();
} catch (_refreshError) {
/* keep the per-entry failure summary below */
}
}
if (failures.length) {
showApiError(`일부 파생 항목 비활성화에 실패했습니다 (${failures.length}건).`);
}
}
async function toggleProvider(providerId) {
const provider = providers.find((item) => item.id === providerId);
if (!provider) return;
try {
await apiJson(`/api/providers/${encodeURIComponent(providerId)}`, {
method: "PATCH",
body: JSON.stringify({ enabled: !provider.enabled }),
});
await refreshFromApi();
} catch (errorValue) {
showApiError(errorValue.message);
}
}
async function setEvidenceStatus(evidenceId, status, note = "") {
const submission = getSelectedCase();
if (!submission || !evidenceId) return;
try {
await apiJson(`/api/evidence/${encodeURIComponent(evidenceId)}/status`, {
method: "POST",
body: JSON.stringify({ submission_id: submission.id, status: evidenceStatusPayload(status), note }),
});
await refreshFromApi();
} catch (errorValue) {
showApiError(errorValue.message);
}
}
function markEvidenceIrrelevant(evidenceId) {
const submission = getSelectedCase();
if (!submission) return;
const fallback = submission.evidence.find((item) => item.contributed);
const targetId = evidenceId || fallback?.id;
void setEvidenceStatus(targetId, "ignored", "운영상 미사용 처리");
}
async function saveKnowledgeEntryEdit(form) {
const entryId = form.dataset.knowledgeEditForm;
const body = {
aliases: form.elements.aliases.value.split(",").map((item) => item.trim()).filter(Boolean),
keywords: form.elements.keywords.value.split(",").map((item) => item.trim()).filter(Boolean),
memo: form.elements.memo.value.trim(),
};
try {
const payload = await apiJson(`/api/knowledge/${encodeURIComponent(entryId)}`, {
method: "PATCH",
body: JSON.stringify(body),
});
editingKnowledgeEntryId = "";
applyBootstrap(payload);
renderAll();
} catch (errorValue) {
showApiError(errorValue.message);
}
}
async function deactivateKnowledgeEntry(entryId) {
const reason = window.prompt("비활성 사유 메모(선택)");
if (reason === null) return;
try {
const payload = await apiJson(`/api/knowledge/${encodeURIComponent(entryId)}/deactivate`, {
method: "POST",
body: JSON.stringify({ reason: reason.trim() }),
});
applyBootstrap(payload);
renderAll();
} catch (errorValue) {
showApiError(errorValue.message);
}
}
async function reactivateKnowledgeEntry(entryId) {
const reason = window.prompt("재활성 사유 메모(필수)") || "";
if (!reason.trim()) {
showApiError("재활성에는 사유 메모가 필요합니다.");
return;
}
try {
const payload = await apiJson(`/api/knowledge/${encodeURIComponent(entryId)}/reactivate`, {
method: "POST",
body: JSON.stringify({ reason: reason.trim() }),
});
applyBootstrap(payload);
renderAll();
} catch (errorValue) {
showApiError(errorValue.message);
}
}
async function promoteWatchlistEntry(entryId) {
try {
const payload = await apiJson(`/api/knowledge/${encodeURIComponent(entryId)}/promote-watchlist`, {
method: "POST",
body: JSON.stringify({}),
});
applyBootstrap(payload);
renderAll();
} catch (errorValue) {
showApiError(errorValue.message);
}
}
async function excludeWatchlistEntry(entryId) {
try {
const payload = await apiJson(`/api/knowledge/${encodeURIComponent(entryId)}/exclude-watchlist`, {
method: "POST",
body: JSON.stringify({ reason: "오탐 확인" }),
});
applyBootstrap(payload);
renderAll();
} catch (errorValue) {
showApiError(errorValue.message);
}
}
async function emergencyDisableExternalProviders() {
try {
await apiJson("/api/providers/emergency-disable", {
method: "POST",
body: JSON.stringify({}),
});
await refreshFromApi();
} catch (errorValue) {
showApiError(errorValue.message);
}
}

View file

@ -1,264 +0,0 @@
// Part 2 of 15 of the operator console app script.
//
// Auth token prompt, fetch wrapper, and bootstrap/refresh of client state.
//
// Load order is significant: these are classic (non-module) scripts sharing one
// global scope, and index.html must keep them in the order listed above/below.
// Splitting is a pure line-range extraction of the former single app.js.
function promptForAuthToken(message = "") {
const gate = document.getElementById("auth-gate");
const form = document.getElementById("auth-gate-form");
const input = document.getElementById("auth-gate-input");
const error = document.getElementById("auth-gate-error");
if (!gate || !form || !input) {
return Promise.resolve("");
}
// Collapse concurrent 401s onto one prompt so a burst of parallel requests
// cannot stack multiple dialogs.
if (pendingAuthPrompt) {
return pendingAuthPrompt;
}
if (error) {
error.hidden = !message;
error.textContent = message || "";
}
gate.hidden = false;
input.value = "";
input.focus();
pendingAuthPrompt = new Promise((resolve) => {
const onSubmit = (event) => {
event.preventDefault();
const value = input.value.trim();
if (!value) {
return;
}
form.removeEventListener("submit", onSubmit);
gate.hidden = true;
input.value = "";
pendingAuthPrompt = null;
resolve(value);
};
form.addEventListener("submit", onSubmit);
});
return pendingAuthPrompt;
}
function authHeaders(extra) {
const headers = { "Content-Type": "application/json", ...(extra || {}) };
if (authToken) {
headers.Authorization = `Bearer ${authToken}`;
}
return headers;
}
async function apiJson(path, options = {}, allowAuthRetry = true) {
const response = await fetch(path, {
...options,
headers: authHeaders(options.headers),
});
const text = await response.text();
let payload = null;
if (text) {
try {
payload = JSON.parse(text);
} catch (error) {
payload = null;
}
}
if (response.status === 401 && allowAuthRetry) {
// Ask for the token, then replay this request once. A second 401 falls
// through to the normal error path instead of looping.
const token = await promptForAuthToken(
authToken ? "토큰이 올바르지 않습니다. 다시 입력하세요." : "",
);
if (token) {
authToken = token;
return apiJson(path, options, false);
}
}
if (!response.ok) {
const message = (payload && payload.error) || `API 요청 실패: ${response.status}`;
throw new Error(message);
}
return payload;
}
function replaceCollection(target, next) {
target.splice(0, target.length, ...(next || []));
}
function clearRuntimeData() {
replaceCollection(submissions, []);
replaceCollection(providers, []);
replaceCollection(knowledgeEntries, []);
replaceCollection(collectionCandidates, []);
replaceCollection(corrections, []);
replaceCollection(auditEvents, []);
Object.assign(coverageThresholds, DEFAULT_COVERAGE_THRESHOLDS);
Object.keys(searchCoverage).forEach((key) => delete searchCoverage[key]);
state.submissionQueue = null;
state.selectedCaseId = "";
}
function applyBootstrap(payload) {
// Any successful payload proves the API is reachable, so clear a stale error
// banner here (not only in refreshFromApi) — actions that applyBootstrap
// directly, e.g. promote/dismiss, otherwise leave the old error visible.
state.apiError = "";
replaceCollection(submissions, payload.submissions);
replaceCollection(providers, payload.providers);
replaceCollection(knowledgeEntries, payload.knowledgeEntries);
state.submissionQueue = payload.submissionQueue || null;
Object.assign(
coverageThresholds,
normalizeCoverageThresholds(payload.coverageThresholds || (payload.searchCoverage && payload.searchCoverage.coverageThresholds)),
);
Object.keys(searchCoverage).forEach((key) => delete searchCoverage[key]);
Object.assign(searchCoverage, payload.searchCoverage || {});
replaceCollection(collectionCandidates, payload.collectionCandidates);
replaceCollection(corrections, payload.corrections);
replaceCollection(auditEvents, payload.auditEvents);
replaceCollection(decisionMemoPresets, payload.decisionMemoPresets || []);
if (!submissions.some((item) => item.id === state.selectedCaseId)) {
state.selectedCaseId = submissions[0]?.id || "";
}
}
async function refreshFromApi() {
const payload = await apiJson("/api/bootstrap");
state.apiError = "";
applyBootstrap(payload);
renderAll();
}
function setImportStatus(message, tone = "idle") {
state.importStatus = { message, tone };
renderImportStatus();
}

View file

@ -1,558 +0,0 @@
// Part 15 of 15 of the operator console app script.
//
// Event binding, init, and the DOM listeners that start the app.
//
// Load order is significant: these are classic (non-module) scripts sharing one
// global scope, and index.html must keep them in the order listed above/below.
// Splitting is a pure line-range extraction of the former single app.js.
function bindEvents() {
document.querySelectorAll(".nav-button").forEach((button) => {
button.addEventListener("click", () => switchView(button.dataset.view));
});
document.querySelectorAll("#risk-filter button").forEach((button) => {
button.addEventListener("click", () => {
state.filters.risk = button.dataset.risk;
document.querySelectorAll("#risk-filter button").forEach((item) => item.classList.toggle("active", item === button));
renderQueue();
});
});
document.getElementById("source-filter").addEventListener("change", (event) => {
state.filters.source = event.target.value;
renderCoverageTabs();
renderQueue();
});
document.getElementById("decision-filter").addEventListener("change", (event) => {
state.filters.decision = event.target.value;
renderQueue();
});
document.getElementById("queue-sort").addEventListener("change", (event) => {
state.filters.sort = event.target.value;
renderQueue();
});
document.getElementById("queue-search").addEventListener("input", (event) => {
state.filters.query = event.target.value;
renderQueue();
});
document.getElementById("queue-body").addEventListener("click", (event) => {
const duplicateButton = event.target.closest("[data-duplicate-group]");
if (duplicateButton) {
selectDuplicateGroup(duplicateButton.dataset.duplicateGroup);
return;
}
const selectButton = event.target.closest("[data-select-case]");
if (selectButton) {
selectCase(selectButton.dataset.selectCase);
return;
}
const row = event.target.closest("[data-row-case]");
if (row && !event.target.matches("input")) {
selectCase(row.dataset.rowCase);
}
});
// Keep the batch-decision counter in step with the row checkboxes.
document.getElementById("queue-body").addEventListener("change", (event) => {
if (event.target.matches("input[data-bulk-id]")) {
renderBulkDecisionCount();
}
});
document.getElementById("coverage-tabs").addEventListener("click", (event) => {
const tab = event.target.closest("[data-coverage-filter]");
if (!tab) return;
applyCoverageFilter(tab.dataset.coverageFilter);
});
document.querySelectorAll("[data-workbench-tab]").forEach((button) => {
button.addEventListener("click", () => switchWorkbenchTab(button.dataset.workbenchTab));
});
document.querySelectorAll("[data-knowledge-tab]").forEach((button) => {
button.addEventListener("click", () => switchKnowledgeTab(button.dataset.knowledgeTab));
});
document.querySelectorAll("[data-decision]").forEach((button) => {
button.addEventListener("click", () => {
const requiresMemo = button.dataset.requiresMemo === "true";
setDecision(button.dataset.decision, requiresMemo);
});
});
document.getElementById("manual-query-form").addEventListener("submit", runManualSearch);
document.getElementById("candidate-collection-form").addEventListener("submit", runCandidateCollection);
document.getElementById("collection-promotion-form").addEventListener("submit", promoteSelectedCollectionCandidates);
document.getElementById("select-all-candidates").addEventListener("click", () => setAllCollectionCandidateSelection(true));
document.getElementById("clear-selected-candidates").addEventListener("click", () => setAllCollectionCandidateSelection(false));
document.getElementById("knowledge-form").addEventListener("submit", addKnowledgeEntry);
document.getElementById("knowledge-search").addEventListener("input", (event) => {
knowledgeFilters.query = event.target.value;
renderKnowledgeBase();
});
[
["knowledge-type-filter", "type"],
["knowledge-status-filter", "status"],
["knowledge-active-filter", "active"],
].forEach(([elementId, key]) => {
document.getElementById(elementId).addEventListener("change", (event) => {
knowledgeFilters[key] = event.target.value;
renderKnowledgeBase();
});
});
document.addEventListener("submit", (event) => {
const editFormElement = event.target.closest("[data-knowledge-edit-form]");
if (!editFormElement) return;
event.preventDefault();
void saveKnowledgeEntryEdit(editFormElement);
});
document.getElementById("knowledge-image").addEventListener("change", updateKnowledgeImageName);
document.getElementById("reload-submissions").addEventListener("click", reloadSubmissions);
document.getElementById("submission-image").addEventListener("change", updateSubmissionImageName);
document.getElementById("upload-submission-image").addEventListener("click", uploadSubmissionImage);
document.getElementById("rerun-enrichment").addEventListener("click", rerunEnrichment);
document.getElementById("add-from-case").addEventListener("click", addKnowledgeEntryFromCase);
document.getElementById("mark-irrelevant").addEventListener("click", () => markEvidenceIrrelevant());
document.getElementById("disable-derived").addEventListener("click", () => {
// Scope the deactivation to the currently-selected case. Picking the first
// automatic entry globally could deactivate an entry that belongs to a
// different submission entirely.
const selected = getSelectedCase();
const automaticEntry = selected
? knowledgeEntries.find(
(entry) =>
entry.provenance === "automatic" &&
entry.active &&
(entry.entryStatus || "confirmed") === "confirmed" &&
entry.sourceSubmissionId === selected.id,
)
: undefined;
if (automaticEntry) {
void deactivateKnowledgeEntry(automaticEntry.id);
} else {
showApiError("이 케이스에서 비활성화할 자동 지식 항목이 없습니다.");
}
});
// Image viewer controls. Without handlers these were dead buttons; they now
// adjust the case image (fit / actual size / rotate) with state on the element
// so renderCaseReview can reset them per case.
document.getElementById("fit-image").addEventListener("click", () => {
const image = document.getElementById("case-image");
if (image) image.style.objectFit = "contain";
});
document.getElementById("actual-image").addEventListener("click", () => {
const image = document.getElementById("case-image");
if (image) image.style.objectFit = "none";
});
document.getElementById("rotate-image").addEventListener("click", () => {
const image = document.getElementById("case-image");
if (!image) return;
const next = ((Number(image.dataset.rotation || "0") + 90) % 360 + 360) % 360;
image.dataset.rotation = String(next);
image.style.transform = `rotate(${next}deg)`;
});
document.getElementById("open-correction").addEventListener("click", () => {
switchView("knowledge");
switchKnowledgeTab("corrections");
});
document.getElementById("bulk-rerun").addEventListener("click", rerunSelectedEnrichment);
document.querySelectorAll("[data-bulk-decision]").forEach((button) => {
button.addEventListener("click", () => applyBulkDecision(button.dataset.bulkDecision));
});
document.getElementById("export-decisions").addEventListener("click", exportDecisions);
document.getElementById("create-backup").addEventListener("click", createBackupSnapshot);
document.getElementById("clear-reference").addEventListener("click", registerClearedReference);
document.getElementById("llm-draft").addEventListener("click", (event) => {
if (event.target.closest("#llm-draft-use")) pullLlmDraftIntoMemo();
});
document.getElementById("memo-presets").addEventListener("click", (event) => {
if (event.target.closest("#memo-preset-save")) {
void saveCurrentMemoAsPreset();
return;
}
const chip = event.target.closest("[data-preset-id]");
if (chip) applyMemoPreset(chip.dataset.presetId);
});
document.addEventListener("keydown", handleReviewShortcut);
document.getElementById("emergency-disable").addEventListener("click", emergencyDisableExternalProviders);
document.getElementById("app-main").addEventListener("click", (event) => {
const providerButton = event.target.closest("[data-toggle-provider]");
if (providerButton) {
toggleProvider(providerButton.dataset.toggleProvider);
return;
}
const retryButton = event.target.closest("[data-provider-retry]");
if (retryButton) {
// No provider-wide retry endpoint exists. Tell the operator honestly
// instead of writing a fake "Provider called" audit event that never
// re-ran anything (which falsely implied the failures were retried).
showApiError("제공자 단위 '실패 재시도'는 아직 서버에 구현되지 않았습니다. 개별 케이스의 '재분석'을 사용하세요.");
return;
}
const correctionButton = event.target.closest("[data-deactivate-correction]");
if (correctionButton) {
void deactivateCorrectionEntry(correctionButton.dataset.deactivateCorrection);
return;
}
const editKnowledgeButton = event.target.closest("[data-edit-kb]");
if (editKnowledgeButton) {
editingKnowledgeEntryId = editingKnowledgeEntryId === editKnowledgeButton.dataset.editKb ? "" : editKnowledgeButton.dataset.editKb;
renderKnowledgeBase();
return;
}
const cancelEditKnowledgeButton = event.target.closest("[data-cancel-edit-kb]");
if (cancelEditKnowledgeButton) {
editingKnowledgeEntryId = "";
renderKnowledgeBase();
return;
}
const deactivateKnowledgeButton = event.target.closest("[data-deactivate-kb]");
if (deactivateKnowledgeButton) {
void deactivateKnowledgeEntry(deactivateKnowledgeButton.dataset.deactivateKb);
return;
}
const reactivateKnowledgeButton = event.target.closest("[data-reactivate-kb]");
if (reactivateKnowledgeButton) {
void reactivateKnowledgeEntry(reactivateKnowledgeButton.dataset.reactivateKb);
return;
}
const evidenceStatusButton = event.target.closest("[data-evidence-status]");
if (evidenceStatusButton) {
setEvidenceStatus(evidenceStatusButton.dataset.evidenceId, evidenceStatusButton.dataset.evidenceStatus);
return;
}
const suggestedQueryButton = event.target.closest("[data-suggested-query]");
if (suggestedQueryButton) {
applySuggestedQuery(suggestedQueryButton);
return;
}
const promoteWatchlistButton = event.target.closest("[data-promote-watchlist]");
if (promoteWatchlistButton) {
promoteWatchlistEntry(promoteWatchlistButton.dataset.promoteWatchlist);
return;
}
const excludeWatchlistButton = event.target.closest("[data-exclude-watchlist]");
if (excludeWatchlistButton) {
excludeWatchlistEntry(excludeWatchlistButton.dataset.excludeWatchlist);
return;
}
const promoteCandidateButton = event.target.closest("[data-promote-candidate]");
if (promoteCandidateButton) {
promoteCollectionCandidate(promoteCandidateButton.dataset.promoteCandidate);
return;
}
const dismissCandidateButton = event.target.closest("[data-dismiss-candidate]");
if (dismissCandidateButton) {
void dismissCollectionCandidate(dismissCandidateButton.dataset.dismissCandidate);
return;
}
const runSuggestedButton = event.target.closest("[data-run-suggested-query]");
if (runSuggestedButton) {
void executeSuggestedQueries([runSuggestedButton.dataset.runSuggestedQuery]);
return;
}
const runAllSuggestedButton = event.target.closest("#run-all-suggested-queries");
if (runAllSuggestedButton) {
void executeSuggestedQueries(suggestedEvidenceQueries(getSelectedCase()));
return;
}
const rerunQueryButton = event.target.closest("[data-rerun-query]");
if (rerunQueryButton) {
rerunHistoricalQuery(rerunQueryButton);
}
});
}
async function init() {
const hashView = window.location.hash.replace("#", "");
if (viewNames.has(hashView)) {
state.currentView = hashView;
} else if (hashView === "case" || hashView === "evidence") {
state.currentView = "workbench";
} else if (hashView === "corrections") {
state.currentView = "knowledge";
state.knowledgeTab = "corrections";
}
bindEvents();
try {
await refreshFromApi();
} catch (errorValue) {
clearRuntimeData();
showApiError(`API 연결 실패: ${errorValue.message}`);
renderAll();
}
switchView(state.currentView, false);
switchWorkbenchTab(state.workbenchTab);
switchKnowledgeTab(state.knowledgeTab);
}
document.addEventListener("DOMContentLoaded", () => {
void init();
});
const __uiCopyCompatibility = {
immediateMove: "바로 편입",
reasonPresent: "근거 있음",
noResult: "결과 없음",
notRun: "미실행",
evidenceUsed: "판단에 사용",
falsePositive: "오탐",
externalSearchTool: "외부 검색 tool",
similarImage: "이미지 유사도",
showMore: "자세히 보기",
faceWebReason: "얼굴 영역 웹 근거",
searchImageResult: "검색 이미지 결과",
samePerson: "동일인 판정이 아닙니다",
watchlistNote: "주의 후보 근거",
evidenceUsedStatus: "사용",
evidenceIgnoredStatus: "미사용",
usedForJudgmentTuple: ["used_for_judgment", "사용"],
ignoredTuple: ["ignored", "미사용"],
pageRepresentativeImage: "페이지 대표 이미지",
confidenceLabel: "신뢰도",
queryStrategyByTitle: "구글 페이지 제목 기반",
evidenceId: "근거 ID",
queryStrategyBySubmission: "제출 제목/파일명 기반",
sampleFingerprints: "샘플 지문",
};
window.addEventListener("hashchange", () => switchView(window.location.hash.replace("#", ""), false));

View file

@ -1,323 +0,0 @@
// Part 13 of 15 of the operator console app script.
//
// Collection candidate search, dismissal, selection, and promotion.
//
// Load order is significant: these are classic (non-module) scripts sharing one
// global scope, and index.html must keep them in the order listed above/below.
// Splitting is a pure line-range extraction of the former single app.js.
function clearCollectionCandidatesForSearch() {
collectionCandidates.splice(0, collectionCandidates.length);
renderCollectionCandidates();
}
function clearCollectionCandidatesAfterAction() {
collectionCandidates.splice(0, collectionCandidates.length);
state.currentCollectionQuery = "";
state.currentCollectionProvider = "";
renderCollectionCandidates();
}
async function dismissCollectionCandidate(candidateId) {
const status = document.getElementById("collection-status");
if (status) status.textContent = "후보를 무시하는 중입니다.";
try {
const payload = await apiJson(`/api/collections/candidates/${encodeURIComponent(candidateId)}/dismiss`, {
method: "POST",
body: JSON.stringify({}),
});
applyBootstrap(payload);
renderAll();
if (status) status.textContent = "후보를 무시했습니다.";
} catch (errorValue) {
if (status) status.textContent = errorValue.message;
}
}
async function runCandidateCollection(event) {
event.preventDefault();
const provider = document.getElementById("collection-provider").value;
const queryInput = document.getElementById("collection-query");
const status = document.getElementById("collection-status");
const submitButton = event.target.querySelector('button[type="submit"]');
const query = queryInput.value.trim();
if (!query) {
state.currentCollectionQuery = "";
state.currentCollectionProvider = "";
renderCollectionCandidates();
status.textContent = "수집할 키워드를 입력하세요.";
return;
}
const providerConfig = providers.find((item) => item.id === provider);
if (!providerConfig || !providerConfig.enabled) {
status.textContent = `${providerLabels[provider] || provider} 외부 검색 tool이 비활성입니다.`;
return;
}
submitButton.disabled = true;
status.textContent = "키워드 후보를 수집하는 중입니다.";
try {
const payload = await apiJson("/api/collections/keyword", {
method: "POST",
body: JSON.stringify({ provider, query }),
});
// Commit the new query/provider and swap in the new candidates only after the
// request succeeds, so a failed collection doesn't wipe the previous view.
state.currentCollectionQuery = query;
state.currentCollectionProvider = provider;
applyBootstrap(payload);
renderAll();
status.textContent = `${payload.collected || 0}개후보를 수집했습니다.`;
} catch (errorValue) {
status.textContent = errorValue.message;
} finally {
submitButton.disabled = false;
}
}
function selectableCollectionCandidateInputs() {
return Array.from(document.querySelectorAll("input[data-collection-candidate-id]:not(:disabled)"));
}
function selectedCollectionCandidateIds() {
return selectableCollectionCandidateInputs()
.filter((input) => input.checked)
.map((input) => input.dataset.collectionCandidateId);
}
function setAllCollectionCandidateSelection(selected) {
const inputs = selectableCollectionCandidateInputs();
const status = document.getElementById("collection-status");
inputs.forEach((input) => {
input.checked = selected;
});
if (!inputs.length) {
status.textContent = "선택된 후보가 없습니다.";
return;
}
status.textContent = selected ? `${inputs.length}개 후보를 선택했습니다.` : "후보 선택을 모두 해제했습니다.";
}
function commaSeparatedValues(elementId) {
return document
.getElementById(elementId)
.value.split(",")
.map((item) => item.trim())
.filter(Boolean);
}
async function promoteSelectedCollectionCandidates(event) {
event.preventDefault();
const candidateIds = selectedCollectionCandidateIds();
const status = document.getElementById("collection-status");
const submitButton = document.getElementById("promote-selected-candidates");
if (!candidateIds.length) {
status.textContent = "반영할 후보를 선택하세요.";
return;
}
submitButton.disabled = true;
status.textContent = "선택한 후보를 기준 DB에 반영하는 중입니다.";
try {
const payload = await apiJson("/api/collections/candidates/promote-batch", {
method: "POST",
body: JSON.stringify({
candidate_ids: candidateIds,
name: document.getElementById("collection-promotion-name").value.trim(),
type: document.getElementById("collection-promotion-type").value,
aliases: commaSeparatedValues("collection-promotion-aliases"),
keywords: commaSeparatedValues("collection-promotion-keywords"),
memo: document.getElementById("collection-promotion-memo").value.trim(),
}),
});
applyBootstrap(payload);
clearCollectionCandidatesAfterAction();
event.target.reset();
renderAll();
status.textContent = `${candidateIds.length}개후보를 기준 DB에 반영했습니다.`;
} catch (errorValue) {
status.textContent = errorValue.message;
} finally {
submitButton.disabled = false;
}
}
async function promoteCollectionCandidate(candidateId) {
const status = document.getElementById("collection-status");
status.textContent = "후보를 기준 DB에 반영하는 중입니다.";
try {
const payload = await apiJson(`/api/collections/candidates/${encodeURIComponent(candidateId)}/promote`, {
method: "POST",
body: JSON.stringify({}),
});
applyBootstrap(payload);
clearCollectionCandidatesAfterAction();
renderAll();
status.textContent = "후보를 기준 DB에 반영했습니다.";
} catch (errorValue) {
status.textContent = errorValue.message;
}
}

View file

@ -1,361 +0,0 @@
// Part 10 of 15 of the operator console app script.
//
// Memo presets, decision export, backup, cleared reference, LLM draft.
//
// Load order is significant: these are classic (non-module) scripts sharing one
// global scope, and index.html must keep them in the order listed above/below.
// Splitting is a pure line-range extraction of the former single app.js.
// --- Decision memo presets ---------------------------------------------
function renderMemoPresets() {
const target = document.getElementById("memo-presets");
if (!target) return;
const chips = decisionMemoPresets
.map(
(preset) =>
`<button type="button" class="memo-preset-chip" data-preset-id="${escapeHtml(preset.id)}" title="${escapeHtml(preset.body)}">${escapeHtml(preset.label)}</button>`,
)
.join("");
target.innerHTML = `${chips}<button type="button" class="memo-preset-chip memo-preset-add" id="memo-preset-save">+ 현재 메모 저장</button>`;
}
function applyMemoPreset(presetId) {
const preset = decisionMemoPresets.find((item) => item.id === presetId);
const memo = document.getElementById("decision-memo");
if (!preset || !memo) return;
memo.value = memo.value.trim() ? `${memo.value.trim()} ${preset.body}` : preset.body;
memo.focus();
}
async function saveCurrentMemoAsPreset() {
const memo = document.getElementById("decision-memo");
const error = document.getElementById("memo-error");
const body = memo?.value.trim() || "";
if (!body) {
if (error) error.textContent = "저장할 메모 내용이 없습니다.";
return;
}
const label = window.prompt("프리셋 이름 (예: 연예인 초상)", body.slice(0, 20));
if (!label) return;
try {
const presets = await apiJson("/api/decision-memo-presets", {
method: "POST",
body: JSON.stringify({ label, body }),
});
replaceCollection(decisionMemoPresets, presets);
renderMemoPresets();
if (error) error.textContent = "";
} catch (errorValue) {
if (error) error.textContent = `프리셋 저장 실패: ${errorValue.message}`;
}
}
// --- Decision export ----------------------------------------------------
async function exportDecisions() {
const status = document.getElementById("bulk-decision-status");
const audience = window.confirm(
"내부 보관용(증거·점수 포함)으로 내보내려면 확인, 신청 채널 전달용(판정·메모만)으로 내보내려면 취소를 누르세요.",
)
? "internal"
: "applicant";
try {
const payload = await apiJson(
`/api/decisions/export?audience=${encodeURIComponent(audience)}`,
);
if (!payload.rows.length) {
if (status) status.textContent = "내보낼 판정 완료 케이스가 없습니다.";
return;
}
downloadTextFile(
`copyrighter-decisions-${audience}.csv`,
payload.csv,
"text/csv;charset=utf-8",
);
if (status) status.textContent = `${payload.rows.length}건을 ${audience === "internal" ? "내부 보관용" : "신청 채널 전달용"} CSV로 내보냈습니다.`;
} catch (errorValue) {
if (status) status.textContent = `내보내기 실패: ${errorValue.message}`;
}
}
function downloadTextFile(filename, text, mimeType) {
// BOM so Excel opens the Korean CSV as UTF-8 instead of the system codepage.
const blob = new Blob([`${text}`], { type: mimeType });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
// --- Backup -------------------------------------------------------------
async function createBackupSnapshot() {
const status = document.getElementById("bulk-decision-status");
const button = document.getElementById("create-backup");
if (button) button.disabled = true;
if (status) status.textContent = "백업 스냅샷을 만드는 중…";
try {
const result = await apiJson("/api/admin/backup", { method: "POST" });
const megabytes = (Number(result.sizeBytes || 0) / (1024 * 1024)).toFixed(1);
if (status) {
status.textContent = `백업 완료: ${result.file} (${megabytes}MB · 미디어 ${result.mediaFiles}개)`;
}
await refreshFromApi();
} catch (errorValue) {
if (status) status.textContent = `백업 실패: ${errorValue.message}`;
} finally {
if (button) button.disabled = false;
}
}
// --- Cleared reference (positive knowledge) -----------------------------
async function registerClearedReference() {
const submission = getSelectedCase();
if (!submission) return;
const label = window.prompt(
"클리어 기준 이름 (예: 자사 보유 스톡 · 라이선스 확인됨)",
submission.title || submission.id,
);
if (label === null) return;
const note = window.prompt("메모(선택) — 왜 클리어인지", "") || "";
try {
const payload = await apiJson(
`/api/submissions/${encodeURIComponent(submission.id)}/clear-reference`,
{ method: "POST", body: JSON.stringify({ label: label.trim(), note }) },
);
applyBootstrap(payload);
renderAll();
} catch (errorValue) {
showApiError(errorValue.message);
}
}
// --- LLM draft (persist + show + pull into memo) ------------------------
function llmSummaryFor(submission) {
const evidence = submission && Array.isArray(submission.evidence) ? submission.evidence : [];
// The freshest LLM summary that actually carries body text.
return (
evidence
.filter((item) => item.source === "llm" && String(item.summary || "").trim())
.slice(-1)[0] || null
);
}
let currentLlmDraftText = "";
function renderLlmDraft(submission) {
const box = document.getElementById("llm-draft");
if (!box) return;
const summary = llmSummaryFor(submission);
if (!summary) {
box.hidden = true;
box.innerHTML = "";
currentLlmDraftText = "";
return;
}
currentLlmDraftText = String(summary.summary || "");
const urls = Array.isArray(summary.sourceUrls) ? summary.sourceUrls : [];
const sourceNote = urls.length ? `연결 근거 ${urls.length}건` : "연결 근거 없음";
box.hidden = false;
box.innerHTML = `
<div class="llm-draft-head">
<span class="llm-draft-tag">내부 요약(초안)</span>
<span class="muted small">${escapeHtml(sourceNote)} · 최종 판정은 운영자</span>
</div>
<p class="llm-draft-body">${escapeHtml(currentLlmDraftText)}</p>
<button type="button" class="llm-draft-use" id="llm-draft-use">메모 초안으로 넣기</button>
`;
}
function pullLlmDraftIntoMemo() {
const memo = document.getElementById("decision-memo");
if (!memo || !currentLlmDraftText) return;
memo.value = memo.value.trim()
? `${memo.value.trim()}\n${currentLlmDraftText}`
: currentLlmDraftText;
memo.focus();
}

View file

@ -1,591 +0,0 @@
// Part 3 of 15 of the operator console app script.
//
// Coverage math, HTML/URL escaping, and every label-formatting helper.
//
// Load order is significant: these are classic (non-module) scripts sharing one
// global scope, and index.html must keep them in the order listed above/below.
// Splitting is a pure line-range extraction of the former single app.js.
function coveragePercent(numerator, denominator) {
if (!denominator) return 0;
return Math.round((numerator / denominator) * 1000) / 10;
}
function normalizeCoverageThresholds(next = {}) {
const raw = next || {};
const normalized = {};
const intValue = (value) => {
const parsed = Number(value);
if (!Number.isFinite(parsed)) return null;
return Math.max(0, Math.min(100, Math.round(parsed)));
};
normalized.coverageGoodRate = intValue(raw.coverageGoodRate);
if (!Number.isFinite(normalized.coverageGoodRate)) {
normalized.coverageGoodRate = DEFAULT_COVERAGE_THRESHOLDS.coverageGoodRate;
}
normalized.coverageWarnRate = intValue(raw.coverageWarnRate);
if (!Number.isFinite(normalized.coverageWarnRate)) {
normalized.coverageWarnRate = DEFAULT_COVERAGE_THRESHOLDS.coverageWarnRate;
}
normalized.coverageWarnRate = Math.min(normalized.coverageWarnRate, normalized.coverageGoodRate);
normalized.queryGoodRate = intValue(raw.queryGoodRate);
if (!Number.isFinite(normalized.queryGoodRate)) {
normalized.queryGoodRate = DEFAULT_COVERAGE_THRESHOLDS.queryGoodRate;
}
normalized.queryWarnRate = intValue(raw.queryWarnRate);
if (!Number.isFinite(normalized.queryWarnRate)) {
normalized.queryWarnRate = DEFAULT_COVERAGE_THRESHOLDS.queryWarnRate;
}
normalized.queryWarnRate = Math.min(normalized.queryWarnRate, normalized.queryGoodRate);
return normalized;
}
function coverageTone(rate, kind = "coverage") {
const config = coverageThresholds;
const goodRate = config[`${kind}GoodRate`];
const warnRate = config[`${kind}WarnRate`];
if (!Number.isFinite(goodRate) || !Number.isFinite(warnRate)) return "bad";
if (rate >= goodRate) return "ok";
if (rate >= warnRate) return "warn";
return "bad";
}
function coverageToneLabelConfig(kind = "coverage") {
const config = coverageThresholds;
const goodRate = Number.isFinite(config[`${kind}GoodRate`])
? config[`${kind}GoodRate`]
: DEFAULT_COVERAGE_THRESHOLDS[`${kind}GoodRate`];
const warnRate = Number.isFinite(config[`${kind}WarnRate`])
? config[`${kind}WarnRate`]
: DEFAULT_COVERAGE_THRESHOLDS[`${kind}WarnRate`];
return `양호: ${goodRate}% 이상, 주의: ${warnRate}%~${Math.max(0, goodRate - 1)}%, 위험: ${Math.max(0, warnRate - 1)}% 미만`;
}
function clampPercent(value) {
if (!Number.isFinite(value)) return 0;
return Math.max(0, Math.min(100, value));
}
function coverageTabButton({ filter, label, value, tone = "ok", title = "" }) {
const active = state.filters.source === filter || (filter === "all" && state.filters.source === "all");
return `
<button
class="coverage-tab ${tone} ${active ? "active" : ""}"
type="button"
data-coverage-filter="${escapeHtml(filter)}"
title="${escapeHtml(title || label)}"
aria-pressed="${active ? "true" : "false"}"
>
<span>${escapeHtml(label)}</span>
<strong>${escapeHtml(value)}</strong>
</button>
`;
}
function renderCoverageTabs() {
const target = document.getElementById("coverage-tabs");
if (!target) return;
const summaries = searchCoverage.submissions || {};
const queries = searchCoverage.queries || {};
const providers = (searchCoverage.providers || []).filter((provider) => !retiredProviderIds.has(provider.id));
const total = Number(summaries.total || submissions.length || 0);
const coverageRate = coveragePercent(summaries.coverageSubmissions || 0, total);
const coverageStatus = coverageTone(coverageRate, "coverage");
const failedQueries = Number(queries.failed || 0);
const providerTabs = [...providers]
.sort((left, right) => (right.querySubmissions || 0) - (left.querySubmissions || 0))
.map((provider) => {
const filter = provider.id && provider.id.includes("google") ? "google" : provider.id || "all";
const queryCount = Number(provider.queryEntries || 0);
const evidenceCount = Number(provider.evidenceSubmissions || 0);
return coverageTabButton({
filter,
label: provider.name || provider.id || "provider",
value: `${queryCount} / ${evidenceCount}`,
tone: evidenceCount ? "ok" : queryCount ? "warn" : "idle",
title: `쿼리 ${queryCount}, 근거 보유 케이스 ${evidenceCount}`,
});
})
.join("");
target.innerHTML = `
${coverageTabButton({ filter: "all", label: "전체", value: `${total}` })}
${coverageTabButton({
filter: "covered",
label: "근거 보유",
value: `${summaries.coverageSubmissions || 0}/${total}`,
tone: coverageStatus,
title: coverageToneLabelConfig("coverage"),
})}
${providerTabs || coverageTabButton({ filter: "all", label: "외부 검색 tool", value: "0" })}
${coverageTabButton({ filter: "failure", label: "실패", value: `${failedQueries}`, tone: failedQueries ? "bad" : "idle" })}
`;
}
function applyCoverageFilter(filter) {
state.filters.source = filter || "all";
const sourceSelect = document.getElementById("source-filter");
if (sourceSelect) {
sourceSelect.value = [...sourceSelect.options].some((option) => option.value === state.filters.source) ? state.filters.source : "all";
}
renderCoverageTabs();
renderQueue();
}
function showApiError(message) {
state.apiError = message;
const target = document.getElementById("queue-health");
if (target) target.textContent = message;
}
function escapeHtml(value) {
return String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
function safeUrl(value) {
// Defense-in-depth for URLs derived from external search results: only allow
// absolute http(s) or same-origin paths into href/src (blocks javascript:,
// data:, etc.). The server also normalizes these server-side.
const url = String(value || "").trim();
// Same-origin path, but reject protocol-relative ("//host") and backslash
// ("/\\host") forms that browsers normalize to an external host.
const isSameOriginPath = url.startsWith("/") && !url.startsWith("//") && !url.startsWith("/\\");
if (/^https?:\/\//i.test(url) || isSameOriginPath) {
return url;
}
return "#";
}
function formatReason(reason) {
const text = String(reason || "");
if (!text) return "";
if (text === "Naver search result found") return "네이버 검색 결과 발견";
if (text === "Naver search returned no results") return "네이버 검색 결과 없음";
if (text === "Naver blog search result found") return "네이버 블로그 검색 결과 발견";
if (text === "Naver blog search returned no results") return "네이버 블로그 검색 결과 없음";
if (text === "Naver web search result found") return "네이버 웹문서 검색 결과 발견";
if (text === "Naver web search returned no results") return "네이버 웹문서 검색 결과 없음";
if (text === "Google custom image search result found") return "구글 이미지 검색 결과 발견";
if (text === "Google custom image search returned no results") return "구글 이미지 검색 결과 없음";
if (text === "Google custom web search result found") return "구글 웹 검색 결과 발견";
if (text === "Google custom web search returned no results") return "구글 웹 검색 결과 없음";
if (text.startsWith("Google face crop web evidence: ")) {
return `얼굴 영역 웹 근거: ${formatReason(text.replace("Google face crop web evidence: ", ""))}`;
}
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 ", "")}`;
if (text === "LLM summary has no source evidence") return "내부 요약에 연결 근거 없음";
if (text === "LLM summary generated") return "내부 요약 생성";
if (text.startsWith("LLM assistance failed")) return text.replace("LLM assistance failed", "내부 요약 실패");
if (text.includes("Google Web Detection timeout")) return "구글 이미지 감지 시간 초과";
if (text === "local submission") return "로컬 제출 이미지";
if (text.startsWith("naver.example / rank ")) return text.replace("naver.example / rank ", "네이버 예시 / 순위 ");
if (text.startsWith("google.example / page")) return "구글 예시 / 페이지";
if (text.startsWith("prior rejection / ")) return text.replace("prior rejection / ", "이전 반려 / ");
return text
.replaceAll("Google Web Detection", "구글 이미지 감지")
.replaceAll("Google", "구글")
.replaceAll("Naver", "네이버")
.replaceAll("google", "구글")
.replaceAll("naver", "네이버")
.replaceAll("internal", "내부")
.replaceAll("local", "로컬")
.replaceAll("submission", "제출")
.replaceAll("page", "페이지")
.replaceAll("weak", "약함")
.replaceAll("LLM", "내부 요약")
.replaceAll("confidence", "신뢰도")
.replaceAll("sourceEvidenceIds", "근거 ID")
.replaceAll("rank", "순위");
}
function formatEvidenceTitle(evidence) {
return formatReason(evidence.title || evidence.reason || "");
}
function formatProviderStatus(status) {
return providerStatusLabels[status] || status;
}
function formatProviderName(provider) {
return providerLabels[provider.id] || provider.name || provider.id;
}
function formatQueueProviderStatus(provider, status) {
const providerNames = {
internal: "내부",
naver: "네이버",
naver_blog: "블로그",
naver_web: "웹문서",
google: "구글",
google_search: "구글",
llm: "요약",
};
const statusNames = {
ok: "정상",
covered: "근거 있음",
empty: "없음",
failed: "실패",
disabled: "중지",
skipped: "미실행",
pending: "미실행",
not_run: "미실행",
};
return `${providerNames[provider] || providerLabels[provider] || provider} ${statusNames[status] || formatProviderStatus(status)}`;
}
function visibleProviderStateEntries(providerState) {
return Object.entries(providerState || {}).filter(([provider]) => !retiredProviderIds.has(provider));
}
function renderOperatorSearchProviderOptions() {
const optionHtml = operatorSearchProviders
.map((provider) => {
const runtime = providers.find((item) => item.id === provider.id);
const unavailable = Boolean(runtime) && !runtime.enabled;
const label = unavailable ? `${provider.label} (비활성)` : provider.label;
const reason = unavailable ? String(runtime.lastFailure || runtime.compliance || "외부 검색 tool 미연결") : "";
return `<option value="${escapeHtml(provider.id)}" ${unavailable ? "disabled" : ""} title="${escapeHtml(reason)}">${escapeHtml(label)}</option>`;
})
.join("");
["manual-query-provider", "collection-provider"].forEach((elementId) => {
const select = document.getElementById(elementId);
if (!select) return;
const current = select.value;
select.innerHTML = optionHtml;
select.value = operatorSearchProviders.some((provider) => provider.id === current) ? current : operatorSearchProviders[0].id;
if (select.selectedOptions[0] && select.selectedOptions[0].disabled) {
select.value = operatorSearchProviders[0].id;
}
});
}
function formatCandidateStatus(status) {
return candidateStatusLabels[status] || status;
}
function formatCandidateSourceType(sourceType) {
const labels = {
search_result_image: "검색 이미지 결과",
provider_page_image: "페이지 대표 이미지",
html_page_image: "페이지 대표 이미지",
};
return labels[sourceType] || sourceType || "검색 이미지 결과";
}
function formatQueryStatus(status) {
return window.OperatorSearch.formatQueryStatus(status);
}
function formatQueryStrategy(strategy) {
return window.OperatorSearch.formatQueryStrategy(strategy);
}
function formatEvidenceStatus(status) {
return evidenceStatusLabels[normalizeEvidenceOperatorStatus(status)] || status || "자동 판단";
}
function normalizeEvidenceOperatorStatus(status) {
if (status === "used_for_judgment") return "used_for_judgment";
const ignoredStatuses = new Set();
ignoredStatuses.add("irrelevant");
ignoredStatuses.add("false_positive");
ignoredStatuses.add("ignored");
if (ignoredStatuses.has(status)) return "ignored";
return status || "";
}
function evidenceStatusPayload(status) {
return status === "ignored" ? "false_positive" : status;
}
function formatKnowledgeStatus(status) {
return knowledgeStatusLabels[status || "confirmed"] || status;
}
function formatKnowledgeType(type) {
return knowledgeTypeLabels[type] || type;
}
function formatProvenance(provenance) {
return provenanceLabels[provenance] || provenance;
}
function formatMatchType(matchType) {
const labels = {
full: "동일 이미지",
partial: "부분 일치",
page: "포함 페이지",
visual: "유사 이미지",
weak_label: "약한 라벨",
entity: "엔티티",
google_best_guess: "구글 추정 라벨",
google_face_crop_page: "얼굴 영역 웹페이지",
google_face_crop_entity: "얼굴 영역 엔티티",
empty: "결과 없음",
};
labels.search_result_image = "검색 결과 이미지 지목";
labels.search_result_page_image = "검색 결과 페이지 이미지 지목";
return labels[matchType] || matchType;
}
function formatFactLabel(label) {
const labels = {
size: "크기",
format: "형식",
submitted: "제출",
analysis: "분석",
};
return labels[label] || formatReason(label);
}
function formatDomain(domain) {
if (!domain) return "내부";
return formatReason(domain);
}
function formatQueueTimestamp(value) {
return value || "-";
}

View file

@ -1,450 +0,0 @@
// Part 9 of 15 of the operator console app script.
//
// Keyboard review mode, duplicate grouping, and batch decisions.
//
// Load order is significant: these are classic (non-module) scripts sharing one
// global scope, and index.html must keep them in the order listed above/below.
// Splitting is a pure line-range extraction of the former single app.js.
// --- Keyboard review mode ------------------------------------------------
const KEYBOARD_HELP = [
"J / ↓ : 다음 케이스",
"K / ↑ : 이전 케이스",
"A : 승인 H : 보류 R : 반려(메모로 이동)",
"N : 다음 미심사 케이스",
"? : 이 도움말 열기/닫기",
"Esc : 도움말 닫기",
].join("\n");
function isTypingTarget(target) {
if (!target) return false;
const tag = String(target.tagName || "").toLowerCase();
return tag === "input" || tag === "textarea" || tag === "select" || target.isContentEditable;
}
function moveSelection(offset) {
const rows = filteredSubmissions();
if (!rows.length) return;
const index = rows.findIndex((item) => item.id === state.selectedCaseId);
// From "no selection", J lands on the first row and K on the last.
const nextIndex = index === -1
? (offset > 0 ? 0 : rows.length - 1)
: Math.min(rows.length - 1, Math.max(0, index + offset));
selectCase(rows[nextIndex].id);
}
function moveToNextUnreviewed() {
const rows = filteredSubmissions();
const index = rows.findIndex((item) => item.id === state.selectedCaseId);
const ordered = [...rows.slice(index + 1), ...rows.slice(0, Math.max(0, index + 1))];
const next = ordered.find((item) => item.decisionStatus === "unreviewed");
if (next) {
selectCase(next.id);
} else {
setImportStatus("미심사 케이스가 없습니다.", "ok");
}
}
function renderReviewProgress() {
const target = document.getElementById("review-progress");
if (!target) return;
const rows = filteredSubmissions();
const position = rows.findIndex((item) => item.id === state.selectedCaseId) + 1;
const remaining = rows.filter((item) => item.decisionStatus === "unreviewed").length;
target.textContent = rows.length
? `${position || "-"}/${rows.length} · 남은 미심사 ${remaining}건 · ? 도움말`
: "";
}
function handleReviewShortcut(event) {
if (event.ctrlKey || event.metaKey || event.altKey) return;
if (isTypingTarget(event.target)) return;
const key = event.key;
if (key === "Escape") {
const help = document.getElementById("keyboard-help");
if (help && !help.hidden) help.hidden = true;
return;
}
if (key === "?") {
const help = document.getElementById("keyboard-help");
if (help) {
help.textContent = KEYBOARD_HELP;
help.hidden = !help.hidden;
}
event.preventDefault();
return;
}
// Navigation works from the queue and the workbench; decisions only make
// sense once a case is open.
if (key === "j" || key === "J" || key === "ArrowDown") {
moveSelection(1);
event.preventDefault();
return;
}
if (key === "k" || key === "K" || key === "ArrowUp") {
moveSelection(-1);
event.preventDefault();
return;
}
if (key === "n" || key === "N") {
moveToNextUnreviewed();
event.preventDefault();
return;
}
if (!state.selectedCaseId) return;
if (key === "a" || key === "A") {
setDecision("approved").then(() => moveToNextUnreviewed());
event.preventDefault();
return;
}
if (key === "h" || key === "H") {
setDecision("held").then(() => moveToNextUnreviewed());
event.preventDefault();
return;
}
if (key === "r" || key === "R") {
// Rejection needs a memo, so hand the operator the field instead of firing
// a request that would fail validation.
switchView("workbench");
const memo = document.getElementById("decision-memo");
if (memo) memo.focus();
event.preventDefault();
}
}
// --- Duplicate / prior-case grouping ------------------------------------
function duplicateBadgeHtml(submission) {
const group = submission.duplicateGroup;
if (!group || !group.size) return "";
const priors = group.priorDecisions || [];
const priorText = priors.length
? `선례: ${priors
.map((item) => `${item.submissionId} ${decisionLabels[item.decision] || item.decision}`)
.join(", ")}`
: "아직 판정된 선례 없음";
const tone = group.conflicting ? " conflicting" : "";
const conflictNote = group.conflicting ? " · 판정 불일치" : "";
return `<button type="button" class="duplicate-badge${tone}" data-duplicate-group="${escapeHtml(submission.id)}" title="${escapeHtml(`${priorText}${conflictNote}`)}">동일 이미지 ${group.size}건${conflictNote}</button>`;
}
function selectDuplicateGroup(submissionId) {
// Tick the whole group so the operator can hand it to batch re-analysis or a
// batch decision in one move.
const submission = submissions.find((item) => item.id === submissionId);
const group = submission && submission.duplicateGroup;
if (!group) return;
const ids = new Set([submissionId, ...(group.peerIds || [])]);
document.querySelectorAll("input[data-bulk-id]").forEach((input) => {
input.checked = ids.has(input.dataset.bulkId);
});
renderBulkDecisionCount();
const status = document.getElementById("bulk-decision-status");
if (status) {
const priors = group.priorDecisions || [];
status.textContent = priors.length
? `동일 이미지 ${ids.size}건 선택 · 선례 ${priors
.map((item) => `${item.submissionId} ${decisionLabels[item.decision] || item.decision}`)
.join(", ")}`
: `동일 이미지 ${ids.size}건을 선택했습니다.`;
}
}
// --- Batch decisions ---------------------------------------------------
function checkedBulkSubmissionIds() {
// Unlike selectedBulkSubmissionIds(), this never falls back to the current
// case: a batch decision must act on an explicit selection only.
return Array.from(document.querySelectorAll("input[data-bulk-id]:checked")).map(
(input) => input.dataset.bulkId,
);
}
function renderBulkDecisionCount() {
const target = document.getElementById("bulk-decision-count");
if (!target) return;
const ids = checkedBulkSubmissionIds();
const highRisk = ids.filter((id) => {
const submission = submissions.find((item) => item.id === id);
return submission && submission.riskBand === "high";
}).length;
target.textContent = highRisk
? `선택 ${ids.length}건 (고위험 ${highRisk}건)`
: `선택 ${ids.length}건`;
}
let isBulkDecisionInFlight = false;
async function applyBulkDecision(decision) {
if (isBulkDecisionInFlight) return;
const ids = checkedBulkSubmissionIds();
const status = document.getElementById("bulk-decision-status");
const setStatus = (message) => {
if (status) status.textContent = message;
};
if (!ids.length) {
setStatus("판정할 케이스를 먼저 선택하세요.");
return;
}
const memo = document.getElementById("decision-memo")?.value.trim() || "";
if (decision === "rejected" && !memo) {
setStatus("일괄 반려에는 결정 메모가 필요합니다. 메모를 입력한 뒤 다시 시도하세요.");
return;
}
const highRisk = ids.filter((id) => {
const submission = submissions.find((item) => item.id === id);
return submission && submission.riskBand === "high";
});
// Guardrail: approving a high-risk case in bulk is the one irreversible-feeling
// action here, so name the count instead of asking a generic "are you sure".
if (highRisk.length) {
const label = decisionLabels[decision] || decision;
if (!window.confirm(`선택 ${ids.length}건 중 고위험 ${highRisk.length}건이 포함되어 있습니다. 그대로 ${label} 처리할까요?`)) {
setStatus("일괄 판정을 취소했습니다.");
return;
}
}
isBulkDecisionInFlight = true;
setStatus(`${ids.length}건 판정 중…`);
try {
const payload = await apiJson("/api/submissions/decision-batch", {
method: "POST",
body: JSON.stringify({ submission_ids: ids, decision, memo }),
});
const result = payload.batchDecision || { applied: [], failed: [] };
applyBootstrap(payload);
renderAll();
const failures = result.failed || [];
setStatus(
failures.length
? `${(result.applied || []).length}건 적용 · ${failures.length}건 실패: ${failures
.map((item) => `${item.submissionId}(${item.error})`)
.join(", ")}`
: `${(result.applied || []).length}건 판정을 적용했습니다.`,
);
} catch (errorValue) {
setStatus(`일괄 판정 실패: ${errorValue.message}`);
} finally {
isBulkDecisionInFlight = false;
}
}

View file

@ -1,450 +0,0 @@
// Part 8 of 15 of the operator console app script.
//
// View/tab switching, case selection, decisions, manual search, rerun.
//
// Load order is significant: these are classic (non-module) scripts sharing one
// global scope, and index.html must keep them in the order listed above/below.
// Splitting is a pure line-range extraction of the former single app.js.
function switchView(view, updateHash = true) {
if (view === "case" || view === "evidence") view = "workbench";
let knowledgeTabToApply = "";
if (view === "corrections") {
view = "knowledge";
// Setting state alone left the visible panel on whatever knowledge tab was
// last shown — the tab DOM is switched by switchKnowledgeTab, which must run
// (applied at the end, after the view panels are toggled).
knowledgeTabToApply = "corrections";
}
if (!viewNames.has(view)) return;
state.currentView = view;
// Stats are read on demand (a fresh cross-tab each open) rather than shipped
// in every bootstrap payload.
if (view === "stats") {
void loadDecisionStats();
}
document.querySelectorAll(".nav-button").forEach((button) => {
const active = button.dataset.view === view;
button.classList.toggle("active", active);
if (active) {
button.setAttribute("aria-current", "page");
} else {
button.removeAttribute("aria-current");
}
});
document.querySelectorAll("[data-view-panel]").forEach((panel) => {
const active = panel.dataset.viewPanel === view;
panel.classList.toggle("active", active);
panel.hidden = !active;
});
if (knowledgeTabToApply) {
switchKnowledgeTab(knowledgeTabToApply);
}
if (updateHash && window.location.hash.replace("#", "") !== view) {
window.history.replaceState(null, "", `#${view}`);
}
}
function switchKnowledgeTab(tab) {
const nextTab = ["collect", "registered", "manual", "corrections"].includes(tab) ? tab : "collect";
state.knowledgeTab = nextTab;
document.querySelectorAll("[data-knowledge-tab]").forEach((button) => {
const active = button.dataset.knowledgeTab === nextTab;
button.classList.toggle("active", active);
button.setAttribute("aria-selected", active ? "true" : "false");
});
document.querySelectorAll("[data-knowledge-panel]").forEach((panel) => {
const active = panel.dataset.knowledgePanel === nextTab;
panel.classList.toggle("active", active);
panel.hidden = !active;
});
}
function switchWorkbenchTab(tab) {
const nextTab = ["evidence", "queries"].includes(tab) ? tab : "evidence";
state.workbenchTab = nextTab;
document.querySelectorAll("[data-workbench-tab]").forEach((button) => {
const active = button.dataset.workbenchTab === nextTab;
button.classList.toggle("active", active);
button.setAttribute("aria-selected", active ? "true" : "false");
});
document.querySelectorAll("[data-workbench-panel]").forEach((panel) => {
const panelName = panel.dataset.workbenchPanel;
const active = panelName === nextTab;
panel.classList.toggle("active", active);
panel.hidden = !active;
});
}
function selectCase(caseId) {
state.selectedCaseId = caseId;
suggestedQueryRunSummary = null;
// Reset per-case inputs. The decision memo is submitted with whatever case is
// selected at click time, so a memo typed for case A and left in the box was
// recorded verbatim as the rejection/correction reason for case B.
resetCaseScopedInputs();
switchView("workbench");
switchWorkbenchTab("evidence");
renderAll();
}
function resetCaseScopedInputs() {
const memo = document.getElementById("decision-memo");
if (memo) {
memo.value = "";
}
const memoError = document.getElementById("memo-error");
if (memoError) {
memoError.textContent = "";
}
const manualQuery = document.getElementById("manual-query");
if (manualQuery) {
manualQuery.value = "";
}
const manualStatus = document.getElementById("manual-query-status");
if (manualStatus) {
manualStatus.textContent = "";
}
}
let isDecisionInFlight = false;
async function setDecision(decision, requiresMemo = false) {
// In-flight guard: a double-click, or clicking two different decisions
// quickly, would otherwise POST two conflicting decisions concurrently.
if (isDecisionInFlight) return;
const submission = getSelectedCase();
const memo = document.getElementById("decision-memo").value.trim();
const error = document.getElementById("memo-error");
if (!submission) {
error.textContent = "판정할 제출 이미지가 없습니다.";
return;
}
if (requiresMemo && !memo) {
error.textContent = "반려 또는 보정 결정에는 메모가 필요합니다.";
return;
}
error.textContent = "";
isDecisionInFlight = true;
try {
await apiJson(`/api/submissions/${encodeURIComponent(submission.id)}/decision`, {
method: "POST",
body: JSON.stringify({ decision, memo }),
});
document.getElementById("decision-memo").value = "";
await refreshFromApi();
} catch (errorValue) {
error.textContent = errorValue.message;
} finally {
isDecisionInFlight = false;
}
}
let isManualSearchActive = false;
async function runManualSearch(event) {
event.preventDefault();
if (isManualSearchActive) return;
const submission = getSelectedCase();
const provider = document.getElementById("manual-query-provider").value;
const queryInput = document.getElementById("manual-query");
const status = document.getElementById("manual-query-status");
const query = queryInput.value.trim();
if (!submission) {
status.textContent = "검색을 연결할 제출 이미지가 없습니다.";
return;
}
if (!query) {
status.textContent = "쿼리를 입력하세요.";
return;
}
const providerConfig = providers.find((item) => item.id === provider);
if (!providerConfig || !providerConfig.enabled) {
status.textContent = `${providerLabels[provider] || provider} 외부 검색 tool이 비활성입니다.`;
return;
}
isManualSearchActive = true;
try {
const targetCaseId = submission.id;
await apiJson("/api/search/manual", {
method: "POST",
body: JSON.stringify({ submission_id: targetCaseId, provider, query }),
});
// The operator may have moved to another case while the search ran. Naming
// the case the evidence actually landed on prevents "added to the current
// case" from pointing at the wrong one, and the input is only cleared when
// it still belongs to the case we searched for.
if (state.selectedCaseId === targetCaseId) {
queryInput.value = "";
status.textContent = `${providerLabels[provider]} 쿼리 결과가 현재 케이스 증거에 추가되었습니다.`;
} else {
status.textContent = `${providerLabels[provider]} 쿼리 결과가 케이스 ${targetCaseId}에 추가되었습니다.`;
}
await refreshFromApi();
} catch (errorValue) {
status.textContent = errorValue.message;
} finally {
isManualSearchActive = false;
}
}
function normalizeManualSearchProvider(provider) {
return window.OperatorSearch.normalizeManualSearchProvider(provider);
}
function rerunHistoricalQuery(button) {
const queryInput = document.getElementById("manual-query");
const providerSelect = document.getElementById("manual-query-provider");
const provider = normalizeManualSearchProvider(button.dataset.rerunProvider);
queryInput.value = button.dataset.rerunQuery || "";
providerSelect.value = provider;
void runManualSearch(new Event("submit"));
}
async function rerunEnrichment() {
const submission = getSelectedCase();
const button = document.getElementById("rerun-enrichment");
if (!submission) {
showApiError("재분석할 제출 이미지가 없습니다.");
return;
}
if (button) button.disabled = true;
try {
await apiJson(`/api/submissions/${encodeURIComponent(submission.id)}/rerun-enrichment`, {
method: "POST",
body: JSON.stringify({}),
});
await refreshFromApi();
} catch (errorValue) {
showApiError(errorValue.message);
} finally {
if (button) button.disabled = false;
}
}
function selectedBulkSubmissionIds() {
const ids = Array.from(document.querySelectorAll("input[data-bulk-id]:checked")).map((input) => input.dataset.bulkId);
return ids.length ? ids : state.selectedCaseId ? [state.selectedCaseId] : [];
}

View file

@ -1,449 +0,0 @@
// Part 6 of 15 of the operator console app script.
//
// Evidence grouping, ordering, and the evidence panel markup.
//
// Load order is significant: these are classic (non-module) scripts sharing one
// global scope, and index.html must keep them in the order listed above/below.
// Splitting is a pure line-range extraction of the former single app.js.
function evidenceGroupDefinitions() {
return [
{ id: "watchlist", label: "주의 후보 근거", description: "보류/반려 이력에서 생성된 주의 후보 또는 유사 이미지입니다.", tone: "strong" },
{ id: "face_web", label: "얼굴 영역 웹 근거", description: "얼굴 영역만으로 찾은 웹 검색 결과입니다. 동일 인물 판정은 아닙니다.", tone: "search" },
{ id: "full", label: "동일 이미지", description: "원본 이미지 직접 매칭", tone: "strong" },
{ id: "partial", label: "부분 매칭", description: "일부 영역 또는 파생 이미지 매칭", tone: "strong" },
{ id: "page", label: "출처 페이지", description: "같은 이미지가 포함된 웹페이지", tone: "strong" },
{ id: "naver", label: "네이버 검색", description: "텍스트 기반 이미지 검색 결과", tone: "search" },
{ id: "face", label: "얼굴/인물 감지", description: "동일 인물 판정은 아니며 얼굴 또는 사람 존재만 표시합니다.", tone: "local" },
{ id: "provenance", label: "출처 표기", description: "파일에 들어 있던 권리 표기와 화면 캡처 신호입니다. 자기신고 정보이며 점수에 반영하지 않습니다.", tone: "local" },
{ id: "visual", label: "유사 이미지", description: "시각적으로 유사한 참고 결과", tone: "weak" },
{ id: "weak", label: "약한 라벨", description: "점수에 직접 반영하지 않는 힌트", tone: "weak" },
{ id: "llm", label: "내부 요약", description: "출처 연결 요약 메모", tone: "note" },
{ id: "internal", label: "내부 지문", description: "로컬 지문 기반 내부 처리 기록", tone: "local" },
{ id: "failure", label: "실패/건너뜀", description: "외부 검색 도구 실패 또는 정책상 스킵", tone: "failure" },
];
}
function evidenceGroupFor(evidence) {
if (evidence.knowledgeEntryStatus === "watchlist") return "watchlist";
if (evidence.faceCropSearch) return "face_web";
if (evidence.source === "google") {
if (evidence.matchType === "full") return "full";
if (evidence.matchType === "partial") return "partial";
if (evidence.matchType === "page") return "page";
if (evidence.matchType === "visual") return "visual";
if (evidence.matchType === "weak_label" || evidence.status === "weak") return "weak";
return "visual";
}
if (evidence.source === "naver") return "naver";
if (evidence.source === "face") return "face";
if (evidence.source === "provenance") return "provenance";
if (evidence.source === "llm") return "llm";
if (evidence.source === "failure") return "failure";
return "internal";
}
function groupedEvidence(evidence) {
return evidence.reduce((collection, item) => {
const group = evidenceGroupFor(item);
if (!collection[group]) collection[group] = [];
collection[group].push(item);
return collection;
}, {});
}
function evidencePriorityScore(evidence) {
const groupPriority = {
full: 100,
watchlist: 95,
partial: 85,
page: 75,
naver: 65,
face_web: 58,
face: 55,
visual: 35,
weak: 10,
llm: 8,
internal: 6,
failure: 4,
};
return (groupPriority[evidenceGroupFor(evidence)] || 0) + Number(evidence.confidence || 0);
}
function sortEvidenceItems(items) {
return [...items].sort((left, right) => evidencePriorityScore(right) - evidencePriorityScore(left));
}
function renderRerunDiffSummary(diff) {
const scoreBefore = Number(diff.scoreBefore || 0);
const scoreAfter = Number(diff.scoreAfter || 0);
const delta = scoreAfter - scoreBefore;
const direction = delta > 0 ? `상승 ${delta}` : delta < 0 ? `하락 ${Math.abs(delta)}` : "변동 없음";
const removedSummaries = diff.removedSummaries || [];
const removedBlock = removedSummaries.length
? `
<details class="rerun-removed">
<summary>이번 재분석에서 제거됨 · ${removedSummaries.length}개</summary>
<ul>
${removedSummaries
.map((item) => `<li>${escapeHtml(sourceLabels[item.source] || item.source || "내부")} · ${escapeHtml(formatReason(item.reason || ""))}</li>`)
.join("")}
</ul>
</details>
`
: "";
return `
<section class="rerun-diff-panel" aria-label="재분석 변경 요약">
<strong>재분석 ${escapeHtml(diff.at || "")}</strong>
<span>점수 ${escapeHtml(String(scoreBefore))} → ${escapeHtml(String(scoreAfter))} (${escapeHtml(direction)})</span>
<span>신규 증거 ${(diff.addedEvidenceIds || []).length}개</span>
${removedBlock}
</section>
`;
}
function renderEvidenceSummary(submission) {
const groups = groupedEvidence(submission.evidence);
const count = (group) => (groups[group] || []).length;
const directCount = count("full") + count("partial") + count("page");
const searchCount = directCount + count("naver");
const primaryJudgment = directCount
? "직접 매칭 우선 확인"
: count("visual")
? "유사 이미지 참고 확인"
: count("face")
? "인물 신호만 확인됨"
: "증거 부족";
const summaryCards = [
["동일", count("full")],
["부분", count("partial")],
["페이지", count("page")],
["유사", count("visual")],
["네이버", count("naver")],
["인물", count("face")],
];
return `
<section class="evidence-summary-board" aria-label="판단 요약">
<div class="summary-lead">
<span class="risk-badge ${riskClass(submission.riskBand)}">${riskLabels[submission.riskBand]} ${submission.riskScore}</span>
<div>
<strong>${escapeHtml(primaryJudgment)}</strong>
<span>${searchCount}개 검색 근거 · 전체 ${submission.evidence.length}개 증거</span>
</div>
</div>
<div class="summary-stat-grid">
${summaryCards
.map(
([label, value]) => `
<div class="summary-stat ${value ? "has-signal" : ""}">
<span>${escapeHtml(label)}</span>
<strong>${value}</strong>
</div>
`,
)
.join("")}
</div>
</section>
`;
}
function renderEvidenceGroups(submission) {
if (!submission.evidence.length) {
return `<div class="empty-state">아직 분석 증거가 없습니다. 외부 검색 도구 상태를 확인하고 증거 재수집을 실행하세요.</div>`;
}
const groups = groupedEvidence(submission.evidence);
return evidenceGroupDefinitions()
.filter((group) => groups[group.id]?.length)
.map((group) => renderEvidenceGroup(group, sortEvidenceItems(groups[group.id])))
.join("");
}
function renderEvidenceGroup(group, items) {
const topItems = items.slice(0, 3);
const detailItems = items.slice(3);
return `
<section class="evidence-group evidence-group-${escapeHtml(group.tone)}" aria-label="${escapeHtml(group.label)} 증거">
<div class="evidence-group-head">
<div>
<h3>${escapeHtml(group.label)}</h3>
<span>${escapeHtml(group.description)}</span>
</div>
<strong>${items.length}개</strong>
</div>
<div class="evidence-card-grid">
${topItems.map(renderEvidenceRow).join("")}
</div>
${
detailItems.length
? `
<details class="evidence-details">
<summary>자세히 보기 · ${detailItems.length}개</summary>
<div class="evidence-card-grid evidence-card-grid-detail">
${detailItems.map(renderEvidenceRow).join("")}
</div>
</details>
`
: ""
}
</section>
`;
}
function renderEvidenceRow(evidence) {
const isNewFromRerun = activeRerunAddedIds.has(evidence.id);
const newChip = isNewFromRerun ? `<span class="evidence-new-chip">신규</span>` : "";
const sourceEvidenceIds = evidence.sourceEvidenceIds || [];
const sourceIds = sourceEvidenceIds.length
? `근거 ID: ${sourceEvidenceIds.join(", ")}`
: "근거 ID 없음";
const contribution = evidence.contributed ? "점수 반영" : "참고";
const normalizedOperatorStatus = normalizeEvidenceOperatorStatus(evidence.operatorStatus);
const operatorStatus = normalizedOperatorStatus
? `<span class="evidence-status-chip">${escapeHtml(formatEvidenceStatus(normalizedOperatorStatus))}</span>`
: "";
const confidence = typeof evidence.confidence === "number" ? evidence.confidence.toFixed(2) : evidence.confidence;
const matchType = evidence.matchType ? `<span>${escapeHtml(formatMatchType(evidence.matchType))}</span>` : "";
const pageTitle = evidence.pageTitle ? `<span>${escapeHtml(evidence.pageTitle)}</span>` : "";
const faceCropMeta = evidence.faceCropSearch
? `<span>얼굴 영역 ${escapeHtml(evidence.cropIndex || "")}</span><span>${escapeHtml(evidence.privacyNote || "동일 인물 판정은 아닙니다")}</span>`
: "";
const watchlistMeta =
evidence.knowledgeEntryStatus === "watchlist"
? `<span>주의 후보: ${escapeHtml(evidence.knowledgeEntryName || evidence.knowledgeEntryId || "-")}</span><span>원 케이스 ${escapeHtml(evidence.sourceSubmissionId || "-")}</span>`
: "";
const providerScore =
evidence.providerScore !== undefined && evidence.providerScore !== null && evidence.providerScore !== "" ? `<span>검색 도구 점수 ${escapeHtml(evidence.providerScore)}</span>` : "";
const similarity =
evidence.similarity !== undefined && evidence.similarity !== ""
? `<span>이미지 유사도 ${escapeHtml(Number(evidence.similarity).toFixed(2))}</span>`
: "";
const queryStrategy = evidence.queryStrategy ? `<span>${escapeHtml(formatQueryStrategy(evidence.queryStrategy))}</span>` : "";
const searchTypeLabels = { image: "이미지 검색", blog: "블로그 페이지", web: "웹문서 페이지" };
const searchType = evidence.searchType ? `<span>${escapeHtml(searchTypeLabels[evidence.searchType] || evidence.searchType)}</span>` : "";
const hasPreview = Boolean(evidence.thumbnailUrl || evidence.imageUrl);
const preview = renderEvidencePreview(evidence);
const link = renderEvidenceLink(evidence);
const statusButtons = [
["used_for_judgment", "사용"],
["ignored", "미사용"],
]
.map(
([status, label]) => `
<button class="row-action ${normalizedOperatorStatus === status ? "active" : ""}" type="button" data-evidence-status="${status}" data-evidence-id="${escapeHtml(evidence.id)}">
${label}
</button>
`,
)
.join("");
return `
<article class="evidence-row ${hasPreview ? "" : "no-preview"} ${isNewFromRerun ? "evidence-row-new" : ""}" data-evidence-id="${escapeHtml(evidence.id)}">
${preview}
<div class="evidence-main">
<div class="evidence-title">
<span class="source-chip ${sourceClass(evidence.source)}">${escapeHtml(sourceLabels[evidence.source] || evidence.source)}</span>
${newChip}
<span>${escapeHtml(formatEvidenceTitle(evidence))}</span>
</div>
<div class="evidence-meta">
<span>신뢰도 ${escapeHtml(confidence)}</span>
<span>${escapeHtml(contribution)}</span>
${operatorStatus}
${matchType}
${faceCropMeta}
${watchlistMeta}
${providerScore}
${similarity}
${queryStrategy}
${searchType}
${pageTitle}
<span>${escapeHtml(evidence.query || "쿼리 없음")}</span>
<span>${escapeHtml(formatDomain(evidence.domain))}</span>
<span>${escapeHtml(evidence.retrievedAt)}</span>
<span>${escapeHtml(sourceIds)}</span>
</div>
${link}
</div>
<div class="evidence-actions">
${statusButtons}
</div>
</article>
`;
}
function renderEvidencePreview(evidence) {
const previewUrl = evidence.thumbnailUrl || evidence.imageUrl;
const targetUrl = evidence.imageUrl || evidence.url || previewUrl;
if (!previewUrl) return `<div class="evidence-preview evidence-preview-empty" aria-hidden="true"></div>`;
return `
<a class="evidence-preview" href="${escapeHtml(safeUrl(targetUrl))}" target="_blank" rel="noreferrer" aria-label="검색 이미지 열기">
<img src="${escapeHtml(safeUrl(previewUrl))}" alt="" onerror="this.style.display='none'">
</a>
`;
}
function renderEvidenceLink(evidence) {
const url = evidence.url || evidence.imageUrl || "";
if (!url) return "";
const label = evidence.pageTitle || evidence.url || evidence.imageUrl;
return `
<a class="evidence-link" href="${escapeHtml(safeUrl(url))}" target="_blank" rel="noreferrer">
${escapeHtml(label)}
</a>
`;
}
function renderEvidenceSearch() {
const submission = getSelectedCase();
const queryHistory = document.getElementById("query-history");
const results = document.getElementById("search-results");
if (!submission) {
document.getElementById("query-case-label").textContent = "-";
queryHistory.innerHTML = `<div class="empty-state">표시할 케이스가 없습니다.</div>`;
results.innerHTML = `<div class="empty-state">API 연결 후 검색 근거를 확인할 수 있습니다.</div>`;
return;
}
document.getElementById("query-case-label").textContent = submission.id;
queryHistory.innerHTML = submission.queryHistory.length
? submission.queryHistory
.map(
(query) => `
<article class="query-row">
<div class="row-title">
<span class="source-chip ${sourceClass(query.provider)}">${escapeHtml(providerLabels[query.provider] || query.provider)}</span>
<span>${escapeHtml(query.query)}</span>
</div>
<div class="row-meta">
<span>${escapeHtml(formatQueryStatus(query.status))}</span>
${query.strategy ? `<span>${escapeHtml(formatQueryStrategy(query.strategy))}</span>` : ""}
${query.source ? `<span>출처 ${escapeHtml(query.source)}</span>` : ""}
<span>${escapeHtml(query.timestamp)}</span>
<span>${query.count}건</span>
</div>
<button class="row-action" type="button" data-rerun-query="${escapeHtml(query.query)}" data-rerun-provider="${escapeHtml(query.provider || "")}">쿼리 재실행</button>
</article>
`,
)
.join("")
: `<div class="empty-state">아직 실행한 검색 쿼리가 없습니다.</div>`;
activeRerunAddedIds = new Set(submission.lastRerunDiff?.addedEvidenceIds || []);
const searchableEvidence = searchableEvidenceItems(submission);
results.innerHTML = searchableEvidence.length
? renderEvidenceGroups({ ...submission, evidence: searchableEvidence })
: `<div class="empty-state">현재 케이스에는 검색 결과가 없습니다.</div>`;
}

View file

@ -1,364 +0,0 @@
// Part 7 of 15 of the operator console app script.
//
// Knowledge, candidates, corrections, providers, audit panels and renderAll.
//
// Load order is significant: these are classic (non-module) scripts sharing one
// global scope, and index.html must keep them in the order listed above/below.
// Splitting is a pure line-range extraction of the former single app.js.
function filteredKnowledgeEntries() {
const query = knowledgeFilters.query.trim().toLowerCase();
return knowledgeEntries.filter((entry) => {
const typeOk = knowledgeFilters.type === "all" || entry.type === knowledgeFilters.type;
const statusOk = knowledgeFilters.status === "all" || (entry.entryStatus || "confirmed") === knowledgeFilters.status;
const activeOk = knowledgeFilters.active === "all" || (knowledgeFilters.active === "active") === Boolean(entry.active);
const haystack = [entry.name, ...(entry.aliases || []), ...(entry.keywords || [])].join(" ").toLowerCase();
const queryOk = !query || haystack.includes(query);
return typeOk && statusOk && activeOk && queryOk;
});
}
function renderKnowledgeBase() {
const entries = filteredKnowledgeEntries();
document.getElementById("knowledge-list").innerHTML = entries.length
? entries
.map((entry) => {
const aliases = entry.aliases || [];
const keywords = entry.keywords || [];
const entryStatus = entry.entryStatus || "confirmed";
const statusMeta =
entryStatus === "watchlist"
? `<span>원 케이스: ${escapeHtml(entry.sourceSubmissionId || "-")}</span><span>미래 검출 ${escapeHtml(entry.contributionCount || 0)}건</span>`
: "";
const memoInline = entry.memo && entry.memo.startsWith("외부 후보 수집에서 반영:") ? `<span>${escapeHtml(entry.memo)}</span>` : "";
const memoBlock = memoInline ? "" : `<p class="muted small">${escapeHtml(entry.memo)}</p>`;
const lifecycleActions =
entryStatus === "watchlist"
? `
<button class="row-action" type="button" data-promote-watchlist="${escapeHtml(entry.id)}">확정 DB 반영</button>
<button class="row-action danger" type="button" data-exclude-watchlist="${escapeHtml(entry.id)}">오탐 제외</button>
`
: entryStatus === "excluded"
? ""
: entry.active
? `<button class="row-action danger" type="button" data-deactivate-kb="${escapeHtml(entry.id)}">비활성</button>`
: `<button class="row-action" type="button" data-reactivate-kb="${escapeHtml(entry.id)}">재활성</button>`;
const editForm =
editingKnowledgeEntryId === entry.id
? `
<form class="knowledge-edit-form" data-knowledge-edit-form="${escapeHtml(entry.id)}">
<label>
<span>별칭</span>
<input name="aliases" type="text" value="${escapeHtml(aliases.join(", "))}">
</label>
<label>
<span>검색 키워드</span>
<input name="keywords" type="text" value="${escapeHtml(keywords.join(", "))}">
</label>
<label class="wide-field">
<span>정책 메모</span>
<textarea name="memo" rows="3">${escapeHtml(entry.memo || "")}</textarea>
</label>
<div class="knowledge-edit-actions">
<button class="primary-action" type="submit">저장</button>
<button class="secondary-action" type="button" data-cancel-edit-kb="${escapeHtml(entry.id)}">취소</button>
</div>
</form>
`
: "";
return `
<article class="knowledge-row ${entry.active ? "" : "inactive"} ${entryStatus === "watchlist" ? "watchlist" : ""}">
${
entry.imageAsset
? `<img class="knowledge-thumb" src="${escapeHtml(entry.imageAsset)}" alt="${escapeHtml(entry.name)} 참조 이미지">`
: `<div class="knowledge-thumb empty" aria-hidden="true"></div>`
}
<div class="knowledge-main">
<div class="row-title knowledge-title">
<span>${escapeHtml(entry.name)}</span>
</div>
<div class="knowledge-detail-line">
<div class="knowledge-chip-row">
<span class="provenance-chip ${escapeHtml(entry.provenance)}">${escapeHtml(formatProvenance(entry.provenance))}</span>
<span class="watchlist-chip ${escapeHtml(entryStatus)}">${escapeHtml(formatKnowledgeStatus(entryStatus))}</span>
</div>
<div class="row-meta knowledge-meta">
<span>${escapeHtml(formatKnowledgeType(entry.type))}</span>
<span>별칭: ${escapeHtml(aliases.join(", ") || "-")}</span>
<span>키워드: ${escapeHtml(keywords.join(", ") || "-")}</span>
<span>샘플 지문 ${(entry.sampleFingerprints || []).length}</span>
${statusMeta}
${memoInline}
</div>
</div>
${memoBlock}
${editForm}
</div>
<div class="knowledge-actions">
<button class="row-action" type="button" data-edit-kb="${escapeHtml(entry.id)}">편집</button>
${lifecycleActions}
</div>
</article>
`;
})
.join("")
: `<div class="empty-state">조건에 맞는 기준 항목이 없습니다.</div>`;
renderCollectionCandidates();
}
function renderCollectionCandidates() {
const target = document.getElementById("collection-candidates");
if (!target) return;
const visibleCandidates = visibleCollectionCandidates();
target.innerHTML = visibleCandidates.length
? visibleCandidates
.map((candidate) => {
const promoted = candidate.status === "promoted";
const canPromote = !promoted && (candidate.sampleFingerprints || []).length;
return `
<article class="candidate-card ${promoted ? "promoted" : ""}">
<label class="candidate-select">
<input
type="checkbox"
data-collection-candidate-id="${escapeHtml(candidate.id)}"
${canPromote ? "" : "disabled"}
>
<span class="sr-only">${escapeHtml(candidate.title || candidate.query)} 선택</span>
</label>
${
candidate.imageAsset
? `<img class="candidate-thumb" src="${escapeHtml(candidate.imageAsset)}" alt="${escapeHtml(candidate.title || candidate.query)} 후보 이미지">`
: `<div class="candidate-thumb empty" aria-hidden="true"></div>`
}
<div class="candidate-main">
<div class="row-title">
<span class="source-chip ${sourceClass(candidate.provider)}">${escapeHtml(providerLabels[candidate.provider] || candidate.provider || "검색")}</span>
<span>${escapeHtml(candidate.title || candidate.query)}</span>
</div>
<div class="row-meta">
<span>${escapeHtml(candidate.query)}</span>
<span>${escapeHtml(formatCandidateSourceType(candidate.sourceCandidateType))}</span>
<span>순위 ${escapeHtml(candidate.rank || "-")}</span>
<span>${escapeHtml(formatCandidateStatus(candidate.status))}</span>
</div>
${
candidate.sourceUrl
? `<a class="evidence-link" href="${escapeHtml(safeUrl(candidate.sourceUrl))}" target="_blank" rel="noreferrer">출처 열기</a>`
: ""
}
</div>
<div class="candidate-actions">
<button class="row-action" type="button" data-promote-candidate="${escapeHtml(candidate.id)}" ${canPromote ? "" : "disabled"}>
${promoted ? "반영됨" : "기준 DB 반영"}
</button>
<button class="row-action secondary-action" type="button" data-dismiss-candidate="${escapeHtml(candidate.id)}">무시</button>
</div>
</article>
`;
})
.join("")
: `<div class="empty-state">검색어와 제공자를 선택하면 기준 DB 후보가 표시됩니다.</div>`;
}
function visibleCollectionCandidates() {
return collectionCandidates.filter(
(candidate) =>
candidate.status === "candidate" &&
candidate.query === state.currentCollectionQuery &&
(!state.currentCollectionProvider || candidate.provider === state.currentCollectionProvider),
);
}
function renderCorrections() {
document.getElementById("corrections-list").innerHTML = corrections
.map(
(correction) => `
<article class="correction-row">
<div>
<div class="row-title">
<span class="risk-badge risk-failed">보정</span>
<span>${escapeHtml(correction.decisionId)} · ${escapeHtml(correction.submissionId)}</span>
</div>
<div class="row-meta">
<span>${escapeHtml(correction.previous)} -> ${escapeHtml(correction.current)}</span>
<span>${escapeHtml(correction.operator)}</span>
<span>${escapeHtml(correction.timestamp)}</span>
</div>
<p class="muted small">${escapeHtml(correction.reason)}</p>
<div class="chip-row">
${(correction.derivedEntries || [])
.map(
(entry) =>
`<span class="provider-chip ${entry.active ? "failed" : "disabled"}">${escapeHtml(entry.name)} · ${entry.active ? "활성" : "비활성"}</span>`,
)
.join("")}
</div>
</div>
<button class="row-action" type="button" data-deactivate-correction="${escapeHtml(correction.id)}">파생 항목 비활성</button>
</article>
`,
)
.join("");
}
function renderProviderEnvStatus(provider) {
const requiredEnv = Array.isArray(provider.requiredEnv) ? provider.requiredEnv : [];
if (!requiredEnv.length) return "";
const configuredEnv = provider.configuredEnv || {};
const missing = requiredEnv.filter((key) => !configuredEnv[key]);
if (!missing.length) {
return `<span class="muted small">환경 변수 확인됨: ${escapeHtml(requiredEnv.join(", "))}</span>`;
}
return `<span class="muted small">누락된 환경 변수: ${escapeHtml(missing.join(", "))}</span>`;
}
function visibleProviderControls() {
return providers.filter((provider) => !retiredProviderIds.has(provider.id));
}
function renderProviderControls() {
document.getElementById("providers-list").innerHTML = visibleProviderControls()
.map((provider) => {
const percent = provider.quota ? Math.min(100, Math.round((provider.usage / provider.quota) * 100)) : 0;
const providerName = formatProviderName(provider);
const envStatus = renderProviderEnvStatus(provider);
return `
<article class="provider-row">
<div class="provider-summary">
<div class="row-title">
<span class="status-dot ${provider.enabled ? "ok" : "bad"}" aria-hidden="true"></span>
<span>${escapeHtml(providerName)}</span>
</div>
<div class="row-meta">
<span>${provider.enabled ? "활성" : "중지"}</span>
<span>${escapeHtml(formatReason(provider.compliance))}</span>
<span>최근 성공: ${escapeHtml(formatReason(provider.lastSuccess))}</span>
<span>최근 실패: ${escapeHtml(formatReason(provider.lastFailure))}</span>
</div>
<span class="muted small">${escapeHtml(formatReason(provider.boundary))}</span>
${envStatus}
</div>
<div>
<div class="row-meta"><span>${provider.usage} / ${provider.quota == null ? "무제한" : provider.quota}</span><span>${percent}%</span></div>
<div class="quota-meter" aria-label="${escapeHtml(providerName)} 사용량 ${percent}%"><span style="width: ${percent}%"></span></div>
</div>
<div class="provider-actions">
<button type="button" data-toggle-provider="${escapeHtml(provider.id)}">${provider.enabled ? "중지" : "활성"}</button>
<button type="button" data-provider-retry="${escapeHtml(provider.id)}">실패 재시도</button>
</div>
</article>
`;
})
.join("");
}
function renderAuditLog() {
document.getElementById("audit-body").innerHTML = auditEvents
.map(
(event) => `
<tr>
<td>${escapeHtml(event.timestamp)}</td>
<td>${escapeHtml(event.actor)}</td>
<td>${escapeHtml(formatReason(event.event))}</td>
<td>${escapeHtml(formatReason(event.object))}</td>
<td>${escapeHtml(formatReason(event.change))}</td>
</tr>
`,
)
.join("");
}
function renderProviderPulse() {
const visibleProviders = visibleProviderControls();
const failed = visibleProviders.filter((provider) => provider.enabled && provider.lastFailure !== "없음").length;
const disabled = visibleProviders.filter((provider) => !provider.enabled).length;
const className = disabled || failed ? "warn" : "ok";
document.getElementById("provider-pulse").innerHTML = `
<span class="status-dot ${className}" aria-hidden="true"></span>
<span>외부 검색 tool ${visibleProviders.filter((provider) => provider.enabled).length}/${visibleProviders.length} 활성 · 실패 ${failed}</span>
`;
}
function renderAll() {
renderOperatorSearchProviderOptions();
renderProviderPulse();
renderCoverageTabs();
renderImportStatus();
renderQueue();
renderCaseReview();
renderEvidenceSearch();
renderKnowledgeBase();
labelCollectionCandidateActions();
renderCorrections();
renderProviderControls();
renderAuditLog();
renderMemoPresets();
renderBulkDecisionCount();
renderReviewProgress();
}
function labelCollectionCandidateActions() {
document.querySelectorAll("[data-promote-candidate]").forEach((button) => {
const candidate = collectionCandidates.find((item) => item.id === button.dataset.promoteCandidate);
button.textContent = candidate?.status === "promoted" ? "반영됨" : "기준 DB 반영";
});
}

View file

@ -1,322 +0,0 @@
// Part 5 of 15 of the operator console app script.
//
// Queue list, import status, and the case review shell.
//
// Load order is significant: these are classic (non-module) scripts sharing one
// global scope, and index.html must keep them in the order listed above/below.
// Splitting is a pure line-range extraction of the former single app.js.
function renderQueue() {
const rows = filteredSubmissions();
const queueLabel = state.submissionQueue?.label || "";
const queueMeta = queueLabel ? ` (${escapeHtml(queueLabel)})` : "";
const body = document.getElementById("queue-body");
document.getElementById("queue-health").innerHTML = state.apiError
? `
<span class="status-dot warn" aria-hidden="true"></span>
<span>${escapeHtml(state.apiError)}</span>
`
: `
<span class="status-dot ${rows.some((item) => item.riskBand === "failed") ? "warn" : "ok"}" aria-hidden="true"></span>
<span>${rows.length}건 / 고위험 ${submissions.filter((item) => item.riskBand === "high").length}건${queueMeta}</span>
`;
if (!rows.length) {
body.innerHTML = `
<tr>
<td colspan="9">
<div class="empty-state">현재 필터에 맞는 심사 건이 없습니다.</div>
</td>
</tr>
`;
return;
}
body.innerHTML = rows
.map((submission) => {
const providerHtml = visibleProviderStateEntries(submission.providerState)
.map(
([provider, status]) => {
const fullLabel = `${providerLabels[provider] || provider} ${formatProviderStatus(status)}`;
return `<span class="provider-chip queue-provider-chip ${providerClass(status)}" title="${escapeHtml(fullLabel)}">${escapeHtml(formatQueueProviderStatus(provider, status))}</span>`;
},
)
.join("");
const reasonHtml = submission.reasons
.slice(0, 2)
.map((reason) => `<span title="${escapeHtml(formatReason(reason))}">${escapeHtml(formatReason(reason))}</span>`)
.join("");
const submittedTime = formatQueueTimestamp(submission.submittedAt);
return `
<tr class="queue-row ${submission.id === state.selectedCaseId ? "selected-row" : ""}" data-row-case="${escapeHtml(submission.id)}">
<td><input type="checkbox" aria-label="${escapeHtml(submission.id)} 선택" data-bulk-id="${escapeHtml(submission.id)}"></td>
<td><img class="thumb" src="${escapeHtml(submission.asset)}" alt="${escapeHtml(submission.title)} 썸네일"></td>
<td>
<div class="queue-submission-cell">
<button class="row-action queue-case-button" type="button" data-select-case="${escapeHtml(submission.id)}" title="${escapeHtml(submission.id)}">
<span class="submission-id">${escapeHtml(submission.id)}</span>
</button>
${duplicateBadgeHtml(submission)}
</div>
</td>
<td>
<div class="score-stack queue-risk-cell" title="${escapeHtml(submission.lastAnalysis)}">
<span class="risk-badge ${riskClass(submission.riskBand)}">${riskLabels[submission.riskBand]} ${submission.riskScore}</span>
</div>
</td>
<td><div class="reason-cell">${reasonHtml}</div></td>
<td><div class="provider-strip queue-provider-strip">${providerHtml}</div></td>
<td>${escapeHtml(submission.applicantStatus)}</td>
<td><span class="decision-badge">${decisionLabels[submission.decisionStatus]}</span></td>
<td>
<div class="queue-time-cell" title="${escapeHtml(submission.fileFacts.submitted)}">
<strong>${escapeHtml(submittedTime)}</strong>
</div>
</td>
</tr>
`;
})
.join("");
}
function renderImportStatus() {
const target = document.getElementById("submission-import-status");
if (!target) return;
const tone = state.importStatus.tone === "bad" ? "bad" : state.importStatus.tone === "warn" ? "warn" : "ok";
target.innerHTML = `
<span class="status-dot ${tone}" aria-hidden="true"></span>
<span>${escapeHtml(state.importStatus.message)}</span>
`;
}
function renderNoSelectedCase() {
document.getElementById("case-title").textContent = "선택된 제출 이미지가 없습니다";
document.getElementById("case-image").removeAttribute("src");
document.getElementById("case-image").alt = "";
document.getElementById("image-derivative-note").textContent = "API 연결 후 제출 이미지를 불러오면 이 영역에 분석 대상이 표시됩니다.";
document.getElementById("case-score").className = "score-pill risk-pending";
document.getElementById("case-score").textContent = "대기";
document.getElementById("floating-case-score").className = "score-pill risk-pending";
document.getElementById("floating-case-score").textContent = "대기";
document.getElementById("file-facts").innerHTML = "";
document.getElementById("similar-strip").innerHTML = "";
document.getElementById("face-crop-strip").innerHTML = "";
activeRerunAddedIds = new Set();
document.getElementById("rerun-diff-summary").innerHTML = "";
document.getElementById("case-reasons").innerHTML = `<div class="empty-state">표시할 케이스가 없습니다.</div>`;
document.getElementById("evidence-groups").innerHTML = `<div class="empty-state">API 연결 실패 또는 제출 이미지 없음 상태입니다.</div>`;
document.getElementById("recommendation-box").innerHTML = `<span class="muted">실제 API 데이터가 로드될 때까지 판정하지 않습니다.</span>`;
document.getElementById("derived-preview").innerHTML = `<span class="muted">자동 후보 생성 없음</span>`;
// Clear the evidence-gap recommendations too: they belong to the case that was
// just deselected, and leaving them rendered invited running suggested queries
// against nothing.
const nextActions = document.getElementById("evidence-next-actions");
if (nextActions) nextActions.innerHTML = "";
const llmDraft = document.getElementById("llm-draft");
if (llmDraft) {
llmDraft.hidden = true;
llmDraft.innerHTML = "";
}
}
function renderCaseReview() {
const submission = getSelectedCase();
if (!submission) {
renderNoSelectedCase();
return;
}
renderLlmDraft(submission);
document.getElementById("case-title").textContent = `${submission.id} · ${submission.title}`;
const caseImageElement = document.getElementById("case-image");
caseImageElement.src = safeUrl(submission.asset);
caseImageElement.alt = `${submission.id} 제출 이미지`;
// Reset the view controls (rotate / fit) so a new case starts unrotated.
caseImageElement.dataset.rotation = "0";
caseImageElement.style.transform = "";
caseImageElement.style.objectFit = "";
document.getElementById("image-derivative-note").textContent = submission.derivativeNote;
document.getElementById("case-score").className = `score-pill ${riskClass(submission.riskBand)}`;
document.getElementById("case-score").textContent = `${riskLabels[submission.riskBand]} ${submission.riskScore}`;
document.getElementById("floating-case-score").className = `score-pill ${riskClass(submission.riskBand)}`;
document.getElementById("floating-case-score").textContent = `${riskLabels[submission.riskBand]} ${submission.riskScore}`;
document.getElementById("file-facts").innerHTML = Object.entries(submission.fileFacts)
.map(([label, value]) => `<div class="fact-item"><span>${escapeHtml(formatFactLabel(label))}</span><strong>${escapeHtml(value)}</strong></div>`)
.join("");
document.getElementById("similar-strip").innerHTML = submission.similar
.map(
(item) => `
<div class="similar-item">
<img src="${escapeHtml(safeUrl(item.asset))}" alt="${escapeHtml(item.label)}">
<span>${escapeHtml(formatReason(item.label))}</span>
</div>
`,
)
.join("");
document.getElementById("face-crop-strip").innerHTML = (submission.faceCrops || [])
.map(
(crop) => `
<a class="similar-item face-crop-item face-crop-link" href="${escapeHtml(safeUrl(crop.url))}" target="_blank" rel="noreferrer" aria-label="얼굴 영역 ${escapeHtml(String(crop.index))} 크게 보기">
<img src="${escapeHtml(safeUrl(crop.url))}" alt="얼굴 영역 ${escapeHtml(String(crop.index))} 크롭">
<span>얼굴 영역 ${escapeHtml(String(crop.index))} · 동일 인물 판정은 아닙니다</span>
</a>
`,
)
.join("");
activeRerunAddedIds = new Set(submission.lastRerunDiff?.addedEvidenceIds || []);
document.getElementById("rerun-diff-summary").innerHTML = submission.lastRerunDiff
? renderRerunDiffSummary(submission.lastRerunDiff)
: "";
document.getElementById("case-reasons").innerHTML = renderEvidenceSummary(submission);
document.getElementById("evidence-next-actions").innerHTML = renderEvidenceNextActions(submission);
document.getElementById("evidence-groups").innerHTML = renderEvidenceGroups(submission);
document.getElementById("recommendation-box").innerHTML = `
<span class="risk-badge ${riskClass(submission.riskBand)}">${riskLabels[submission.riskBand]}</span>
<strong>${escapeHtml(submission.recommendation.label)}</strong>
<span class="muted">${escapeHtml(formatReason(submission.recommendation.detail))}</span>
<span class="muted small">현재 운영 결정: ${decisionLabels[submission.decisionStatus]}</span>
`;
document.getElementById("derived-preview").innerHTML = `
<strong>${submission.derivedPreview.automatic ? "자동 후보 생성 예정" : "자동 이력 없음"}</strong>
<span>${escapeHtml(submission.derivedPreview.entryName)}</span>
<span class="muted small">${escapeHtml(submission.derivedPreview.effect)}</span>
`;
}

View file

@ -1,310 +0,0 @@
// Part 4 of 15 of the operator console app script.
//
// Derived reads over state: selected case, risk/source classes, evidence filters.
//
// Load order is significant: these are classic (non-module) scripts sharing one
// global scope, and index.html must keep them in the order listed above/below.
// Splitting is a pure line-range extraction of the former single app.js.
function getSelectedCase() {
return submissions.find((item) => item.id === state.selectedCaseId) || submissions[0];
}
const RISK_BANDS = new Set(["low", "medium", "high", "failed", "pending"]);
function riskClass(riskBand) {
// Allowlist the band so a malformed/hostile server value can't break out of
// the class attribute it is interpolated into (these feed innerHTML).
return `risk-${RISK_BANDS.has(riskBand) ? riskBand : "pending"}`;
}
function sourceClass(source) {
if (source === "naver" || source === "naver_blog" || source === "naver_web") return "source-naver";
if (source === "google" || source === "google_search") return "source-google";
if (source === "llm") return "source-llm";
if (source === "failure") return "source-failure";
return "source-internal";
}
function providerClass(status) {
if (status === "ok" || status === "covered") return "ok";
if (status === "empty") return "skipped";
if (status === "failed") return "failed";
if (status === "disabled" || status === "skipped" || status === "not_run" || status === "pending") return "disabled";
return "skipped";
}
function sourceMatches(submission, source) {
if (source === "all") return true;
if (source === "covered") return submission.evidence.some((item) => item.contributed || item.confidence > 0);
if (source === "failure") {
return Object.values(submission.providerState).includes("failed") || submission.evidence.some((item) => item.source === "failure");
}
if (source === "face") {
return submission.evidence.some((item) => item.source === "face");
}
return submission.evidence.some((item) => item.source === source);
}
function searchableEvidenceItems(submission) {
return window.OperatorEvidenceGuidance.searchableEvidenceItems(submission);
}
function directEvidenceItems(submission) {
return window.OperatorEvidenceGuidance.directEvidenceItems(submission);
}
function hasSearchAttempt(submission) {
return window.OperatorEvidenceGuidance.hasSearchAttempt(submission);
}
function evidenceNeedsFollowup(submission) {
return window.OperatorEvidenceGuidance.evidenceNeedsFollowup(submission);
}
function evidenceFollowupReasons(submission) {
return window.OperatorEvidenceGuidance.evidenceFollowupReasons(submission);
}
function normalizedQuerySeed(submission) {
return window.OperatorEvidenceGuidance.normalizedQuerySeed(submission);
}
function suggestedEvidenceQueries(submission) {
return window.OperatorEvidenceGuidance.suggestedEvidenceQueries(submission);
}
function renderEvidenceNextActions(submission) {
const summary =
suggestedQueryRunSummary && suggestedQueryRunSummary.caseId === submission.id
? `<div class="inline-status" id="suggested-query-run-result">${escapeHtml(suggestedQueryRunSummary.message)}</div>`
: "";
if (!evidenceNeedsFollowup(submission)) return summary;
const queries = suggestedEvidenceQueries(submission);
const reasons = evidenceFollowupReasons(submission);
if (!queries.length) return summary;
return `
<section class="evidence-next-action-panel" aria-label="근거 보강 추천">
<div>
<strong>근거 보강 추천</strong>
<span>추천 쿼리를 바로 실행하거나, 쿼리 본문을 눌러 수동 검색 입력칸에서 수정할 수 있습니다.</span>
</div>
<ul class="evidence-followup-reasons">
${reasons.map((reason) => `<li>${escapeHtml(reason)}</li>`).join("")}
</ul>
<div class="suggested-query-list">
${queries
.map(
(query) => `
<span class="suggested-query-item">
<button class="row-action" type="button" data-suggested-query="${escapeHtml(query)}" data-suggested-provider="naver">
${escapeHtml(query)}
</button>
<button class="row-action" type="button" data-run-suggested-query="${escapeHtml(query)}">바로 실행</button>
</span>
`,
)
.join("")}
</div>
<div class="suggested-query-actions">
<button class="row-action" type="button" id="run-all-suggested-queries">모두 실행</button>
</div>
<div id="suggested-query-run-status" class="inline-status" aria-live="polite"></div>
${summary}
</section>
`;
}
function applySuggestedQuery(button) {
const queryInput = document.getElementById("manual-query");
const providerSelect = document.getElementById("manual-query-provider");
const status = document.getElementById("manual-query-status");
const provider = normalizeManualSearchProvider(button.dataset.suggestedProvider);
queryInput.value = button.dataset.suggestedQuery || "";
providerSelect.value = provider;
switchWorkbenchTab("queries");
queryInput.focus();
if (status) status.textContent = "추천 쿼리를 입력했습니다. 실행 버튼을 눌러 검색하세요.";
}
let isSuggestedQueryRunActive = false;
async function executeSuggestedQueries(queries) {
if (isSuggestedQueryRunActive) return;
const submission = getSelectedCase();
if (!submission || !queries.length) return;
const statusTarget = document.getElementById("suggested-query-run-status");
const naver = providers.find((provider) => provider.id === "naver");
if (!naver || !naver.enabled) {
if (statusTarget) statusTarget.textContent = `${providerLabels.naver} 외부 검색 tool이 비활성입니다.`;
return;
}
isSuggestedQueryRunActive = true;
try {
const results = [];
for (const [index, query] of queries.entries()) {
if (statusTarget) statusTarget.textContent = `"${query}" 실행 중… (${index + 1}/${queries.length})`;
try {
const review = await apiJson("/api/search/manual", {
method: "POST",
body: JSON.stringify({ submission_id: submission.id, provider: "naver", query }),
});
// queryHistory.count is the number of evidence rows the search produced,
// which includes the skip/failure/"no results" markers — so a query that
// was refused still reported count>=1 and was announced as "완료(근거 1건)"
// while the skip branch below could never run. Count real evidence here
// and let a failure row speak for itself.
const matching = (review.evidence || []).filter((item) => item.query === query);
const skipped = matching.find((item) => item.source === "failure");
const resultCount = matching.filter(
(item) =>
item.source !== "failure" &&
!/returned no results/i.test(String(item.title || "")),
).length;
if (skipped) {
results.push(`"${query}" ${formatReason(skipped.title || "건너뜀")}`);
} else if (resultCount === 0) {
results.push(`"${query}" 결과 없음`);
} else {
results.push(`"${query}" 완료(근거 ${resultCount}건)`);
}
} catch (errorValue) {
results.push(`"${query}" 실패: ${errorValue.message}`);
}
}
suggestedQueryRunSummary = {
caseId: submission.id,
message: `추천 쿼리 실행 — ${results.join(" · ")}`,
};
try {
await refreshFromApi();
} catch (refreshError) {
// Don't let a refresh failure become an unhandled rejection that leaves
// the status stuck at "실행 중…". Re-resolve the element (a render may have
// replaced it) and report honestly alongside the collected results.
const liveStatus = document.getElementById("suggested-query-run-status");
if (liveStatus) {
liveStatus.textContent = `새로고침 실패: ${refreshError.message} · 실행 결과: ${results.join(" · ")}`;
}
}
} finally {
isSuggestedQueryRunActive = false;
}
}
function filteredSubmissions() {
const query = state.filters.query.trim().toLowerCase();
const filtered = submissions.filter((submission) => {
const riskOk = state.filters.risk === "all" || submission.riskBand === state.filters.risk;
const sourceOk = sourceMatches(submission, state.filters.source);
const decisionOk = state.filters.decision === "all" || submission.decisionStatus === state.filters.decision;
const queryOk =
!query ||
submission.id.toLowerCase().includes(query) ||
submission.title.toLowerCase().includes(query) ||
submission.reasons.join(" ").toLowerCase().includes(query);
return riskOk && sourceOk && decisionOk && queryOk;
});
return filtered.sort((left, right) => {
if (state.filters.sort === "newest") return right.submittedEpoch - left.submittedEpoch;
if (state.filters.sort === "oldest") return left.submittedEpoch - right.submittedEpoch;
if (state.filters.sort === "analysis_failure") {
return Number(right.riskBand === "failed") - Number(left.riskBand === "failed");
}
if (state.filters.sort === "provider_failure") {
return Number(sourceMatches(right, "failure")) - Number(sourceMatches(left, "failure"));
}
return right.riskScore - left.riskScore;
});
}

View file

@ -1,137 +0,0 @@
// Part 1 of 15 of the operator console app script.
//
// Label imports and the in-memory client state every other part reads.
//
// Load order is significant: these are classic (non-module) scripts sharing one
// global scope, and index.html must keep them in the order listed above/below.
// Splitting is a pure line-range extraction of the former single app.js.
const {
riskLabels,
decisionLabels,
providerLabels,
sourceLabels,
providerStatusLabels,
candidateStatusLabels,
evidenceStatusLabels,
knowledgeStatusLabels,
provenanceLabels,
knowledgeTypeLabels,
} = window.OperatorLabels;
const retiredProviderIds = new Set();
const operatorSearchProviders = [
{ id: "naver", label: providerLabels.naver },
{ id: "google_search", label: providerLabels.google_search },
];
const submissions = [];
const providers = [];
const knowledgeEntries = [];
const searchCoverage = {};
let suggestedQueryRunSummary = null; // { caseId, message } — 현재 케이스의 마지막 추천 쿼리 실행 결과
const knowledgeFilters = { query: "", type: "all", status: "all", active: "all" };
let editingKnowledgeEntryId = "";
let activeRerunAddedIds = new Set(); // 현재 렌더 중인 케이스의 lastRerunDiff.addedEvidenceIds
const DEFAULT_COVERAGE_THRESHOLDS = Object.freeze({
coverageGoodRate: 70,
coverageWarnRate: 40,
queryGoodRate: 70,
queryWarnRate: 40,
});
const coverageThresholds = { ...DEFAULT_COVERAGE_THRESHOLDS };
const collectionCandidates = [];
const corrections = [];
const auditEvents = [];
const decisionMemoPresets = [];
const state = {
currentView: "queue",
workbenchTab: "evidence",
knowledgeTab: "collect",
currentCollectionQuery: "",
currentCollectionProvider: "",
selectedCaseId: submissions[0]?.id || "",
apiError: "",
submissionQueue: null,
importStatus: {
tone: "idle",
message: "로컬 제출 저장소 변경 대기 중",
},
filters: {
risk: "all",
source: "all",
decision: "all",
sort: "risk",
query: "",
},
};
const viewNames = new Set(["queue", "workbench", "knowledge", "providers", "stats", "audit"]);
// Bearer token for a server started with COPYRIGHTER_AUTH_TOKEN. Held in memory
// only (never localStorage/sessionStorage): the first authenticated request makes
// the server mint an HttpOnly session cookie, which is what subsequently
// authorizes <img>/media requests that cannot carry a header.
let authToken = "";
let pendingAuthPrompt = null;

View file

@ -1,291 +0,0 @@
// Part 11 of 15 of the operator console app script.
//
// Decision statistics and calibration rendering.
//
// Load order is significant: these are classic (non-module) scripts sharing one
// global scope, and index.html must keep them in the order listed above/below.
// Splitting is a pure line-range extraction of the former single app.js.
// --- Decision statistics / calibration ----------------------------------
const DECISION_STAT_LABELS = {
approved: "승인",
held: "보류",
rejected: "반려",
corrected: "보정",
unreviewed: "미심사",
};
const BAND_STAT_LABELS = {
high: "고위험",
medium: "중위험",
low: "저위험",
failed: "분석실패",
pending: "대기",
};
async function loadDecisionStats() {
const body = document.getElementById("stats-body");
if (!body) return;
body.innerHTML = `<div class="empty-state">통계를 불러오는 중…</div>`;
try {
const stats = await apiJson("/api/stats/decisions");
renderDecisionStats(stats);
} catch (errorValue) {
body.innerHTML = `<div class="empty-state">통계를 불러오지 못했습니다: ${escapeHtml(errorValue.message)}</div>`;
}
}
function calibrationTone(rate, decided) {
// Not enough decisions to judge -> neutral, don't cry miscalibration on n=1.
if (decided < 5) return "neutral";
if (rate >= 0.4) return "bad";
if (rate >= 0.2) return "warn";
return "good";
}
function renderDecisionStats(stats) {
const body = document.getElementById("stats-body");
if (!body) return;
const cal = stats.calibration || {};
const throughput = stats.throughput || { byDay: [] };
const backlog = stats.backlog || { unreviewed: 0, total: 0 };
const overTone = calibrationTone(cal.highBandApprovalRate || 0, cal.highBandDecided || 0);
const underTone = calibrationTone(cal.lowBandRejectionRate || 0, cal.lowBandDecided || 0);
const tiles = `
<div class="stat-tiles">
<article class="stat-tile tone-${overTone}">
<span class="stat-tile-label">과탐 — 고위험인데 승인</span>
<strong class="stat-tile-value">${formatPercent(cal.highBandApprovalRate)}</strong>
<span class="stat-tile-sub">고위험 판정 ${cal.highBandDecided || 0}건 기준</span>
</article>
<article class="stat-tile tone-${underTone}">
<span class="stat-tile-label">미탐 — 저위험인데 반려</span>
<strong class="stat-tile-value">${formatPercent(cal.lowBandRejectionRate)}</strong>
<span class="stat-tile-sub">저위험 판정 ${cal.lowBandDecided || 0}건 기준</span>
</article>
<article class="stat-tile">
<span class="stat-tile-label">미심사 백로그</span>
<strong class="stat-tile-value">${backlog.unreviewed}</strong>
<span class="stat-tile-sub">전체 ${backlog.total}건 중</span>
</article>
<article class="stat-tile">
<span class="stat-tile-label">누적 판정</span>
<strong class="stat-tile-value">${throughput.totalDecisions || 0}</strong>
<span class="stat-tile-sub">감사 로그 기준</span>
</article>
</div>
`;
body.innerHTML = `
${tiles}
<div class="stats-columns">
<section class="stats-card">
<h2>밴드 × 판정 매트릭스</h2>
<p class="muted small">각 위험 밴드가 실제로 어떻게 판정됐는지. 대각선을 벗어난 값이 보정 오차입니다.</p>
${renderStatsMatrix(stats)}
</section>
<section class="stats-card">
<h2>최근 결정 추이</h2>
<p class="muted small">최근 14일 일별 판정 건수.</p>
${renderThroughputBars(throughput.byDay || [])}
</section>
</div>
`;
}
function renderStatsMatrix(stats) {
const bands = stats.bands || [];
const decisions = stats.decisions || [];
const matrix = stats.matrix || {};
const head = decisions.map((d) => `<th scope="col">${escapeHtml(DECISION_STAT_LABELS[d] || d)}</th>`).join("");
const rows = bands
.map((band) => {
const row = matrix[band] || {};
const total = decisions.reduce((sum, d) => sum + (row[d] || 0), 0);
const cells = decisions
.map((d) => {
const value = row[d] || 0;
const strong = value > 0 ? " has-value" : "";
return `<td class="matrix-cell${strong}">${value}</td>`;
})
.join("");
return `<tr><th scope="row"><span class="risk-badge ${riskClass(band)}">${escapeHtml(BAND_STAT_LABELS[band] || band)}</span></th>${cells}<td class="matrix-total">${total}</td></tr>`;
})
.join("");
return `
<div class="table-shell">
<table class="stats-matrix">
<thead><tr><th scope="col">밴드 \\ 판정</th>${head}<th scope="col">합계</th></tr></thead>
<tbody>${rows}</tbody>
</table>
</div>
`;
}
function renderThroughputBars(byDay) {
if (!byDay.length) {
return `<div class="empty-state">아직 기록된 판정이 없습니다.</div>`;
}
const max = Math.max(...byDay.map((entry) => entry.count), 1);
const bars = byDay
.map((entry) => {
const height = Math.round((entry.count / max) * 100);
return `
<div class="throughput-bar" title="${escapeHtml(entry.day)} · ${entry.count}건">
<div class="throughput-bar-fill" style="height:${height}%"></div>
<span class="throughput-bar-count">${entry.count}</span>
<span class="throughput-bar-day">${escapeHtml(entry.day.slice(5))}</span>
</div>
`;
})
.join("");
return `<div class="throughput-chart" role="img" aria-label="일별 판정 건수">${bars}</div>`;
}
function formatPercent(rate) {
return `${Math.round((Number(rate) || 0) * 100)}%`;
}

View file

@ -1,382 +0,0 @@
// Part 12 of 15 of the operator console app script.
//
// Submission reload/import/upload and knowledge entry creation.
//
// Load order is significant: these are classic (non-module) scripts sharing one
// global scope, and index.html must keep them in the order listed above/below.
// Splitting is a pure line-range extraction of the former single app.js.
async function rerunSelectedEnrichment() {
const ids = selectedBulkSubmissionIds();
const button = document.getElementById("bulk-rerun");
if (!ids.length) {
setImportStatus("재분석할 제출이 없습니다.", "bad");
return;
}
if (button) button.disabled = true;
setImportStatus(`${ids.length}건 재분석을 시작합니다.`, "warn");
try {
for (const [index, submissionId] of ids.entries()) {
setImportStatus(`${index + 1}/${ids.length} 재분석 중 · ${submissionId}`, "warn");
await apiJson(`/api/submissions/${encodeURIComponent(submissionId)}/rerun-enrichment`, {
method: "POST",
body: JSON.stringify({}),
});
}
state.selectedCaseId = ids[0];
await refreshFromApi();
setImportStatus(`${ids.length}건 재분석 완료`, "ok");
} catch (errorValue) {
// Best-effort refresh so already-rerun submissions aren't left stale in the
// UI when the loop fails partway through.
try { await refreshFromApi(); } catch (_refreshError) { /* keep original error */ }
setImportStatus(errorValue.message, "bad");
} finally {
if (button) button.disabled = false;
}
}
function resetQueueFiltersForImport() {
state.filters.risk = "all";
state.filters.source = "all";
state.filters.decision = "all";
state.filters.sort = "newest";
state.filters.query = "";
document.querySelectorAll("#risk-filter button").forEach((button) => {
button.classList.toggle("active", button.dataset.risk === "all");
});
document.getElementById("source-filter").value = "all";
document.getElementById("decision-filter").value = "all";
document.getElementById("queue-sort").value = "newest";
document.getElementById("queue-search").value = "";
}
function importedFolderStatusMessage(payload, fallbackLabel) {
return window.OperatorSubmissionImport.importedFolderStatusMessage(payload, fallbackLabel);
}
function importedSubmissionStatusMessage(payload, submissionId) {
return window.OperatorSubmissionImport.importedSubmissionStatusMessage(payload, submissionId);
}
async function reloadSubmissions() {
const button = document.getElementById("reload-submissions");
const folderInput = document.getElementById("submission-folder");
const folderPath = (folderInput?.value || "").trim();
const endpoint = folderPath ? "/api/submissions/import-folder" : "/api/submissions/reload";
const body = folderPath ? { path: folderPath } : {};
button.disabled = true;
setImportStatus("제출 폴더를 읽는 중입니다.", "warn");
try {
const payload = await apiJson(endpoint, {
method: "POST",
body: JSON.stringify(body),
});
// Snapshot the pre-reload ids BEFORE applyBootstrap replaces `submissions`,
// otherwise the imported-diff below always compares against the new list and
// is always empty (no new case is ever auto-selected).
const existingIds = new Set(submissions.map((submission) => submission.id));
applyBootstrap(payload);
if (folderPath) {
folderInput.value = "";
const importedFolderSubmissions = payload.submissions || [];
if (importedFolderSubmissions[0]) {
state.selectedCaseId = importedFolderSubmissions[0].id;
resetQueueFiltersForImport();
setImportStatus(importedFolderStatusMessage(payload, folderPath), "ok");
} else {
setImportStatus("추가된 제출 없음", "idle");
}
} else {
const importedSubmissions = (payload.submissions || []).filter((submission) => !existingIds.has(submission.id));
if (importedSubmissions.length) {
state.selectedCaseId = importedSubmissions[0].id;
resetQueueFiltersForImport();
setImportStatus(importedSubmissionStatusMessage(payload, importedSubmissions[0].id), "ok");
} else {
setImportStatus("추가된 제출 없음", "idle");
}
}
renderAll();
} catch (errorValue) {
setImportStatus(errorValue.message, "bad");
} finally {
button.disabled = false;
}
}
function updateSubmissionImageName() {
const input = document.getElementById("submission-image");
const target = document.getElementById("submission-image-name");
if (!input || !target) return;
target.textContent = input.files[0]?.name || "선택된 파일 없음";
}
async function uploadSubmissionImage() {
const input = document.getElementById("submission-image");
const button = document.getElementById("upload-submission-image");
const file = input?.files?.[0];
if (!file) {
setImportStatus("추가할 사진을 선택하세요.", "bad");
return;
}
button.disabled = true;
setImportStatus("사진을 현재 제출 폴더에 넣는 중입니다.", "warn");
try {
const image = await readKnowledgeImage(file);
const payload = await apiJson("/api/submissions/upload-image", {
method: "POST",
body: JSON.stringify({ image }),
});
applyBootstrap(payload);
input.value = "";
updateSubmissionImageName();
if (payload.uploadedSubmissionId) {
state.selectedCaseId = payload.uploadedSubmissionId;
resetQueueFiltersForImport();
switchView("workbench");
switchWorkbenchTab("evidence");
}
setImportStatus(`${payload.uploadedSubmissionId || file.name} 사진이 추가되었습니다. 새 심사 건으로 바로 선택했습니다.`, "ok");
renderAll();
} catch (errorValue) {
setImportStatus(errorValue.message, "bad");
} finally {
button.disabled = false;
}
}
async function readKnowledgeImage(file) {
return window.OperatorSubmissionImport.fileToImagePayload(file);
}
function updateKnowledgeImageName() {
const input = document.getElementById("knowledge-image");
const target = document.getElementById("knowledge-image-name");
if (!input || !target) return;
target.textContent = input.files[0]?.name || "선택된 파일 없음";
}
async function addKnowledgeEntry(event) {
event.preventDefault();
const name = document.getElementById("knowledge-name").value.trim();
const type = document.getElementById("knowledge-type").value;
const aliases = document
.getElementById("knowledge-aliases")
.value.split(",")
.map((item) => item.trim())
.filter(Boolean);
const keywords = document
.getElementById("knowledge-keywords")
.value.split(",")
.map((item) => item.trim())
.filter(Boolean);
const memo = document.getElementById("knowledge-memo").value.trim();
const imageInput = document.getElementById("knowledge-image");
const status = document.getElementById("knowledge-entry-status");
const submitButton = event.target.querySelector('button[type="submit"]');
if (!name || !memo) return;
submitButton.disabled = true;
status.textContent = "기준 DB에 저장하는 중입니다.";
try {
const image = await readKnowledgeImage(imageInput.files[0]);
const payload = await apiJson("/api/knowledge/manual", {
method: "POST",
body: JSON.stringify({ name, type, aliases, keywords, memo, image }),
});
applyBootstrap(payload);
event.target.reset();
updateKnowledgeImageName();
status.textContent = image ? "참조 이미지와 이미지 지문을 저장했습니다." : "기준 DB에 저장했습니다.";
renderAll();
} catch (errorValue) {
status.textContent = errorValue.message;
} finally {
submitButton.disabled = false;
}
}

5428
web/operator-gui/app.js Normal file

File diff suppressed because it is too large Load diff

View file

@ -10,28 +10,7 @@
<script src="submission-import.js" defer></script> <script src="submission-import.js" defer></script>
<script src="evidence-guidance.js" defer></script> <script src="evidence-guidance.js" defer></script>
<script src="operator-search.js" defer></script> <script src="operator-search.js" defer></script>
<!-- <script src="app.js" defer></script>
The former single app.js, split by concern. These are classic scripts
sharing one global scope, so THIS ORDER IS LOAD-BEARING: state before the
code that reads it, and app-bootstrap.js last because it is the only part
that runs anything at load (event binding + DOMContentLoaded). A static
test pins this exact sequence.
-->
<script src="app-state.js" defer></script>
<script src="app-api.js" defer></script>
<script src="app-format.js" defer></script>
<script src="app-selectors.js" defer></script>
<script src="app-render-queue.js" defer></script>
<script src="app-render-evidence.js" defer></script>
<script src="app-render-panels.js" defer></script>
<script src="app-navigation.js" defer></script>
<script src="app-keyboard-batch.js" defer></script>
<script src="app-decision-tools.js" defer></script>
<script src="app-stats.js" defer></script>
<script src="app-submissions.js" defer></script>
<script src="app-collections.js" defer></script>
<script src="app-admin.js" defer></script>
<script src="app-bootstrap.js" defer></script>
</head> </head>
<body data-internal-only="true"> <body data-internal-only="true">
<div class="auth-gate" id="auth-gate" hidden role="dialog" aria-modal="true" aria-labelledby="auth-gate-title"> <div class="auth-gate" id="auth-gate" hidden role="dialog" aria-modal="true" aria-labelledby="auth-gate-title">