docs: 증거 데이터 흐름 다이어그램 신설

수집 → 경계 검사 → 정규화·채점 → 영속화 → 소비 5단으로, 9/18 리뷰 수정에서
나온 불변식을 그림으로 고정한다.

  제공자별 풀·버킷(RiskScorer v3) · 라운드트립이 원 점수를 재현
  (_evidence_payload <-> _domain_evidence_from_ui) · host 는 저장 시 해석하고
  복원 시 우선 반환 · 지식DB 지문 매칭이 풀링 밖 유일한 high 승격 경로

작성 중 확인한 사실도 반영했다: 프로덕션 LLM 경로는 영속화된 payload 를
_domain_evidence_from_ui 로 복원해 넣는다(store_operations.py:487). 그래서
프롬프트가 host 를 보는 것은 복원 경로 덕분이다 — analysis/evidence_enrichment.py
의 EvidenceEnricher 는 테스트에서만 쓰인다.

소스는 evidence-dataflow.dataflow.json (archify dataflow v1). 그림을 고치려면
JSON 을 고쳐 재생성한다. 산출물은 자체 완결 HTML 로 외부 요청이 없다.

검증: archify showcase 통과(아티팩트 검사 9/9 · 합성 오류 0 · 경고 0),
visual-check 통과(1440x900 · 2048x1320 각 Light/Dark, 오버플로 0,
최소 투영 텍스트 6.71px >= 6px). html sha256 268f3352 (822,655 B).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BStHFrjQj2VypecSG9ex1V
This commit is contained in:
changukyu 2026-09-19 00:18:09 +09:00
parent 1605fd3641
commit e4fffc40be
4 changed files with 312 additions and 0 deletions

View file

@ -1,5 +1,7 @@
# Project INDEX # Project INDEX
- 2026-09-19 · `docs/diagrams/evidence-dataflow.html` 신설 — 증거 데이터 흐름(수집 → 경계 검사 → 정규화·채점 → 영속화 → 소비) 자체 완결 다이어그램. 9/18 리뷰 수정의 불변식을 그림으로 고정한다: **제공자별 풀·버킷**(RiskScorer v3), **라운드트립이 원 점수를 재현**(`_evidence_payload` ↔ `_domain_evidence_from_ui`), **host 는 저장 시 해석·복원 시 우선 반환**, 지식DB 지문 매칭이 풀링 밖 유일한 high 승격 경로. 프로덕션 LLM 경로가 영속화 payload 를 복원해 쓴다는 사실(`store_operations.py:487`, `EvidenceEnricher` 는 테스트 전용)도 반영. archify showcase 검증 통과(아티팩트 검사 9/9 · 합성 오류 0 · 경고 0), 브라우저 증거 통과(1440×900 · 2048×1320 각 Light/Dark 4컷, 오버플로 0, 최소 투영 텍스트 6.71px ≥ 6px). spec sha256 42c334cd · html sha256 268f3352 (822,655 B). CDN·원격 fetch 없음 — 폐쇄망 규칙 준수
- 2026-09-18 · `/code-review` 15건 전수 처리 (**헤드라인 1건은 반증**). 반증: "CSE 웹 증거가 라운드트립 후 0→45점" — 리뷰가 `match` 키 없는 합성 Evidence 로 재현한 것이고, 실제 CSE 웹 결과는 `google_custom_search.py:301` 에서 `match="page"` 를 달고 오며 `matchType` 이 충실히 왕복한다(신선/복구 모두 35점). 직렬화 불변식 위반 없음. 대신 그 조사에서 **리뷰가 놓친 라이브 버그**가 나왔다: `counted_web_buckets` 가 제공자를 안 가려 Vision 이 `match="page"` 를 먼저 보고하면 **CSE 웹 히트가 점수도 사유도 없이 통째로 버려졌다**. 실수정 — **점수 v2 → v3**(풀·버킷을 제공자별로: Vision 자기 상관 뷰 4개는 60/medium 유지, Vision full + CSE page 는 45+35=80/high 복원 · 도달 불가였던 `category` 탈출구 제거 + 미등록 유명인 미탐은 의도된 트레이드임을 명시, 승격 경로는 지식DB/FINGERPRINT), **저장·복구 5종**(`_result_host` 가 자기 host 를 안 읽어 재직렬화 시 API 이름으로 퇴행하던 잠재 버그 + 점 없는 토큰 호스트 거부 · 복구가 남긴 고아 `faceCrops` 참조를 재생성/정리 · `assetMissing` 해제 경로 신설로 두 번째 소실도 감사에 남김 · 플래그+감사를 한 트랜잭션으로 · 스코어러 마이그레이션 밴드 변경에 `scoredVersion` 스탬프 + 감사 1회), **콘솔 4종**(긴 CDN 호스트가 제목을 세로 컬럼으로 만든 flex 버그 — 실측 360px 51×95px→246×19px, **1280px 9×380px→205×19px 로 폰 전용이 아니었음** · 20ch 로 잘린 호스트를 툴팁에 · `bloggerLink` 폴백 누락 · 보존 케이스 설명 표시), **PROMPT_VERSION ko-v4-structured-host**(실모델 프로브 qwen2.5:7b — grounded 3/dropped 0/verified True, 반향 없음). 테스트 +14건. **ruff clean · pytest 586 passed**. 발견된 기존 결함 1건은 미수정 기록: 로컬 ollama 가 떠 있으면 `test_http_app.py` 3건이 실호출 타임아웃으로 실패한다(기본 생성 store 가 `127.0.0.1:11434` 기본값에 붙음 — clean HEAD 에서도 동일). 커밋 b0aa4c5 / 4ba83eb / e38dad8 - 2026-09-18 · `/code-review` 15건 전수 처리 (**헤드라인 1건은 반증**). 반증: "CSE 웹 증거가 라운드트립 후 0→45점" — 리뷰가 `match` 키 없는 합성 Evidence 로 재현한 것이고, 실제 CSE 웹 결과는 `google_custom_search.py:301` 에서 `match="page"` 를 달고 오며 `matchType` 이 충실히 왕복한다(신선/복구 모두 35점). 직렬화 불변식 위반 없음. 대신 그 조사에서 **리뷰가 놓친 라이브 버그**가 나왔다: `counted_web_buckets` 가 제공자를 안 가려 Vision 이 `match="page"` 를 먼저 보고하면 **CSE 웹 히트가 점수도 사유도 없이 통째로 버려졌다**. 실수정 — **점수 v2 → v3**(풀·버킷을 제공자별로: Vision 자기 상관 뷰 4개는 60/medium 유지, Vision full + CSE page 는 45+35=80/high 복원 · 도달 불가였던 `category` 탈출구 제거 + 미등록 유명인 미탐은 의도된 트레이드임을 명시, 승격 경로는 지식DB/FINGERPRINT), **저장·복구 5종**(`_result_host` 가 자기 host 를 안 읽어 재직렬화 시 API 이름으로 퇴행하던 잠재 버그 + 점 없는 토큰 호스트 거부 · 복구가 남긴 고아 `faceCrops` 참조를 재생성/정리 · `assetMissing` 해제 경로 신설로 두 번째 소실도 감사에 남김 · 플래그+감사를 한 트랜잭션으로 · 스코어러 마이그레이션 밴드 변경에 `scoredVersion` 스탬프 + 감사 1회), **콘솔 4종**(긴 CDN 호스트가 제목을 세로 컬럼으로 만든 flex 버그 — 실측 360px 51×95px→246×19px, **1280px 9×380px→205×19px 로 폰 전용이 아니었음** · 20ch 로 잘린 호스트를 툴팁에 · `bloggerLink` 폴백 누락 · 보존 케이스 설명 표시), **PROMPT_VERSION ko-v4-structured-host**(실모델 프로브 qwen2.5:7b — grounded 3/dropped 0/verified True, 반향 없음). 테스트 +14건. **ruff clean · pytest 586 passed**. 발견된 기존 결함 1건은 미수정 기록: 로컬 ollama 가 떠 있으면 `test_http_app.py` 3건이 실호출 타임아웃으로 실패한다(기본 생성 store 가 `127.0.0.1:11434` 기본값에 붙음 — clean HEAD 에서도 동일). 커밋 b0aa4c5 / 4ba83eb / e38dad8
- 2026-08-09 · P1 2차 — **게시 호스트 복구**. P1 3번의 전제("호스트는 저장돼 있고 점수만 안 쓴다")가 틀렸다: CSE 가 실호스트를 `data["domain"]` 에 넣는데 `store_serialization.py:298` 이 `provider` 를 우선해 **저장 시점에 덮어썼고**, Vision·Naver 는 URL 만 준다. 결과적으로 LLM 프롬프트 12줄이 전부 `domain=google` 이라 "어디에 떠 있나"를 모델도 운영자도 못 봤다. `domain` 은 provider 커버리지·CSE 시그니처 매칭의 키라 재사용 불가 → **`host` 별도 키 신설**(`_result_host`: CSE displayLink 또는 URL 파싱, www 제거·소문자). 점수 무영향·`_evidence_id` 무변화(도메인 객체 해싱이라 payload 키 추가가 id 를 안 바꿈). `PROMPT_DATA_FIELDS` 의 `domain`→`host`, 콘솔 증거 카드에 monospace `host-chip`(레거시 행은 `evidenceHost()` 가 저장 URL 에서 파생 — 마이그레이션 불요). **실렌더 검증**(실 DB, 칩 82개 전부 유효 호스트, 콘솔 에러 0)에서만 드러난 결함 2건 수정: 긴 CDN 호스트가 제목을 한 글자씩 세로로 깨뜨림 → 칩 20ch 상한 + 제목 `flex:1 1 auto`, 이후 짧은 호스트까지 잘림 → 칩 `flex:0 0 auto` 고정. PROVENANCE 배점은 **보류**(카테고리가 2종뿐이라 라벨이 사실상 "geometry AND 자막"이고, "새 신호는 0점 출시 후 상관 확인" 결정과 충돌 — 한계를 `provenance.py` 상수 옆에 기록). **pytest 574 passed**(착수 전 570) - 2026-08-09 · P1 2차 — **게시 호스트 복구**. P1 3번의 전제("호스트는 저장돼 있고 점수만 안 쓴다")가 틀렸다: CSE 가 실호스트를 `data["domain"]` 에 넣는데 `store_serialization.py:298` 이 `provider` 를 우선해 **저장 시점에 덮어썼고**, Vision·Naver 는 URL 만 준다. 결과적으로 LLM 프롬프트 12줄이 전부 `domain=google` 이라 "어디에 떠 있나"를 모델도 운영자도 못 봤다. `domain` 은 provider 커버리지·CSE 시그니처 매칭의 키라 재사용 불가 → **`host` 별도 키 신설**(`_result_host`: CSE displayLink 또는 URL 파싱, www 제거·소문자). 점수 무영향·`_evidence_id` 무변화(도메인 객체 해싱이라 payload 키 추가가 id 를 안 바꿈). `PROMPT_DATA_FIELDS` 의 `domain`→`host`, 콘솔 증거 카드에 monospace `host-chip`(레거시 행은 `evidenceHost()` 가 저장 URL 에서 파생 — 마이그레이션 불요). **실렌더 검증**(실 DB, 칩 82개 전부 유효 호스트, 콘솔 에러 0)에서만 드러난 결함 2건 수정: 긴 CDN 호스트가 제목을 한 글자씩 세로로 깨뜨림 → 칩 20ch 상한 + 제목 `flex:1 1 auto`, 이후 짧은 호스트까지 잘림 → 칩 `flex:0 0 auto` 고정. PROVENANCE 배점은 **보류**(카테고리가 2종뿐이라 라벨이 사실상 "geometry AND 자막"이고, "새 신호는 0점 출시 후 상관 확인" 결정과 충돌 — 한계를 `provenance.py` 상수 옆에 기록). **pytest 574 passed**(착수 전 570)

View file

@ -167,6 +167,12 @@ WAL 적용됨. DB 기본 경로는 `data/copyrighter.sqlite3`(gitignore).
--- ---
## 다이어그램
| 파일 | 내용 |
|---|---|
| `docs/diagrams/evidence-dataflow.html` | 증거 데이터 흐름 5단(수집·경계 검사·정규화/채점·영속화·소비). 소스는 같은 폴더의 `evidence-dataflow.dataflow.json` (archify dataflow 스키마 v1) — 그림을 고치려면 JSON 을 고쳐 `archify deliver` 로 재생성한다. 자체 완결 HTML(외부 요청 없음) |
## 알려진 부채 ## 알려진 부채
| 항목 | 위치 | 비고 | | 항목 | 위치 | 비고 |

View file

@ -0,0 +1,304 @@
{
"schema_version": 1,
"diagram_type": "dataflow",
"meta": {
"title": "증거 데이터 흐름 — 수집에서 판정까지",
"output": "docs/diagrams/evidence-dataflow.html",
"quality_profile": "showcase",
"views": [
{
"id": "collect-boundary",
"label": "수집 경계",
"focus": [
"vision",
"cse",
"naver",
"policy",
"normalizer"
],
"note": "외부 응답이 Evidence 가 되기까지 통과하는 데이터 등급 경계."
},
{
"id": "scoring",
"label": "채점",
"focus": [
"normalizer",
"scorer",
"knowledge"
],
"note": "제공자별 풀·버킷과, 풀링 대상이 아닌 지식DB 승격 경로."
},
{
"id": "roundtrip",
"label": "라운드트립",
"focus": [
"payload",
"sqlite",
"restore",
"scorer"
],
"note": "저장과 복원이 원 점수를 재현해야 하는 고리."
}
],
"viewBox": [
1080,
500
]
},
"stages": [
{
"label": "수집"
},
{
"label": "경계 검사"
},
{
"label": "정규화 · 채점"
},
{
"label": "영속화"
},
{
"label": "소비"
}
],
"nodes": [
{
"id": "vision",
"type": "external",
"label": "Cloud Vision",
"sublabel": "웹 탐지 · 엔티티",
"tag": "match",
"brand": "google-cloud",
"stage": 0,
"row": 0
},
{
"id": "cse",
"type": "external",
"label": "Google 맞춤 검색",
"sublabel": "이미지 · 웹문서",
"tag": "match=page",
"stage": 0,
"row": 1
},
{
"id": "naver",
"type": "external",
"label": "네이버 검색",
"sublabel": "블로그 · 웹문서",
"tag": "promoted",
"stage": 0,
"row": 2
},
{
"id": "knowledge",
"type": "database",
"label": "지식DB · 주의목록",
"sublabel": "등록 이미지 지문",
"tag": "내부",
"stage": 2,
"row": 2
},
{
"id": "policy",
"type": "security",
"label": "데이터 등급 검사",
"sublabel": "payload 허용 목록",
"tag": "SEARCH_EVIDENCE",
"stage": 1,
"row": 1
},
{
"id": "normalizer",
"type": "backend",
"label": "증거 정규화",
"sublabel": "Evidence 레코드",
"tag": "source",
"stage": 2,
"row": 0
},
{
"id": "scorer",
"type": "backend",
"label": "RiskScorer",
"sublabel": "제공자별 풀 · 버킷",
"tag": "v3",
"stage": 2,
"row": 1
},
{
"id": "payload",
"type": "backend",
"label": "_evidence_payload",
"sublabel": "도메인 → payload",
"tag": "host 해석",
"stage": 3,
"row": 0
},
{
"id": "sqlite",
"type": "database",
"label": "evidence 테이블",
"sublabel": "payload JSON · WAL",
"tag": "cascade",
"stage": 3,
"row": 1
},
{
"id": "restore",
"type": "backend",
"label": "증거 복원",
"sublabel": "payload → 도메인",
"tag": "복원",
"stage": 3,
"row": 2
},
{
"id": "console",
"type": "frontend",
"label": "운영자 콘솔",
"sublabel": "증거 카드 · host 칩",
"tag": "판정",
"stage": 4,
"row": 0
},
{
"id": "llm",
"type": "backend",
"label": "LLM 증거 판독",
"sublabel": "상위 12행 화이트리스트",
"tag": "ko-v4",
"stage": 4,
"row": 1
}
],
"flows": [
{
"id": "vision-payload",
"from": "vision",
"to": "policy",
"label": "웹 탐지 결과",
"classification": "외부 응답",
"route": "top-channel"
},
{
"id": "cse-payload",
"from": "cse",
"to": "policy",
"label": "검색 결과",
"classification": "외부 응답",
"route": "straight"
},
{
"id": "naver-payload",
"from": "naver",
"to": "policy",
"label": "블로그 · 웹문서",
"classification": "외부 응답",
"route": "bottom-channel"
},
{
"id": "allowed-fields",
"from": "policy",
"to": "normalizer",
"label": "허용 필드만 통과",
"classification": "등급 통제",
"variant": "security"
},
{
"id": "evidence-record",
"from": "normalizer",
"to": "scorer",
"label": "채점 입력 신호",
"classification": "주 경로",
"variant": "emphasis",
"labelDy": 36
},
{
"id": "fingerprint-match",
"from": "knowledge",
"to": "scorer",
"label": "지문 유사도 0.9+ → 80점",
"classification": "풀링 제외",
"labelDy": -20
},
{
"id": "score-band",
"from": "scorer",
"to": "payload",
"label": "점수 · 밴드 · 사유",
"classification": "주 경로",
"variant": "emphasis"
},
{
"id": "serialize",
"from": "payload",
"to": "sqlite",
"label": "payload JSON · host",
"classification": "주 경로",
"variant": "emphasis",
"labelDy": 36
},
{
"id": "reload-restore",
"from": "sqlite",
"to": "restore",
"label": "리로드 · 판정 시 복원",
"classification": "읽기 경로",
"labelDy": 36
},
{
"id": "roundtrip-invariant",
"from": "restore",
"to": "scorer",
"label": "원 점수 재현",
"classification": "불변식",
"variant": "emphasis"
},
{
"id": "bootstrap-read",
"from": "sqlite",
"to": "console",
"label": "bootstrap 조회",
"classification": "읽기 경로"
},
{
"id": "evidence-reading",
"from": "restore",
"to": "llm",
"label": "증거 판독 요청",
"classification": "읽기 경로"
}
],
"cards": [
{
"dot": "emerald",
"title": "주 경로",
"items": [
"외부 응답은 데이터 등급 검사를 통과한 필드만 Evidence 가 된다",
"RiskScorer v3 는 풀과 dedup 버킷을 제공자별로 나눈다 (presence 60 / entity 20)",
"점수 · 밴드 · 사유는 payload 로 직렬화되어 evidence 테이블에 남는다"
]
},
{
"dot": "rose",
"title": "라운드트립 불변식",
"items": [
"_evidence_payload 로 직렬화하고 _domain_evidence_from_ui 로 복원한다",
"복원된 증거를 다시 채점하면 원 점수가 재현되어야 한다",
"result_url → url 별칭이 0점을 45점으로 바꾼 사고가 있었다",
"host 는 저장 시 해석하고 복원 시 우선 반환한다 — API 이름으로 되돌아가지 않는다"
]
},
{
"dot": "orange",
"title": "소비 경로",
"items": [
"콘솔은 영속화된 payload 를 읽는다 — 호스트 칩은 20ch 로 잘리고 전체는 툴팁에 있다",
"LLM 은 복원된 증거 상위 12행만 본다 — 인용 없는 claim 은 폐기된다",
"지식DB 지문 매칭은 풀링 대상이 아니며, 라벨만으로는 닿지 않는 high 승격 경로다"
]
}
]
}

Binary file not shown.