POSA_Copyrighter/docs/diagrams/system-architecture.architecture.json
changukyu 994852d920 feat: 신뢰 출처 — 자사·계약처 도메인은 위험 신호가 아니다 (SCORER_VERSION v4)
8/8 실효성 평가가 "남은 것: 신뢰출처 판정 자체. 목록만 정해지면
cleared_reference 와 같은 패턴으로 바로 붙는다"로 남겨둔 항목이다. 전제였던
"호스트가 저장 시점에 파괴되는" 문제는 9/18 에 고쳤고, 저장 위치는 지식DB 로
정해졌다.

  지식DB 의 trusted_host 항목 — 이름이 호스트, 별칭이 같은 주체의 다른 도메인
  신뢰 철회는 기존 활성/비활성 수명주기 그대로 (두 번째 스위치를 만들지 않는다)

핵심은 **행 단위 제외**라는 것이다. 케이스 단위로 끄면 자사 사본이 있다는
이유로 스크래퍼 게시까지 덮인다. 실측: 우리 CDN 완전일치 + 블로그 게시가 같이
있을 때 신뢰 등록 전 80/high, 등록 후 35/medium — 블로그만 남는다.

제외된 행은 점수에서 빠지되 사유로는 남는다("신뢰 출처 posar.info — 위험
점수에서 제외"). 증거 목록에는 그 행이 그대로 보이므로, 왜 점수가 증거보다
조용한지 운영자에게 말해주지 않으면 도구를 불신하게 된다.

경계 처리:
  서브도메인은 같은 주체다 (cdn.posar.info)
  posar.info.evil.test 는 접미사가 아니라 다른 도메인이다
  점 없는 값은 무시한다 — "com" 하나가 웹 전체를 신뢰 처리하면 안 된다
  엔티티 라벨은 영향 없다 — "어디에 떠 있나"가 아니라 "무엇이 찍혔나"다
  신선한 CSE 행은 host 키가 없어 domain(displayLink)에서 해석한다

호스트 해석이 store_serialization._result_host 와 따로 사는 이유는 층 때문이다.
analysis 는 server 를 import 할 수 없고, 한쪽은 값을 영속화하고 다른 쪽은
운영자 목록과 대조한다. 우선순위는 같게 맞추고 그 사실을 양쪽 주석에 적었다.

빈 목록이면 v3 과 동작이 같다. 등록하지 않은 배포는 아무것도 달라지지 않는다.

다이어그램 사실 게이트(9/21 신설)가 v3 -> v4 를 3개 다이어그램에서 자동으로
잡았다. 스펙을 고치고 rebuild-diagrams.ps1 로 재생성했다 — 게이트의 첫 실사용이고
의도대로 동작했다.

검증: ruff clean, pytest 632 passed (스코어러 7건 + 스토어 3건 추가).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BStHFrjQj2VypecSG9ex1V
2026-09-21 12:18:31 +09:00

338 lines
7 KiB
JSON

{
"schema_version": 1,
"diagram_type": "architecture",
"meta": {
"title": "POSA Copyrighter 시스템 구성",
"output": "docs/diagrams/system-architecture.html",
"quality_profile": "showcase",
"viewBox": [
1080,
500
],
"views": [
{
"id": "request-path",
"label": "요청 경로",
"focus": [
"console",
"http",
"store",
"sqlite"
],
"note": "정적 콘솔에서 스토어와 SQLite 까지 가는 주 경로."
},
{
"id": "analysis",
"label": "분석 · 채점",
"focus": [
"store",
"images",
"analysis"
],
"note": "제출 이미지에서 증거와 점수가 만들어지는 고리."
},
{
"id": "egress",
"label": "외부 경계",
"focus": [
"store",
"gate",
"fetch",
"providers",
"ollama"
],
"note": "폐쇄망을 넘는 유일한 경로와 그 앞의 두 관문."
}
]
},
"components": [
{
"id": "console",
"type": "frontend",
"label": "운영자 콘솔",
"sublabel": "정적 SPA · 빌드 없음",
"tag": "app-*.js 15",
"pos": [
36,
170
],
"size": [
124,
62
]
},
{
"id": "throttle",
"type": "security",
"label": "인증 스로틀",
"sublabel": "10회 / 60초",
"tag": "주소별",
"pos": [
250,
60
],
"size": [
124,
62
]
},
{
"id": "http",
"type": "backend",
"label": "http_app",
"sublabel": "라우팅 · 인증 · CSP",
"tag": "바디 10MB",
"pos": [
250,
170
],
"size": [
124,
62
]
},
{
"id": "images",
"type": "database",
"label": "이미지 저장소",
"sublabel": "제출물 · manifest",
"pos": [
464,
60
],
"size": [
124,
62
]
},
{
"id": "store",
"type": "backend",
"label": "CopyrighterStore",
"sublabel": "믹스인 8개 조립",
"tag": "판정 · 감사",
"pos": [
464,
170
],
"size": [
124,
62
]
},
{
"id": "sqlite",
"type": "database",
"label": "SQLite",
"sublabel": "payload JSON · WAL",
"tag": "SCHEMA v2",
"pos": [
464,
280
],
"size": [
124,
62
]
},
{
"id": "analysis",
"type": "backend",
"label": "분석 · 채점",
"sublabel": "지문 · 얼굴 · 출처",
"tag": "RiskScorer v4",
"pos": [
678,
60
],
"size": [
124,
62
]
},
{
"id": "gate",
"type": "security",
"label": "외부 호출 게이트",
"sublabel": "쿼터 · 비상 차단",
"pos": [
678,
280
],
"size": [
124,
62
]
},
{
"id": "ollama",
"type": "external",
"label": "로컬 LLM",
"sublabel": "ollama · qwen2.5:7b",
"tag": "선택",
"pos": [
892,
170
],
"size": [
124,
62
]
},
{
"id": "fetch",
"type": "security",
"label": "store_remote_fetch",
"sublabel": "SSRF 가드",
"tag": "DNS 1회 해석",
"pos": [
892,
280
],
"size": [
124,
62
]
},
{
"id": "providers",
"type": "external",
"label": "외부 검색 API",
"sublabel": "Vision · CSE · 네이버",
"pos": [
678,
420
],
"size": [
124,
62
]
}
],
"boundaries": [
{
"kind": "region",
"label": "폐쇄망 (air-gapped) — 런타임 서드파티는 이미지 처리 3종",
"wraps": [
"console",
"throttle",
"http",
"images",
"store",
"sqlite",
"analysis",
"gate",
"ollama",
"fetch"
]
},
{
"kind": "security-group",
"label": "네트워크 경계",
"wraps": [
"gate",
"fetch"
]
}
],
"connections": [
{
"id": "console-api",
"from": "console",
"to": "http",
"label": "REST",
"variant": "emphasis"
},
{
"id": "auth-throttle",
"from": "throttle",
"to": "http",
"label": "인증 시도 제한",
"variant": "security",
"labelDy": 24
},
{
"id": "http-store",
"from": "http",
"to": "store",
"variant": "emphasis"
},
{
"id": "store-sqlite",
"from": "store",
"to": "sqlite",
"label": "payload JSON",
"variant": "emphasis",
"labelDy": 24
},
{
"id": "store-images",
"from": "store",
"to": "images"
},
{
"id": "analysis-store",
"from": "analysis",
"to": "store",
"label": "증거 · 점수 · 밴드",
"labelDy": 24
},
{
"id": "store-gate",
"from": "store",
"to": "gate",
"label": "외부 호출 요청"
},
{
"id": "gate-fetch",
"from": "gate",
"to": "fetch",
"label": "허용 시 통과",
"variant": "security"
},
{
"id": "fetch-providers",
"from": "fetch",
"to": "providers",
"label": "HTTPS",
"variant": "security"
},
{
"id": "store-ollama",
"from": "store",
"to": "ollama",
"label": "판독 요청",
"variant": "dashed"
}
],
"cards": [
{
"dot": "rose",
"title": "경계",
"items": [
"설치·런타임에 인터넷 없음을 전제한다 — CDN · 원격 fetch · 자동업데이트 금지",
"외부로 나가는 경로는 store_remote_fetch 하나뿐이고 SSRF 가드가 앞에 있다",
"게이트는 쿼터와 비상 차단을 본다 — 시드 · 재분석 · 얼굴 크롭 경로 모두 통과한다"
]
},
{
"dot": "emerald",
"title": "서버",
"items": [
"http_app 이 라우팅 · Bearer/세션 인증 · CSP · CSRF · 10MB 바디 상한을 맡는다",
"CopyrighterStore 는 믹스인 8개로 조립되고 SQLite payload JSON 에 쓴다",
"BatchAnalyzer 를 스토어가 구동하고, 증거·점수·밴드가 스토어로 돌아온다"
]
},
{
"dot": "cyan",
"title": "콘솔 · 의존성",
"items": [
"콘솔은 빌드 스텝이 없는 클래식 스크립트 15개로 전역 스코프를 공유한다 — 로드 순서가 계약",
"얼굴 검출은 YuNet(ONNX)이고 가중치는 gitignore 된 models/ 에 있다",
"런타임 서드파티는 numpy · opencv-python-headless · pillow 3종",
"LLM 은 로컬 ollama 선택 사항이며 없으면 연결이 빠르게 실패한다"
]
}
]
}