Compare commits
5 commits
fa00f382e2
...
76a06f2ff1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
76a06f2ff1 | ||
|
|
60903ec1f7 | ||
|
|
278372d171 | ||
|
|
fc4e04f65c | ||
|
|
bc14cd2177 |
39 changed files with 6623 additions and 5504 deletions
36
CLAUDE.md
Normal file
36
CLAUDE.md
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
# 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` 아님).
|
||||
27
README.md
27
README.md
|
|
@ -47,6 +47,8 @@ python run_copyrighter_server.py --host 127.0.0.1 --port 9500
|
|||
기본값으로 실행하면 `http://127.0.0.1:9500` 에서 운영자 콘솔이 열립니다.
|
||||
|
||||
> 보안: `COPYRIGHTER_AUTH_TOKEN`이 비어 있으면 데이터 라우트가 **인증 없이** 열립니다. 단일 호스트 개발 환경 외에는 반드시 토큰을 설정하세요.
|
||||
>
|
||||
> 토큰을 설정하면 **틀린 토큰을 제시한 시도**는 클라이언트 주소별로 60초당 10회로 제한되고, 초과 시 `429`와 `Retry-After`를 반환합니다. 토큰을 아직 입력하지 않은 요청(콘솔 최초 로드)은 시도로 세지 않으므로 정상 운영자가 잠기지 않습니다.
|
||||
|
||||
## 프로젝트 구조
|
||||
|
||||
|
|
@ -55,17 +57,32 @@ src/rights_filter/ 분석·서버·통합·도메인 로직 (Python)
|
|||
server/ http.server 기반 API + 정적 파일 서빙, SQLite 저장소
|
||||
analysis/ 이미지 분석·위험도 산출
|
||||
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 테스트
|
||||
operator_gui/ 운영자 GUI 정적 검증 + Playwright E2E
|
||||
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
|
||||
# 전체
|
||||
python -m pytest
|
||||
python -m pytest # 또는 그냥 pytest — pytest.ini 가 경로를 설정합니다
|
||||
python -m ruff check src tests # 설정: ruff.toml
|
||||
|
||||
# 운영자 GUI E2E (Playwright)
|
||||
playwright install chromium # 빌드 머신에서 1회 — 브라우저 바이너리는 오프라인 번들에 포함
|
||||
|
|
@ -77,6 +94,8 @@ python -m pytest tests/operator_gui/
|
|||
|
||||
## 문서
|
||||
|
||||
- [프로젝트 구조 지도 (PROJECT_MAP)](docs/PROJECT_MAP.md) — 파일·라우트·스키마 위치, 로드 순서, 알려진 부채
|
||||
- [작업 이력 (INDEX)](docs/INDEX.md)
|
||||
- [프로젝트 소개 및 기술 구현](docs/project-introduction-and-technical-implementation.md)
|
||||
- [운영 워크리스트](docs/operations/copyrighter-operation-worklist.md)
|
||||
- [오프라인 설치 절차](docs/operations/offline-install.md)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
# 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-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**
|
||||
|
|
|
|||
177
docs/PROJECT_MAP.md
Normal file
177
docs/PROJECT_MAP.md
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
# 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) | 각 항목에 연기 근거 기재 |
|
||||
|
|
@ -3,6 +3,11 @@
|
|||
> 기준 리뷰: `docs/reviews/2026-07-11-project-review.md` (HEAD 89c1fe4)
|
||||
> 신규 HIGH 9 + 유닛 추가 HIGH 3(통합/프론트) + MEDIUM ~28 + LOW ~14.
|
||||
> 배치는 risk-per-effort 순. 각 배치 후 `pytest tests/rights_filter -q` 통과 확인.
|
||||
>
|
||||
> **2026-08-08 재확인**: 미체크로 남아 있던 항목을 코드와 대조했다. 6건은 이미 수정돼 있었고
|
||||
> (체크리스트가 낡았던 것 — 각 항목에 확인 근거 기재), 보안 2건(#8 rate limit, SSRF
|
||||
> DNS-rebinding TOCTOU)은 실제로 열려 있어 이번에 수정했다. 남은 미체크는 전부 **명시적 연기**
|
||||
> 이며 각 줄에 근거가 있다.
|
||||
|
||||
## Batch 1 — 스코어링 파이프라인 복구 (최우선) ✅ 완료
|
||||
직렬화 라운드트립 필드 손실 1개 패턴이 HIGH 3건을 만듦. 프로모터 3건은 배포 즉시 노출되므로 동일 배치.
|
||||
|
|
@ -46,8 +51,8 @@
|
|||
- [x] LOW `store_remote_fetch.py` — SSRF `not is_global`(CGNAT 등 특수대역 차단)
|
||||
- [x] LOW `env_clients.py` — NAVER display 3종 클램프(1~100)
|
||||
- [x] M `env_clients.py` — daily-limit malformed → 보수적 100(fail-open 제거) + 테스트 갱신
|
||||
- [ ] M `naver_search.py` — 멀티페이지 쿼터 실제 요청 수 (미착수)
|
||||
- [ ] M `store_remote_fetch.py` — SSRF DNS-rebinding TOCTOU (미착수 — 커스텀 opener 필요)
|
||||
- [x] M `naver_search.py` — 멀티페이지 쿼터 실제 요청 수 (2026-08-08 확인: `_requested_calls` + `try_reserve(requested_calls=…)`/`release` 로 이미 구현돼 있었음. 항목이 낡았던 것)
|
||||
- [x] M `store_remote_fetch.py` — SSRF DNS-rebinding TOCTOU (2026-08-08 수정: `_guarded_create_connection` 이 getaddrinfo 를 **1회만** 수행하고 그 답변의 모든 주소를 검증한 뒤 그중 하나로만 연결. `_Guarded*Connection`/`*Handler` 로 opener 에 배선 — 사전검사와 실제 다이얼의 해석이 갈라지지 않는다. 리바인딩 회귀 테스트 포함 14건)
|
||||
- [ ] LOW: EUC-KR 스니핑, dead code 삭제(레이아웃 테스트가 고정 → 유지)
|
||||
|
||||
## Batch 5 — HTTP 보안 ✅ (M10/M11/#6)
|
||||
|
|
@ -56,8 +61,8 @@
|
|||
- [x] #6 앱셸 CSP/X-Frame-Options/nosniff + 테스트
|
||||
- [x] #8(부분) 토큰 길이<32 경고 + static 부재 경고(`__main__`)
|
||||
- [ ] #7 쿠키 분리+Secure (연기 — Secure는 localhost HTTP서 쿠키 미전송→인증 붕괴; HTTPS 전제 필요)
|
||||
- [ ] #8 rate limit (미착수)
|
||||
- [ ] #10 Origin/Referer CSRF 폴백 (미착수)
|
||||
- [x] #8 rate limit (2026-08-08 수정: `server/auth_rate_limit.py` — **틀린 자격증명을 제시한** 시도만 클라이언트 주소별 고정윈도우 10회/60초로 제한, 초과 시 `429` + `Retry-After`. 성공하면 카운터 초기화. 토큰 미입력 요청(콘솔 최초 로드가 여러 건 발생시킴)은 세지 않으므로 정상 운영자가 잠기지 않는다. 테이블은 1024 클라이언트로 상한. 테스트 12건)
|
||||
- [x] #10 Origin/Referer CSRF 폴백 (2026-08-08 확인: `_origin_ok()` 로 이미 구현돼 있었음 — 7/24 리뷰가 지적한 낡은 항목)
|
||||
|
||||
## Batch 6 — 프론트엔드 ✅
|
||||
- [x] H8 이미지 뷰어 버튼 구현 + 케이스별 리셋
|
||||
|
|
@ -92,16 +97,22 @@
|
|||
- [ ] #20 ScoreResult.reasons tuple (연기 — 테스트 다수 `== []` 단언 충돌)
|
||||
|
||||
## Batch 7 — env/config/docs
|
||||
- [ ] `env_file.py:12` — UTF-8-sig BOM
|
||||
- [ ] daily-limit fail-open → 보수적 기본
|
||||
- [ ] `env_clients.py:239` — LLM assistant OLLAMA_* 게이팅
|
||||
- [ ] `__main__.py` — 경로 ROOT 앵커링 + static 부재 경고
|
||||
- [x] `env_file.py:12` — UTF-8-sig BOM (2026-08-08 확인: `read_text(encoding="utf-8-sig")` 로 이미 적용됨)
|
||||
- [x] daily-limit fail-open → 보수적 기본 (2026-08-08 확인: `_daily_limit()` 이 malformed 값에 warning + 100 적용. Batch 4 의 동일 항목과 중복 기재였음)
|
||||
- [ ] `env_clients.py:239` — LLM assistant OLLAMA_* 게이팅 (연기 — env={} 로 LLM 을 기대하는 테스트 다수가 의존)
|
||||
- [x] `__main__.py` — 경로 ROOT 앵커링 + static 부재 경고 (2026-08-08 확인: `6ab6da3` 에서 적용됨)
|
||||
- [ ] offline-install.md — Ollama/Playwright/lock 해시
|
||||
- [ ] python 3.13 가드, .gitignore, package.json 정리
|
||||
|
||||
## Batch 8 — 에러핸들링/기타 백로그
|
||||
- [ ] #14 무음 예외 로깅
|
||||
- [ ] #16 skip 사유 reasons 오염
|
||||
- [ ] #19 EXIF/GPS PIL 폴백 경고
|
||||
- [ ] #20 ScoreResult.reasons tuple
|
||||
- [ ] #18 dedup 핫패스 타깃 쿼리
|
||||
|
||||
> **2026-08-08: 이 배치는 Batch 6 과 중복된 착수 전 계획 목록이었다.** #14·#16·#18·#19 는
|
||||
> Batch 6 에서 이미 처리돼 [x] 로 기재돼 있고, #20 은 같은 곳에 연기 근거와 함께 남아 있다.
|
||||
> 여기서 중복 추적하지 않는다 — 상태는 Batch 6 이 기준.
|
||||
|
||||
- [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 의 연기 항목과 동일 건
|
||||
|
|
|
|||
15
pytest.ini
Normal file
15
pytest.ini
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
[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
|
||||
|
|
@ -3,6 +3,9 @@
|
|||
pytest==8.4.2
|
||||
pytest-cov==6.0.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:
|
||||
# on the build/staging host run `playwright install chromium`, then bundle the
|
||||
# browser cache (PLAYWRIGHT_BROWSERS_PATH) into the offline artifact for the
|
||||
|
|
|
|||
10
ruff.toml
Normal file
10
ruff.toml
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
# 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"]
|
||||
|
|
@ -5,8 +5,6 @@ from dataclasses import dataclass
|
|||
from datetime import datetime
|
||||
from typing import Any, Mapping
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from rights_filter.analysis.llm_assistance import (
|
||||
DEFAULT_MODEL,
|
||||
InternalLlmAssistant,
|
||||
|
|
@ -24,6 +22,8 @@ from rights_filter.integrations.google_custom_search import (
|
|||
from rights_filter.integrations.naver_search import NaverOpenApiSearchClient, NaverSearchAdapter
|
||||
from rights_filter.integrations.search_policy import SearchApiPolicy
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Chosen by measurement against real stored evidence (54 rows, 12 selected) on
|
||||
# the GPU target, not by parameter count:
|
||||
# qwen2.5:0.5b-instruct 55.7s — echoed the shape example as its claim text
|
||||
|
|
|
|||
107
src/rights_filter/server/auth_rate_limit.py
Normal file
107
src/rights_filter/server/auth_rate_limit.py
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
"""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"]
|
||||
|
|
@ -13,6 +13,7 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|||
from pathlib import Path
|
||||
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.sqlite_store import CopyrighterStore
|
||||
|
||||
|
|
@ -86,6 +87,9 @@ def build_server(
|
|||
auth_token: str | None = None,
|
||||
) -> ThreadingHTTPServer:
|
||||
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):
|
||||
server_version = "CopyrighterHTTP/0.1"
|
||||
|
|
@ -335,6 +339,15 @@ def build_server(
|
|||
# routes are protected.
|
||||
if not any(path.startswith(prefix) for prefix in _PROTECTED_PREFIXES):
|
||||
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", "")
|
||||
# Compare as bytes: hmac.compare_digest raises TypeError on str inputs
|
||||
# containing non-ASCII, and that exception escapes before any response
|
||||
|
|
@ -347,12 +360,15 @@ def build_server(
|
|||
# cookie so the browser can load <img>/media (which cannot send an
|
||||
# Authorization header) without ever putting the token in a URL.
|
||||
self._issue_session_cookie = True
|
||||
auth_rate_limiter.record_success(client)
|
||||
return True
|
||||
cookie_header = self.headers.get("Cookie", "")
|
||||
presented_session_cookie = False
|
||||
if cookie_header:
|
||||
jar = SimpleCookie()
|
||||
jar.load(cookie_header)
|
||||
morsel = jar.get(_SESSION_COOKIE)
|
||||
presented_session_cookie = morsel is not None
|
||||
if morsel is not None and hmac.compare_digest(
|
||||
morsel.value.encode("utf-8", "replace"),
|
||||
_session_tag(auth_token).encode("utf-8"),
|
||||
|
|
@ -362,7 +378,16 @@ def build_server(
|
|||
if self.command in _STATE_CHANGING_METHODS and not self._origin_ok():
|
||||
self._json({"error": "invalid origin"}, HTTPStatus.FORBIDDEN)
|
||||
return False
|
||||
auth_rate_limiter.record_success(client)
|
||||
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)
|
||||
return False
|
||||
|
||||
|
|
@ -416,11 +441,18 @@ def build_server(
|
|||
f"{_SESSION_COOKIE}={_session_tag(auth_token)}; HttpOnly; SameSite=Strict; Path=/",
|
||||
)
|
||||
|
||||
def _json(self, payload: object, status: HTTPStatus = HTTPStatus.OK) -> None:
|
||||
def _json(
|
||||
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")
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
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.end_headers()
|
||||
self.wfile.write(data)
|
||||
|
|
|
|||
|
|
@ -15,8 +15,6 @@ from dataclasses import replace
|
|||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from rights_filter.analysis.face_person_detection import (
|
||||
HeuristicFacePersonDetector,
|
||||
face_person_evidence_data,
|
||||
|
|
@ -48,6 +46,8 @@ from rights_filter.server.store_serialization import (
|
|||
_query_history_status,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class StoreEnrichmentMixin:
|
||||
def _sync_provider_payloads(self) -> None:
|
||||
|
|
|
|||
|
|
@ -9,8 +9,9 @@ from __future__ import annotations
|
|||
|
||||
import ipaddress
|
||||
import socket
|
||||
from http.client import HTTPConnection, HTTPSConnection
|
||||
from urllib.parse import urlparse
|
||||
from urllib.request import HTTPRedirectHandler, Request, build_opener
|
||||
from urllib.request import HTTPHandler, HTTPRedirectHandler, HTTPSHandler, Request, build_opener
|
||||
|
||||
from rights_filter.server.store_url_utils import _is_http_url
|
||||
|
||||
|
|
@ -63,11 +64,23 @@ 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)
|
||||
|
||||
|
||||
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:
|
||||
# SSRF guard: candidate image / page / stylesheet URLs come from external
|
||||
# search-result content (attacker-influenceable), so refuse to fetch
|
||||
# anything that resolves to a non-public address (loopback, RFC1918,
|
||||
# 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)
|
||||
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
|
||||
raise ValueError("only public http(s) URLs may be fetched")
|
||||
|
|
@ -77,12 +90,76 @@ def _assert_public_http_url(url: str) -> None:
|
|||
except OSError as exc:
|
||||
raise ValueError("could not resolve fetch host") from exc
|
||||
for *_, sockaddr in infos:
|
||||
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
|
||||
# missed — and only lets genuinely globally-routable addresses through.
|
||||
if not ip.is_global:
|
||||
raise ValueError("refusing to fetch internal address")
|
||||
_assert_public_ip(sockaddr[0])
|
||||
|
||||
|
||||
def _guarded_create_connection(address, timeout=None, source_address=None):
|
||||
"""``socket.create_connection`` that vets the address it actually dials.
|
||||
|
||||
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):
|
||||
|
|
@ -93,7 +170,11 @@ class _ValidatingRedirectHandler(HTTPRedirectHandler):
|
|||
return super().redirect_request(req, fp, code, msg, headers, newurl)
|
||||
|
||||
|
||||
_SSRF_SAFE_OPENER = build_opener(_ValidatingRedirectHandler())
|
||||
_SSRF_SAFE_OPENER = build_opener(
|
||||
_GuardedHTTPHandler(),
|
||||
_GuardedHTTPSHandler(),
|
||||
_ValidatingRedirectHandler(),
|
||||
)
|
||||
|
||||
|
||||
def _open_url(request: Request, timeout: int):
|
||||
|
|
|
|||
|
|
@ -20,6 +20,12 @@ SUGGESTED_QUERY_LOADED_STATUS = "추천 쿼리를 입력했습니다. 실행 버
|
|||
# Client-side guard message when a reject/correct decision is missing its memo.
|
||||
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:
|
||||
"""Drives the operator GUI for browser-level (E2E) assertions."""
|
||||
|
|
@ -123,7 +129,9 @@ class OperatorWorkbench:
|
|||
self.page.get_by_role("button", name="사진 넣기").click()
|
||||
return self
|
||||
|
||||
def upload_and_open(self, file_path: str, case_id: str, timeout: int = 10000) -> "OperatorWorkbench":
|
||||
def upload_and_open(
|
||||
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."""
|
||||
self.upload_submission(file_path)
|
||||
self.confirm_upload()
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ from __future__ import annotations
|
|||
import pytest
|
||||
|
||||
from .pages import OperatorWorkbench
|
||||
from .pages.workbench_page import SUGGESTED_QUERY_LOADED_STATUS
|
||||
from .pages.workbench_page import SUGGESTED_QUERY_LOADED_STATUS, UPLOAD_ANALYSIS_TIMEOUT_MS
|
||||
|
||||
|
||||
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"
|
||||
|
||||
workbench.confirm_upload()
|
||||
workbench.wait_for_case("smoke-upload", state="attached", timeout=10000)
|
||||
workbench.workbench_view.wait_for(state="visible", timeout=10000)
|
||||
workbench.wait_for_case("smoke-upload", state="attached", timeout=UPLOAD_ANALYSIS_TIMEOUT_MS)
|
||||
workbench.workbench_view.wait_for(state="visible", timeout=UPLOAD_ANALYSIS_TIMEOUT_MS)
|
||||
|
||||
assert (operator_server.image_root / "images" / "smoke-upload.svg").exists()
|
||||
assert (
|
||||
|
|
|
|||
|
|
@ -8,7 +8,27 @@ ROOT = Path(__file__).resolve().parents[2]
|
|||
APP_DIR = ROOT / "web" / "operator-gui"
|
||||
INDEX = APP_DIR / "index.html"
|
||||
STYLES = APP_DIR / "styles.css"
|
||||
APP_JS = APP_DIR / "app.js"
|
||||
# The former single app.js, split by concern. Order is load-bearing (classic
|
||||
# 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"
|
||||
SUBMISSION_IMPORT_JS = APP_DIR / "submission-import.js"
|
||||
EVIDENCE_GUIDANCE_JS = APP_DIR / "evidence-guidance.js"
|
||||
|
|
@ -33,10 +53,20 @@ def _read(path: Path) -> str:
|
|||
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():
|
||||
assert INDEX.exists()
|
||||
assert STYLES.exists()
|
||||
assert APP_JS.exists()
|
||||
for path in APP_SCRIPTS:
|
||||
assert path.exists(), path.name
|
||||
assert OPERATOR_LABELS_JS.exists()
|
||||
assert SUBMISSION_IMPORT_JS.exists()
|
||||
assert EVIDENCE_GUIDANCE_JS.exists()
|
||||
|
|
@ -63,7 +93,7 @@ def test_operator_gui_text_does_not_include_mojibake_fragments():
|
|||
"?",
|
||||
]
|
||||
|
||||
for path in [INDEX, APP_JS, OPERATOR_LABELS_JS, SUBMISSION_IMPORT_JS, EVIDENCE_GUIDANCE_JS, OPERATOR_SEARCH_JS]:
|
||||
for path in [INDEX, *APP_SCRIPTS, OPERATOR_LABELS_JS, SUBMISSION_IMPORT_JS, EVIDENCE_GUIDANCE_JS, OPERATOR_SEARCH_JS]:
|
||||
text = _read(path)
|
||||
for fragment in suspect_fragments:
|
||||
assert fragment not in text, f"{path.name} contains mojibake fragment {fragment!r}"
|
||||
|
|
@ -154,7 +184,7 @@ def test_workbench_shell_exposes_all_internal_operator_views():
|
|||
|
||||
|
||||
def test_static_app_models_core_review_operations():
|
||||
script = _read(APP_JS)
|
||||
script = _read_app()
|
||||
|
||||
for state_name in [
|
||||
"submissions",
|
||||
|
|
@ -193,18 +223,56 @@ def test_static_app_models_core_review_operations():
|
|||
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():
|
||||
html = _read(INDEX)
|
||||
script = _read(APP_JS)
|
||||
script = _read_app()
|
||||
labels = _read(OPERATOR_LABELS_JS)
|
||||
helpers = _read(SUBMISSION_IMPORT_JS)
|
||||
guidance = _read(EVIDENCE_GUIDANCE_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="submission-import.js"') < html.index('src="app.js"')
|
||||
assert html.index('src="evidence-guidance.js"') < html.index('src="app.js"')
|
||||
assert html.index('src="operator-search.js"') < html.index('src="app.js"')
|
||||
assert html.index('src="submission-import.js"') < html.index(first_app_part)
|
||||
assert html.index('src="evidence-guidance.js"') < html.index(first_app_part)
|
||||
assert html.index('src="operator-search.js"') < html.index(first_app_part)
|
||||
assert "OperatorLabels" in labels
|
||||
assert "riskLabels" in labels
|
||||
assert "providerLabels" in labels
|
||||
|
|
@ -231,7 +299,7 @@ def test_operator_gui_extracts_submission_import_helpers_from_main_app():
|
|||
|
||||
|
||||
def test_operator_gui_does_not_boot_with_demo_review_data():
|
||||
script = _read(APP_JS)
|
||||
script = _read_app()
|
||||
|
||||
for collection in [
|
||||
"submissions",
|
||||
|
|
@ -303,7 +371,7 @@ def test_ui_overhaul_final_audit_has_no_overflow_and_required_screenshots():
|
|||
def test_header_coverage_uses_horizontal_filter_tabs_not_scrolling_panel():
|
||||
html = _read(INDEX)
|
||||
styles = _read(STYLES)
|
||||
script = _read(APP_JS)
|
||||
script = _read_app()
|
||||
|
||||
assert 'id="coverage-tabs"' in html
|
||||
assert 'id="search-coverage"' not in html
|
||||
|
|
@ -318,7 +386,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():
|
||||
html = _read(INDEX)
|
||||
styles = _read(STYLES)
|
||||
script = _read(APP_JS)
|
||||
script = _read_app()
|
||||
|
||||
assert "queue-grid" in html
|
||||
assert "queue-row" in script
|
||||
|
|
@ -349,7 +417,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():
|
||||
html = _read(INDEX)
|
||||
script = _read(APP_JS)
|
||||
script = _read_app()
|
||||
styles = _read(STYLES)
|
||||
|
||||
assert 'id="workbench-view"' in html
|
||||
|
|
@ -387,7 +455,7 @@ def test_case_workbench_tabs_do_not_share_the_same_review_layout():
|
|||
|
||||
def test_case_decision_controls_float_while_reviewing_evidence():
|
||||
html = _read(INDEX)
|
||||
script = _read(APP_JS)
|
||||
script = _read_app()
|
||||
styles = _read(STYLES)
|
||||
|
||||
assert "floating-decision-panel" in html
|
||||
|
|
@ -404,7 +472,7 @@ def test_case_decision_controls_float_while_reviewing_evidence():
|
|||
|
||||
|
||||
def test_knowledge_base_rows_separate_title_metadata_chips_and_actions():
|
||||
script = _read(APP_JS)
|
||||
script = _read_app()
|
||||
styles = _read(STYLES)
|
||||
|
||||
assert "knowledge-main" in script
|
||||
|
|
@ -423,7 +491,7 @@ def test_knowledge_base_rows_separate_title_metadata_chips_and_actions():
|
|||
|
||||
def test_correction_history_lives_inside_knowledge_database_panel():
|
||||
html = _read(INDEX)
|
||||
script = _read(APP_JS)
|
||||
script = _read_app()
|
||||
styles = _read(STYLES)
|
||||
|
||||
assert 'data-knowledge-tab="collect"' in html
|
||||
|
|
@ -460,7 +528,7 @@ def test_knowledge_collection_and_registered_entries_are_separate_tabs():
|
|||
|
||||
def test_safety_rules_are_visible_in_ui_contract():
|
||||
html = _read(INDEX)
|
||||
script = _read(APP_JS)
|
||||
script = _read_app()
|
||||
|
||||
assert 'id="decision-memo"' in html
|
||||
assert 'id="manual-query-provider"' in html
|
||||
|
|
@ -476,7 +544,7 @@ def test_safety_rules_are_visible_in_ui_contract():
|
|||
|
||||
|
||||
def test_provider_quota_zero_does_not_render_nan_meter():
|
||||
script = _read(APP_JS)
|
||||
script = _read_app()
|
||||
|
||||
assert "provider.quota ? Math.min" in script
|
||||
assert "configuredEnv" in script
|
||||
|
|
@ -484,7 +552,7 @@ def test_provider_quota_zero_does_not_render_nan_meter():
|
|||
|
||||
|
||||
def test_query_history_rerun_preserves_original_search_provider():
|
||||
script = _read(APP_JS)
|
||||
script = _read_app()
|
||||
search = _read(OPERATOR_SEARCH_JS)
|
||||
|
||||
assert "data-rerun-provider" in script
|
||||
|
|
@ -497,7 +565,7 @@ def test_query_history_rerun_preserves_original_search_provider():
|
|||
|
||||
def test_operator_gui_exposes_submission_reload_action():
|
||||
html = _read(INDEX)
|
||||
script = _read(APP_JS)
|
||||
script = _read_app()
|
||||
|
||||
assert 'id="submission-folder"' in html
|
||||
assert 'id="submission-image"' in html
|
||||
|
|
@ -519,7 +587,7 @@ def test_operator_gui_exposes_submission_reload_action():
|
|||
|
||||
|
||||
def test_operator_gui_uses_clear_korean_status_copy_for_queue_imports():
|
||||
script = _read(APP_JS) + _read(SUBMISSION_IMPORT_JS)
|
||||
script = _read_app() + _read(SUBMISSION_IMPORT_JS)
|
||||
|
||||
for required_copy in [
|
||||
"제출 폴더를 읽는 중입니다.",
|
||||
|
|
@ -538,7 +606,7 @@ def test_operator_gui_uses_clear_korean_status_copy_for_queue_imports():
|
|||
|
||||
|
||||
def test_operator_gui_bulk_rerun_uses_checked_queue_rows():
|
||||
script = _read(APP_JS)
|
||||
script = _read_app()
|
||||
|
||||
assert "function selectedBulkSubmissionIds" in script
|
||||
assert 'input[data-bulk-id]:checked' in script
|
||||
|
|
@ -548,7 +616,7 @@ def test_operator_gui_bulk_rerun_uses_checked_queue_rows():
|
|||
|
||||
def test_operator_gui_manual_knowledge_entry_accepts_reference_image():
|
||||
html = _read(INDEX)
|
||||
script = _read(APP_JS)
|
||||
script = _read_app()
|
||||
|
||||
assert 'id="knowledge-image"' in html
|
||||
assert 'id="knowledge-image-name"' in html
|
||||
|
|
@ -563,7 +631,7 @@ def test_operator_gui_manual_knowledge_entry_accepts_reference_image():
|
|||
|
||||
def test_operator_gui_exposes_keyword_candidate_collection_workflow():
|
||||
html = _read(INDEX)
|
||||
script = _read(APP_JS)
|
||||
script = _read_app()
|
||||
search = _read(OPERATOR_SEARCH_JS)
|
||||
|
||||
assert 'id="candidate-collection-form"' in html
|
||||
|
|
@ -612,7 +680,7 @@ def test_operator_gui_exposes_keyword_candidate_collection_workflow():
|
|||
|
||||
def test_google_custom_search_is_exposed_as_operator_text_query_choice():
|
||||
html = _read(INDEX)
|
||||
script = _read(APP_JS)
|
||||
script = _read_app()
|
||||
search = _read(OPERATOR_SEARCH_JS)
|
||||
labels = _read(OPERATOR_LABELS_JS)
|
||||
|
||||
|
|
@ -638,7 +706,7 @@ def test_audit_target_and_change_columns_have_equal_widths():
|
|||
|
||||
|
||||
def test_queue_provider_judgments_render_on_one_line_with_narrower_reason_column():
|
||||
script = _read(APP_JS)
|
||||
script = _read_app()
|
||||
styles = _read(STYLES)
|
||||
|
||||
assert "provider-strip" in script
|
||||
|
|
@ -655,7 +723,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():
|
||||
script = _read(APP_JS)
|
||||
script = _read_app()
|
||||
|
||||
assert '["used_for_judgment", "사용"]' in script
|
||||
assert '["ignored", "미사용"]' in script
|
||||
|
|
@ -667,7 +735,7 @@ def test_evidence_operator_status_actions_are_binary_use_or_ignore():
|
|||
|
||||
def test_operator_gui_uses_korean_operator_copy_for_visible_labels():
|
||||
html = _read(INDEX)
|
||||
script = _read(APP_JS)
|
||||
script = _read_app()
|
||||
|
||||
for english_label in [
|
||||
"Image Rights Operator Console",
|
||||
|
|
@ -702,7 +770,7 @@ def test_operator_gui_uses_korean_operator_copy_for_visible_labels():
|
|||
|
||||
|
||||
def test_operator_gui_renders_search_result_links_and_thumbnails():
|
||||
script = _read(APP_JS)
|
||||
script = _read_app()
|
||||
|
||||
assert "renderEvidenceLink" in script
|
||||
assert "thumbnailUrl" in script
|
||||
|
|
@ -725,7 +793,7 @@ def test_operator_gui_renders_search_result_links_and_thumbnails():
|
|||
|
||||
|
||||
def test_operator_gui_prioritizes_evidence_and_hides_overflow_details():
|
||||
script = _read(APP_JS)
|
||||
script = _read_app()
|
||||
styles = _read(STYLES)
|
||||
|
||||
assert "renderEvidenceSummary" in script
|
||||
|
|
@ -740,7 +808,7 @@ def test_operator_gui_prioritizes_evidence_and_hides_overflow_details():
|
|||
|
||||
def test_operator_gui_suggests_followup_queries_for_insufficient_evidence():
|
||||
html = _read(INDEX)
|
||||
script = _read(APP_JS)
|
||||
script = _read_app()
|
||||
guidance = _read(EVIDENCE_GUIDANCE_JS)
|
||||
combined_script = script + guidance
|
||||
styles = _read(STYLES)
|
||||
|
|
@ -766,7 +834,7 @@ def test_operator_gui_suggests_followup_queries_for_insufficient_evidence():
|
|||
|
||||
|
||||
def test_operator_gui_separates_face_crop_web_evidence_from_identity_matching():
|
||||
script = _read(APP_JS)
|
||||
script = _read_app()
|
||||
|
||||
assert "faceCropSearch" in script
|
||||
assert "face_web" in script
|
||||
|
|
@ -775,7 +843,7 @@ def test_operator_gui_separates_face_crop_web_evidence_from_identity_matching():
|
|||
|
||||
|
||||
def test_operator_gui_exposes_evidence_status_and_watchlist_controls():
|
||||
script = _read(APP_JS)
|
||||
script = _read_app()
|
||||
styles = _read(STYLES)
|
||||
|
||||
assert "/api/evidence/" in script
|
||||
|
|
@ -796,7 +864,7 @@ def test_visual_assets_are_referenced_for_review_images():
|
|||
assets_dir = APP_DIR / "assets"
|
||||
|
||||
assert (assets_dir / "case-portrait.svg").exists()
|
||||
assert "assets/case-portrait.svg" in html or "assets/case-portrait.svg" in _read(APP_JS)
|
||||
assert "assets/case-portrait.svg" in html or "assets/case-portrait.svg" in _read_app()
|
||||
|
||||
for orphan_asset in [
|
||||
"case-character.svg",
|
||||
|
|
@ -807,7 +875,7 @@ def test_visual_assets_are_referenced_for_review_images():
|
|||
|
||||
|
||||
def test_suggested_queries_support_one_click_and_batch_execution():
|
||||
script = _read(APP_JS)
|
||||
script = _read_app()
|
||||
styles = _read(STYLES)
|
||||
|
||||
assert "executeSuggestedQueries" in script
|
||||
|
|
@ -823,7 +891,7 @@ def test_suggested_queries_support_one_click_and_batch_execution():
|
|||
|
||||
def test_registered_knowledge_panel_supports_search_filter_edit_and_lifecycle():
|
||||
html = _read(INDEX)
|
||||
script = _read(APP_JS)
|
||||
script = _read_app()
|
||||
|
||||
registered_panel = html.split('data-knowledge-panel="registered"', 1)[1].split('data-knowledge-panel="manual"', 1)[0]
|
||||
assert 'id="knowledge-search"' in registered_panel
|
||||
|
|
@ -843,7 +911,7 @@ def test_registered_knowledge_panel_supports_search_filter_edit_and_lifecycle():
|
|||
|
||||
def test_workbench_shows_detected_face_crop_strip():
|
||||
html = _read(INDEX)
|
||||
script = _read(APP_JS)
|
||||
script = _read_app()
|
||||
|
||||
assert 'id="face-crop-strip"' in html
|
||||
assert "faceCrops" in script
|
||||
|
|
@ -856,7 +924,7 @@ def test_workbench_shows_detected_face_crop_strip():
|
|||
|
||||
def test_rerun_diff_summary_and_new_evidence_badges_are_rendered():
|
||||
html = _read(INDEX)
|
||||
script = _read(APP_JS)
|
||||
script = _read_app()
|
||||
styles = _read(STYLES)
|
||||
|
||||
assert 'id="rerun-diff-summary"' in html
|
||||
|
|
|
|||
123
tests/rights_filter/server/test_auth_rate_limit.py
Normal file
123
tests/rights_filter/server/test_auth_rate_limit.py
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
"""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
|
||||
|
|
@ -1179,7 +1179,11 @@ def test_http_server_runs_auto_search_for_submission(tmp_path: Path):
|
|||
|
||||
|
||||
def test_operator_gui_uses_api_bootstrap():
|
||||
app_js = Path("web/operator-gui/app.js").read_text(encoding="utf-8")
|
||||
# app.js was split into ordered app-*.js parts; this spec cares that the
|
||||
# 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 "refreshFromApi" in app_js
|
||||
|
|
@ -1403,6 +1407,98 @@ def test_protected_routes_require_token_when_configured(tmp_path: Path):
|
|||
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):
|
||||
# 2026-07-11 #10: a cookie-authenticated POST from a foreign Origin (classic
|
||||
# CSRF) is rejected; header-authed requests are not forgeable.
|
||||
|
|
|
|||
|
|
@ -3171,7 +3171,9 @@ def test_search_result_candidate_image_similarity_respects_runtime_threshold(
|
|||
):
|
||||
image_store = _png_image_store(tmp_path / "submissions")
|
||||
original_image = image_store.image_payload("SUB-PNG1").content
|
||||
fetcher = lambda _: original_image
|
||||
|
||||
def fetcher(_):
|
||||
return original_image
|
||||
|
||||
runtime = build_provider_runtime(
|
||||
env={"COPYRIGHTER_SEARCH_RESULT_SIMILARITY_THRESHOLD": "0.95"},
|
||||
|
|
@ -3287,7 +3289,9 @@ 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):
|
||||
image_store = _png_image_store(tmp_path / "submissions")
|
||||
original_image = image_store.image_payload("SUB-PNG1").content
|
||||
fetcher = lambda _: original_image
|
||||
|
||||
def fetcher(_):
|
||||
return original_image
|
||||
|
||||
runtime = build_provider_runtime(
|
||||
env={"COPYRIGHTER_SEARCH_RESULT_COMPARE_LIMIT": "3"},
|
||||
|
|
|
|||
143
tests/rights_filter/server/test_store_remote_fetch.py
Normal file
143
tests/rights_filter/server/test_store_remote_fetch.py
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
"""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
|
||||
|
|
@ -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_workflow import MAX_BATCH_DECISIONS
|
||||
|
||||
from tests.rights_filter.server.test_sqlite_store import _image_store, FakeJsonTransport
|
||||
from tests.rights_filter.server.test_sqlite_store import FakeJsonTransport
|
||||
|
||||
|
||||
def _photo_store(root: Path, *, duplicate: bool = False):
|
||||
|
|
|
|||
57
verify.ps1
Normal file
57
verify.ps1
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
<#
|
||||
.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
|
||||
282
web/operator-gui/app-admin.js
Normal file
282
web/operator-gui/app-admin.js
Normal file
|
|
@ -0,0 +1,282 @@
|
|||
// 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);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
264
web/operator-gui/app-api.js
Normal file
264
web/operator-gui/app-api.js
Normal file
|
|
@ -0,0 +1,264 @@
|
|||
// 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();
|
||||
|
||||
}
|
||||
|
||||
|
||||
558
web/operator-gui/app-bootstrap.js
Normal file
558
web/operator-gui/app-bootstrap.js
Normal file
|
|
@ -0,0 +1,558 @@
|
|||
// 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));
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
323
web/operator-gui/app-collections.js
Normal file
323
web/operator-gui/app-collections.js
Normal file
|
|
@ -0,0 +1,323 @@
|
|||
// 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;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
361
web/operator-gui/app-decision-tools.js
Normal file
361
web/operator-gui/app-decision-tools.js
Normal file
|
|
@ -0,0 +1,361 @@
|
|||
// 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();
|
||||
|
||||
}
|
||||
|
||||
|
||||
591
web/operator-gui/app-format.js
Normal file
591
web/operator-gui/app-format.js
Normal file
|
|
@ -0,0 +1,591 @@
|
|||
// 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("&", "&")
|
||||
|
||||
.replaceAll("<", "<")
|
||||
|
||||
.replaceAll(">", ">")
|
||||
|
||||
.replaceAll('"', """)
|
||||
|
||||
.replaceAll("'", "'");
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
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 || "-";
|
||||
}
|
||||
|
||||
450
web/operator-gui/app-keyboard-batch.js
Normal file
450
web/operator-gui/app-keyboard-batch.js
Normal file
|
|
@ -0,0 +1,450 @@
|
|||
// 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;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
450
web/operator-gui/app-navigation.js
Normal file
450
web/operator-gui/app-navigation.js
Normal file
|
|
@ -0,0 +1,450 @@
|
|||
// 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] : [];
|
||||
|
||||
}
|
||||
|
||||
|
||||
449
web/operator-gui/app-render-evidence.js
Normal file
449
web/operator-gui/app-render-evidence.js
Normal file
|
|
@ -0,0 +1,449 @@
|
|||
// 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>`;
|
||||
}
|
||||
|
||||
364
web/operator-gui/app-render-panels.js
Normal file
364
web/operator-gui/app-render-panels.js
Normal file
|
|
@ -0,0 +1,364 @@
|
|||
// 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 반영";
|
||||
});
|
||||
}
|
||||
|
||||
322
web/operator-gui/app-render-queue.js
Normal file
322
web/operator-gui/app-render-queue.js
Normal file
|
|
@ -0,0 +1,322 @@
|
|||
// 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>
|
||||
|
||||
`;
|
||||
|
||||
}
|
||||
|
||||
|
||||
310
web/operator-gui/app-selectors.js
Normal file
310
web/operator-gui/app-selectors.js
Normal file
|
|
@ -0,0 +1,310 @@
|
|||
// 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;
|
||||
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
|
||||
137
web/operator-gui/app-state.js
Normal file
137
web/operator-gui/app-state.js
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
// 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;
|
||||
|
||||
|
||||
291
web/operator-gui/app-stats.js
Normal file
291
web/operator-gui/app-stats.js
Normal file
|
|
@ -0,0 +1,291 @@
|
|||
// 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)}%`;
|
||||
|
||||
}
|
||||
|
||||
|
||||
382
web/operator-gui/app-submissions.js
Normal file
382
web/operator-gui/app-submissions.js
Normal file
|
|
@ -0,0 +1,382 @@
|
|||
// 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;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -10,7 +10,28 @@
|
|||
<script src="submission-import.js" defer></script>
|
||||
<script src="evidence-guidance.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>
|
||||
<body data-internal-only="true">
|
||||
<div class="auth-gate" id="auth-gate" hidden role="dialog" aria-modal="true" aria-labelledby="auth-gate-title">
|
||||
|
|
|
|||
Loading…
Reference in a new issue