- src/dist 산출물 분리 원칙 정리(.gitignore, .gitattributes) - 루트 및 주요 폴더(config/scripts/prompts/tests/src, 런타임 폴더 5종)에 안내용 README.md 추가 - CHANGELOG.md, LICENSE, docs/ops/05-release-and-versioning.md 추가 - docs/README.md 문서 지도 갱신
4724 lines
230 KiB
Markdown
4724 lines
230 KiB
Markdown
# 장애 알림과 복구 안내 설계
|
||
|
||
> **이 문서의 역할**: DMF Crawler 가 실패했을 때 **사람이 반드시 알아채고, 그 자리에서 고칠 수 있게** 만드는 알림·복구 계층의 정본(SSOT)이다. 알림 채널 선택과 그 실패 조건, 4등급 체계와 폭주 억제, 상황별 실제 문구 전집, 버튼 액션 구현 코드, `agy` 재로그인 유도 경로, 알림 스크립트 전문, 테스트 절차, 에스컬레이션 규칙까지 이 문서 하나로 구현·검증이 끝나야 한다. 상위 정본은 `docs/design/01-architecture.md`(ADR-10/11/12, §3.14 `alerts.py`, §3.16 `notify/pump.py`, §7 실패 시나리오 대응표)이고, 운영 환경 근거는 `docs/research/08-windows-scheduling-and-resilience.md`, `agy` 사실관계는 `docs/research/05a-agy-cli-ssot.md` 다.
|
||
|
||
---
|
||
|
||
## 0. 한눈에 보기
|
||
|
||
이 문서가 확정하는 것:
|
||
|
||
- **알림 채널은 4단 사다리다.** ① tkinter 토스트(우하단 자동소멸) → ② tkinter 강제 모달(복구 GUI) → ③ Windows 이벤트 로그 + `state/alerts.json`(항상, 무조건) → ④ 웹훅(선택, CRITICAL 전용). **BurntToast·win11toast·pywin32는 쓰지 않는다**(ADR-12 재확인). 외부 모듈 설치가 전제인 알림기는 "설치가 깨지면 알림도 안 뜨는" 순환 실패를 만든다.
|
||
- **토스트가 안 뜨는 조건은 5가지이고, 전부 대응이 있다**: 로그온 전 / Session 0(S4U 배치) / 집중 지원(방해 금지) / 전체 화면 앱 / GUI 스택 자체 손상. 대응의 뼈대는 하나 — **알림을 발생시키는 프로세스와 표시하는 프로세스를 분리하고, 발생 기록은 절대 유실되지 않게 3중으로 남긴다**(ADR-11).
|
||
- **등급은 4개다: INFO / WARN / ERROR / CRITICAL.** 아키텍처 §3.14의 `Severity` 3값(INFO/WARN/CRITICAL)에 **ERROR 를 추가한다**(AMD-01). 판정 기준은 단 하나 — **오늘 xlsx 리포트가 나왔는가**. 나왔으면 최대 WARN, 안 나왔으면 ERROR, 여기에 "반복" 또는 "사람이 손대야만 풀린다"가 붙으면 CRITICAL.
|
||
- **폭주 억제는 3중이다**: ① `dedup_key`(코드+실행일자) + 코드별 쿨다운, ② 같은 pump 주기 내 다건 병합(N건을 토스트 1장으로), ③ 시간당 토스트 상한·일일 알림 상한. CRITICAL 모달만 상한을 면제받되 스누즈(기본 60분)를 갖는다.
|
||
- **문구는 문자열이 아니라 계약이다.** 모든 알림은 **[무엇][왜][어떻게][다음 행동]** 4요소를 반드시 갖고, 하나라도 비면 `raise_alert()`가 `ValueError`를 던진다. 본 문서 §3에 22개 시나리오의 완성 문구 전집을 싣는다.
|
||
- **버튼 액션은 액션 키 레지스트리로 관리한다.** `open_log_dir` / `run_now` / `agy_relogin` / `install_agy` / `enter_api_key` / `reregister_tasks` / `restore_backup` / `open_report_dir` / `cleanup_disk` / `open_doctor` / `snooze` / `dismiss` 12종. **액션 없는 실패 알림은 등록 자체가 거부된다**(막다른 골목 금지, R7.7).
|
||
- **`dmf://` 프로토콜 핸들러는 기본 경로에서 불필요하다.** tkinter 버튼이 같은 프로세스 안에서 함수를 직접 호출하기 때문이다. 다만 진짜 Windows 토스트(액션 센터 잔류)를 선택 의존성으로 켜는 날을 위해 등록 절차를 §4.4에 완비해 둔다.
|
||
- **`agy` 재로그인은 "새 콘솔 창"으로만 가능하다.** S4U 배치 세션에는 데스크톱이 없어 OAuth 브라우저가 뜨지 않는다. 로그온 세션의 pump 가 `cmd.exe /c start` 로 **보이는 콘솔**을 띄우고, 사용자가 로그인을 마치면 pump 가 헬스 프롬프트 1회로 성공을 검증한다.
|
||
- **`scripts/notify.ps1` 을 신설한다**(아키텍처 트리 증분, AMD-02). Python/venv 가 통째로 깨졌을 때(실패 시나리오 #32) tkinter 토스트는 원리적으로 뜰 수 없다. PowerShell 단독으로 도는 최후 알림기가 Agent 작업의 **두 번째 액션**으로 등록되어, pump 가 스탬프를 남기지 못했을 때만 MessageBox 를 띄운다.
|
||
- **에스컬레이션은 3일/5일/7일 3단이다.** 연속 실패 3일 → CRITICAL 모달 + 진단 자동 표시, 5일 → 웹훅 강제 발사(설정돼 있으면), 7일 → 배치 자동 실행 중단(`state/paused.flag`) 후 사람의 명시적 재개를 요구한다. 고장난 배치가 매일 조용히 실패하며 API 를 두드리는 상태를 방치하지 않는다.
|
||
|
||
---
|
||
|
||
## 1. 알림 채널 확정
|
||
|
||
### 1.1 채널 목록과 채택 근거
|
||
|
||
| # | 채널 | 구현 | 프로세스 | 언제 쓰는가 | 채택 |
|
||
|---|---|---|---|---|---|
|
||
| C1 | **자동소멸 토스트** | `notify/toast.py` (tkinter `overrideredirect` 창) | Agent(Interactive) | INFO·WARN. 12초 후 사라짐. 무시해도 되는 알림 | ✅ 기본 |
|
||
| C2 | **강제 모달 복구 창** | `gui/app.py` (tkinter `Toplevel` + `grab_set`) | Agent(Interactive) | ERROR·CRITICAL. 사용자가 액션을 고를 때까지 유지 | ✅ 기본 |
|
||
| C3 | **Windows 이벤트 로그** | `notify/eventlog.py` (`eventcreate.exe`) | 배치·Agent 양쪽 | **전 등급 무조건.** 화면이 없어도 남는 유일한 OS 표준 흔적 | ✅ 기본 |
|
||
| C4 | **상태 파일 미러** | `alerts.mirror_to_file()` → `state/alerts.json` | 배치 | **전 등급 무조건.** Agent 가 SQLite 잠금 없이 읽는 경로 | ✅ 기본 |
|
||
| C5 | **MessageBox 폴백** | `scripts/notify.ps1` (`System.Windows.Forms.MessageBox`) | 별도 PowerShell | Python 이 깨져 C1·C2 가 불가능할 때 | ✅ 폴백 |
|
||
| C6 | **`msg.exe` 세션 메시지** | `scripts/notify.ps1` 내부 | 별도 PowerShell | .NET 로드조차 실패할 때의 최후 수단 | ✅ 최후 |
|
||
| C7 | **웹훅**(Discord/Slack/Telegram/generic) | `notify/webhook.py` | 배치 또는 Agent | CRITICAL·에스컬레이션. **PC 앞에 사람이 없을 때 닿는 유일한 채널** | ⚪ 선택(기본 off) |
|
||
| C8 | **이메일(SMTP)** | — | — | 웹훅으로 대체 | ❌ 기각 |
|
||
| C9 | **BurntToast / win11toast** | PowerShell 모듈 / pip 패키지 | — | — | ❌ 기각(ADR-12) |
|
||
| C10 | **dead-man switch**(healthchecks.io 등) | `notify/webhook.py` 의 ping 모드 | 배치 | PC 가 통째로 꺼져 있는 경우 감지 | ⚪ 선택(기본 off) |
|
||
|
||
**C8(이메일) 기각 근거**: SMTP 는 앱 비밀번호·포트 차단·2FA 정책이라는 실패 표면 3개를 추가하는데, 웹훅은 URL 하나면 되고 실패해도 HTTP 상태 코드로 즉시 진단된다. 요구 N5(의존성 최소)와 R6.3(비개발자가 설정)을 동시에 만족하는 쪽은 웹훅이다.
|
||
|
||
**C9(BurntToast/win11toast) 기각 근거 재확인**: ADR-12 가 이미 기각했다. 추가로, 알림 채널이 외부 모듈에 의존하면 **실패 시나리오 #32(Python/venv 손상)에서 알림 자체가 침묵한다.** 알림기는 시스템에서 가장 의존성이 적어야 하는 컴포넌트다. tkinter 는 CPython 표준 배포에 포함되고, `eventcreate.exe`·`msg.exe`·`powershell.exe` 는 Windows 에 내장된다.
|
||
|
||
### 1.2 토스트가 안 뜨는 조건과 대응
|
||
|
||
| # | 조건 | 왜 안 뜨는가 | 감지 방법 | 대응 |
|
||
|---|---|---|---|---|
|
||
| B1 | **로그온 전 / 로그오프 상태** | 표시할 대화형 세션이 존재하지 않는다 | Agent 작업이 아예 실행되지 않음 | 배치는 `alerts` + `state/alerts.json` + 이벤트 로그에 **축적**. Agent 작업에 **`AtLogOn` 트리거**를 걸어 다음 로그온 즉시 밀린 알림을 병합 표시(시나리오 #31) |
|
||
| B2 | **Session 0 격리(S4U 배치 세션)** | Windows Vista 이후 서비스·비대화형 세션은 사용자 데스크톱과 분리된다. tkinter 창을 만들어도 아무도 못 본다 | `pipeline` 은 애초에 UI 를 호출하지 않는다(ADR-11) | **구조로 해결.** 배치는 알림 **의도**만 기록하고, Interactive 로 도는 Agent 가 표시한다. 최대 지연 15분(`schedule.agent_repeat_minutes`) |
|
||
| B3 | **집중 지원 / 방해 금지 모드** | OS 알림 센터가 억제한다 | 우리 토스트는 OS 알림 API 를 쓰지 않는 **자체 tkinter 창**이므로 **영향받지 않는다** | 해당 없음. 이것이 tkinter 를 택한 부수 이득이다. 단, 전체 화면 앱 위에서는 B4 로 넘어간다 |
|
||
| B4 | **전체 화면 앱(게임·프레젠테이션)** | 우리 창이 `-topmost` 여도 배타적 전체 화면 앞에는 못 온다 | `ctypes` 로 `SHQueryUserNotificationState` 조회 | `QUNS_BUSY`/`QUNS_RUNNING_D3D_FULL_SCREEN`/`QUNS_PRESENTATION_MODE` 이면 **표시를 미루고** 다음 주기에 재시도. CRITICAL 은 미루지 않고 표시하되 이벤트 로그·웹훅을 함께 발사 |
|
||
| B5 | **AppId(AUMID) 미등록** | 진짜 Windows 토스트는 등록된 AUMID 없이는 XML 토스트를 띄울 수 없다 | 해당 없음 | **우리 경로에는 해당하지 않는다.** tkinter 창은 AUMID 를 요구하지 않는다. 선택 의존성으로 win11toast 를 켜는 날에만 §4.4 의 프로토콜/AUMID 등록이 필요해진다 |
|
||
| B6 | **tkinter 자체 손상 / Python 실행 불가** | `import tkinter` 실패, venv 파괴 | Agent 액션이 비0으로 죽고 `state/pump.stamp` 가 갱신되지 않음 | **C5 폴백.** Agent 작업의 2번째 액션 `scripts/notify.ps1 -Mode Guard` 가 스탬프 나이를 보고 MessageBox 를 띄운다(§6.1) |
|
||
| B7 | **Agent 작업 자체가 미등록/비활성** | 사용자가 지웠거나 정책이 껐다 | `checks` ⑨ 가 `Get-ScheduledTask` 로 확인 | 배치가 `TASK_MISSING` CRITICAL 을 기록. 다만 **표시할 주체가 없으므로** 웹훅과 이벤트 로그가 유일한 통로 → 웹훅을 켜라고 온보딩에서 유도 |
|
||
|
||
> **핵심**: B1~B7 중 어느 하나가 걸려도 **알림이 사라지지는 않는다.** `alerts` 테이블은 append-only 이고 `shown_at IS NULL` 인 행은 표시될 때까지 계속 대기한다. "표시 실패"는 지연일 뿐 유실이 아니다.
|
||
|
||
### 1.3 폴백 사다리 (표시 경로 결정)
|
||
|
||
```
|
||
알림 발생 (배치 또는 Agent 내부 워치독)
|
||
│
|
||
├─[항상] alerts 테이블 INSERT ────────────────┐
|
||
├─[항상] state/alerts.json 미러 갱신 ─────────┤ 유실 방지 3중 기록
|
||
└─[항상] Windows 이벤트 로그 기록 ────────────┘
|
||
│
|
||
▼
|
||
Agent(Interactive, 15분 주기 + AtLogOn) 가 pending() 조회
|
||
│
|
||
┌──────────┴───────────────────────────────┐
|
||
│ 등급 판정 │
|
||
├─ INFO / WARN → C1 토스트(12초 자동소멸) │
|
||
│ └ 실패 시 → 다음 주기 재시도(최대 3회) │
|
||
│ └ 3회 실패 → ERROR 로 승격 → C2 │
|
||
├─ ERROR / CRITICAL → C2 강제 모달(복구 GUI) │
|
||
│ └ GUI 기동 실패 → C5 MessageBox │
|
||
│ └ .NET 실패 → C6 msg.exe │
|
||
└────────────────────────────────────────────┘
|
||
│
|
||
[CRITICAL 이고 webhook_enabled] → C7 웹훅 발사(표시 성공 여부와 무관하게)
|
||
│
|
||
표시 성공 → alerts.mark_shown(alert_id) → state/alerts.json 재미러
|
||
```
|
||
|
||
**규칙 3개**
|
||
|
||
1. **기록과 표시는 분리된다.** 표시 실패는 기록을 되돌리지 않는다.
|
||
2. **한 단계 아래로만 떨어진다.** C1 실패가 곧바로 C6 로 가지 않는다. 단계마다 이벤트 로그에 `NOTIFY_DEGRADED` 를 남겨 어느 단에서 떨어졌는지 사후 추적이 된다.
|
||
3. **웹훅은 폴백이 아니라 병렬 채널이다.** CRITICAL 은 화면 표시 성공 여부와 무관하게 웹훅을 쏜다. 화면을 본 사람과 웹훅을 받는 사람이 같은 사람이라도, "PC 앞에 있었는가"는 알 수 없기 때문이다.
|
||
|
||
### 1.4 웹훅 채널 정책
|
||
|
||
| 항목 | 확정 |
|
||
|---|---|
|
||
| 기본 상태 | **off** (`notify.webhook_enabled = false`). 온보딩 GUI 의 선택 단계에서 켠다 |
|
||
| 발사 조건 | `severity >= notify.webhook_min_severity`(기본 `"CRITICAL"`) **또는** 에스컬레이션 5일차 이상 |
|
||
| 지원 형식 | `discord` / `slack` / `telegram` / `generic`(JSON POST) — `notify.webhook_kind` 로 선택 |
|
||
| URL 보관 | **평문 금지.** `secrets_dpapi.save("webhook_url", url)` 로 DPAPI 암호화. `config.toml` 에는 `webhook_kind` 와 on/off 만 들어간다 |
|
||
| 타임아웃 | `notify.webhook_timeout_seconds` 기본 10초. 실패해도 **절대 예외를 전파하지 않는다**(알림기가 파이프라인을 죽이면 안 된다) |
|
||
| 재시도 | 1회만. 웹훅 실패는 이벤트 로그에 `NOTIFY_DEGRADED` 로만 남긴다 |
|
||
| 내용 | 4요소 전문 + `run_id` + 로그 디렉터리 **절대경로**. **API 키·토큰·URL 파라미터는 절대 포함하지 않는다**(`logging.mask_patterns` 를 웹훅 본문에도 적용) |
|
||
| dead-man switch | `notify.deadman_url` 이 설정되면 배치 성공 시 루트 URL, 실패 시 `/fail` 을 GET. PC 가 꺼져 있으면 상대편이 알아챈다 |
|
||
|
||
### 1.5 채널 라우팅 매트릭스
|
||
|
||
| 등급 | C1 토스트 | C2 모달 | C3 이벤트 로그 | C4 alerts.json | C7 웹훅 | 재표시 |
|
||
|---|---|---|---|---|---|---|
|
||
| INFO | 선택(`notify.show_info_toast`, 기본 false) | ✕ | ✅ | ✅ | ✕ | 안 함 |
|
||
| WARN | ✅ 12초 | ✕ | ✅ | ✅ | ✕ | 쿨다운 후 1회 |
|
||
| ERROR | ✕ | ✅ 모달 | ✅ | ✅ | 설정 시 | 60분마다 |
|
||
| CRITICAL | ✕ | ✅ 모달 + 포커스 강제 | ✅ | ✅ | ✅(켜져 있으면) | 60분마다, 해소까지 |
|
||
|
||
---
|
||
|
||
## 2. 알림 등급 체계
|
||
|
||
### 2.1 4등급 정의 — AMD-01 (아키텍처 §3.14 개정)
|
||
|
||
아키텍처 §3.14 의 `Severity` 는 `INFO / WARN / CRITICAL` 3값이다. 이 문서는 **`ERROR` 를 추가해 4값으로 개정한다.**
|
||
|
||
**개정 사유**: 3값 체계에서는 "리포트가 안 나왔다"(오늘 산출물 부재)와 "인증이 만료됐다"(사람이 손대야만 풀림)가 둘 다 `CRITICAL` 로 뭉개진다. 그런데 두 상황의 **필요한 사용자 행동이 다르다** — 전자는 "지금 다시 실행" 한 번이면 풀릴 수 있고, 후자는 반드시 사람이 계정 작업을 해야 한다. 채널·재표시 주기·에스컬레이션 카운터가 전부 갈리므로 등급을 분리하는 편이 싸다.
|
||
|
||
```python
|
||
# src/dmf_crawler/alerts.py (개정)
|
||
class Severity(StrEnum):
|
||
INFO = "INFO"
|
||
WARN = "WARN"
|
||
ERROR = "ERROR"
|
||
CRITICAL = "CRITICAL"
|
||
|
||
@property
|
||
def rank(self) -> int:
|
||
return {"INFO": 0, "WARN": 1, "ERROR": 2, "CRITICAL": 3}[self.value]
|
||
|
||
def at_least(self, other: "Severity") -> bool:
|
||
return self.rank >= other.rank
|
||
```
|
||
|
||
**판정 기준 — 질문 두 개로 끝난다.**
|
||
|
||
```
|
||
Q1. 오늘 xlsx 리포트 파일이 존재하는가?
|
||
+- 예 -> Q2 로
|
||
+- 아니오 -> ERROR (최소)
|
||
Q2. 사람이 개입해야만 풀리는가? / 이미 반복되고 있는가?
|
||
+- 예 -> CRITICAL
|
||
+- 아니오 -> 데이터 품질에 영향이 있으면 WARN, 없으면 INFO
|
||
```
|
||
|
||
| 등급 | 정의 | 실행 상태 | 종료 코드 | 사용자에게 요구하는 것 |
|
||
|---|---|---|---|---|
|
||
| `INFO` | 정상 완료, 또는 자동 회복된 일시 장애 | `SUCCESS` | 0 | **아무것도**. 기록만 남는다 |
|
||
| `WARN` | 부분 실패. **리포트는 생성됐다.** 데이터가 스테일하거나 일부 계층이 빠졌다 | `SUCCESS` 또는 `PARTIAL` | 0 | 인지. 오늘 리포트를 볼 때 배너를 확인 |
|
||
| `ERROR` | **리포트 생성 실패.** 오늘 산출물이 없다. 일시적 원인일 수 있어 재시도 가치가 있다 | `FAILED` | 1 | 지금 조치. "다시 실행" 한 번으로 풀릴 수 있다 |
|
||
| `CRITICAL` | 전제조건 붕괴(인증·키·DB·스케줄) 또는 **연속 실패**. 재시도해도 같은 결과 | `FAILED` / `BLOCKED` | 1 또는 2 | 반드시 사람이 손을 대야 한다. 모달로 막는다 |
|
||
|
||
### 2.2 등급별 채널·표시 방식·빈도
|
||
|
||
| 등급 | 표시 | 지속 | 최초 표시 지연 | 재표시 주기 | 쿨다운(동일 코드) | 웹훅 |
|
||
|---|---|---|---|---|---|---|
|
||
| INFO | (기본 미표시) | — | — | 없음 | 1440분 | ✕ |
|
||
| WARN | 우하단 토스트 | `notify.toast_seconds` 12초 자동소멸 | 최대 15분 | 없음(쿨다운 만료 시 1회) | `notify.cooldown_minutes` 240분 | ✕ |
|
||
| ERROR | 강제 모달(복구 GUI, `recover` 모드) | 사용자가 닫을 때까지 | 최대 15분 | `notify.modal_repeat_minutes` 60분 | 60분 | 설정 시 |
|
||
| CRITICAL | 강제 모달 + `focus_key` 로 해당 체크 항목 강조 + 창 포커스 강제 | 사용자가 닫을 때까지 | 최대 15분 | 60분 | 60분 | 켜져 있으면 ✅ |
|
||
|
||
**표시 지연이 최대 15분인 이유**: Agent 작업의 반복 주기(`schedule.agent_repeat_minutes` 기본 15)다. 요구 N3("24시간 내 인지")에 대해 96배의 여유가 있으므로 더 짧게 만들 이유가 없다. 주기를 줄이면 로그온 세션에서 도는 프로세스 기동 횟수만 늘어난다.
|
||
|
||
### 2.3 `dedup_key` 와 쿨다운
|
||
|
||
**`dedup_key` 산출 규칙**
|
||
|
||
```python
|
||
def make_dedup_key(code: str, run_date: str | None, scope: str | None = None) -> str:
|
||
"""
|
||
code : 알림 코드 (AGY_AUTH, REPORT_LOCKED 등). 대문자 스네이크.
|
||
run_date : YYYY-MM-DD. 하루 단위로 재발생을 허용할 알림만 넣는다.
|
||
scope : 같은 코드라도 대상이 다르면 별개 알림인 경우의 구분자
|
||
(예: 디스크 부족의 드라이브 문자 'D:', 잠긴 파일명).
|
||
"""
|
||
parts = [code]
|
||
if run_date:
|
||
parts.append(run_date)
|
||
if scope:
|
||
parts.append(scope)
|
||
return "|".join(parts)
|
||
```
|
||
|
||
| 코드 유형 | `run_date` 포함? | 이유 |
|
||
|---|---|---|
|
||
| 일자성 실패(`FETCH_FAILED`, `INTEGRITY_BLOCKED`, `REPORT_LOCKED`, `API_QUOTA_EXCEEDED`) | **포함** | 어제 발생했던 것이 오늘 또 발생하면 **새 사건**이다. 알려야 한다 |
|
||
| 상태성 실패(`AGY_AUTH`, `API_KEY_MISSING`, `DB_CORRUPT`, `TASK_MISSING`, `AGY_MISSING`) | **미포함** | 해소될 때까지 하나의 사건이다. 매일 새 알림을 만들면 목록이 오염된다. 재촉은 `modal_repeat_minutes` 가 담당 |
|
||
| 누적성(`CONSECUTIVE_FAILURES`, `WATCHDOG_STALE`) | **미포함** | 위와 동일. 다만 본문의 "N일째"가 갱신된다 |
|
||
|
||
**쿨다운 동작**
|
||
|
||
```
|
||
raise_alert(code=X) 호출
|
||
|
|
||
+- 같은 dedup_key 의 미해소 행이 있는가?
|
||
+- 없다 -> INSERT, occurrences=1, 반환 True (표시 대상)
|
||
+- 있다 -> 마지막 발생 시각으로부터 cooldown 이 지났는가?
|
||
+- 안 지남 -> occurrences += 1, last_seen_at 갱신,
|
||
| 페이로드만 최신으로 UPDATE, 반환 False (표시 안 함)
|
||
+- 지남 -> occurrences += 1, last_seen_at 갱신,
|
||
shown_at = NULL 로 되돌림, 반환 True (재표시)
|
||
```
|
||
|
||
> **`alerts` 는 append-only 인데 UPDATE 를 하는가?** — AMD-03
|
||
> 아키텍처 §3.9 불변식은 "`alerts` 를 UPDATE/DELETE 하지 않는다"고 못박았다. 이 문서는 그 불변식을 **깨지 않기 위해** 테이블을 둘로 나눈다. 발생 사실은 `alert_events`(순수 append-only, 매 발생마다 1행), 표시·해소 상태는 `alerts`(dedup_key 유니크, 상태 머신)다. `occurrences`·`last_seen_at`·`shown_at`·`resolved_at` 은 **상태이지 사실이 아니다.** 감사 추적은 `alert_events` 가 온전히 보존한다.
|
||
|
||
### 2.4 알림 폭주 억제 3중 방어
|
||
|
||
| 층 | 이름 | 대상 | 동작 | 설정 키 |
|
||
|---|---|---|---|---|
|
||
| L1 | **코드별 쿨다운** | 같은 코드의 반복 | 2.3 참조. 쿨다운 안이면 카운터만 증가 | `notify.cooldown_minutes`(WARN 240분), `notify.modal_repeat_minutes`(ERROR/CRITICAL 60분) |
|
||
| L2 | **주기 내 병합** | 서로 다른 코드가 동시에 여러 개 | 한 pump 주기에서 표시 대상이 2건 이상이면 **토스트 1장에 요약**하고 "자세히 보기"로 GUI 를 연다 | `notify.merge_threshold`(기본 2) |
|
||
| L3 | **총량 상한** | 하루 전체 | 시간당 토스트 `notify.max_toasts_per_hour`(6), 일일 알림 표시 `notify.daily_alert_cap`(20) 초과 시 그날은 표시를 멈추고 `NOTIFY_SUPPRESSED` INFO 만 기록 | 위 두 키 |
|
||
|
||
**L3 의 CRITICAL 면제**: CRITICAL 은 상한을 적용받지 않는다. 대신 스누즈(`notify.snooze_minutes` 기본 60)가 있어 사용자가 "나중에"를 누르면 그 시간만큼 조용해진다. 상한으로 CRITICAL 을 막으면 **가장 중요한 알림이 가장 먼저 침묵하는** 역설이 생긴다.
|
||
|
||
**L2 병합 문구 실제 예시**
|
||
|
||
```
|
||
제목: DMF 크롤러 — 확인이 필요한 항목 3건
|
||
본문: 2026-09-02 06:04 실행에서 확인할 항목이 3건 있습니다.
|
||
· [주의] 수집 건수가 어제보다 6.2% 줄어 비교를 건너뛰었습니다
|
||
· [주의] 리포트를 다른 이름으로 저장했습니다 (원본이 열려 있음)
|
||
· [주의] AI 요약을 건너뛰었습니다 (일일 쿼터 소진)
|
||
오늘 리포트는 정상 생성됐습니다.
|
||
버튼: [자세히 보기] [리포트 열기] [닫기]
|
||
```
|
||
|
||
### 2.5 승격 규칙과 해소 규칙
|
||
|
||
| 규칙 | 조건 | 결과 |
|
||
|---|---|---|
|
||
| R-P1 | 같은 WARN 코드가 `notify.consecutive_failure_critical`(기본 3) 회 연속 실행에서 발생 | **CRITICAL 로 승격.** 코드에 `_PERSIST` 접미사를 붙인 별개 알림 발생 |
|
||
| R-P2 | `runs` 에서 `status='FAILED'` 가 3일 연속 | `CONSECUTIVE_FAILURES` CRITICAL 발생 (8절) |
|
||
| R-P3 | WARN 토스트 표시가 3회 연속 실패(창 생성 예외) | 해당 알림을 ERROR 로 승격해 모달 경로로 보냄 |
|
||
| R-P4 | 서킷 브레이커가 `CLOSED -> OPEN` 으로 전환 | 전환 **그 순간에만** CRITICAL 1회. OPEN 유지 동안은 침묵 |
|
||
| R-P5 | 워치독이 heartbeat 나이 > `notify.watchdog_stale_minutes`(120) 판정 | `WATCHDOG_STALE` CRITICAL |
|
||
|
||
| 규칙 | 조건 | 결과 |
|
||
|---|---|---|
|
||
| R-D1 | 다음 실행이 `SUCCESS` 로 끝남 | 일자성 WARN 코드 전부 `resolve(code, note="다음 실행 성공")` |
|
||
| R-D2 | `checks.run_one(key)` 가 `ok=True` 로 바뀜 | 해당 상태성 CRITICAL 해소 (예: 재로그인 후 `AGY_AUTH`) |
|
||
| R-D3 | 서킷이 `OPEN -> HALF_OPEN -> CLOSED` 복귀 | `*_CIRCUIT_OPEN` 해소 + INFO 1회("자동 복구됨") |
|
||
| R-D4 | 사용자가 모달에서 액션을 수행하고 성공 검증을 통과 | 즉시 해소. **모달을 닫기만 해서는 해소되지 않는다** |
|
||
|
||
> **해소는 사용자가 창을 닫는 것으로 이뤄지지 않는다.** 반드시 `checks` 재실행 또는 다음 실행 성공이라는 **객관적 증거**가 있어야 한다. "닫으면 해결된 것으로 친다"는 조용한 실패의 교과서적 원인이다.
|
||
|
||
### 2.6 `alerts` / `alert_events` DDL
|
||
|
||
`storage/migrations/0003_ops.sql` 의 알림 관련 부분 전문이다.
|
||
|
||
```sql
|
||
-- ============================================================
|
||
-- 0003_ops.sql — 운영 계층 (알림 부분)
|
||
-- ============================================================
|
||
|
||
-- 발생 사실. 순수 append-only. 절대 UPDATE/DELETE 하지 않는다.
|
||
CREATE TABLE IF NOT EXISTS alert_events (
|
||
event_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
occurred_at TEXT NOT NULL, -- ISO8601 with offset (+09:00)
|
||
run_id TEXT, -- 실행 밖(Agent 워치독)이면 NULL
|
||
code TEXT NOT NULL,
|
||
severity TEXT NOT NULL CHECK (severity IN ('INFO','WARN','ERROR','CRITICAL')),
|
||
dedup_key TEXT NOT NULL,
|
||
what TEXT NOT NULL,
|
||
why TEXT NOT NULL,
|
||
how TEXT NOT NULL,
|
||
next_actions TEXT NOT NULL, -- JSON 배열: ["run_now","open_log_dir"]
|
||
context_json TEXT NOT NULL DEFAULT '{}', -- 템플릿 치환에 쓴 값 원본
|
||
log_dir TEXT, -- 절대경로
|
||
source TEXT NOT NULL DEFAULT 'pipeline' -- pipeline|watchdog|checks|agent
|
||
);
|
||
CREATE INDEX IF NOT EXISTS ix_alert_events_time ON alert_events(occurred_at DESC);
|
||
CREATE INDEX IF NOT EXISTS ix_alert_events_code ON alert_events(code, occurred_at DESC);
|
||
|
||
-- 표시·해소 상태 머신. dedup_key 당 1행.
|
||
CREATE TABLE IF NOT EXISTS alerts (
|
||
alert_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
dedup_key TEXT NOT NULL UNIQUE,
|
||
code TEXT NOT NULL,
|
||
severity TEXT NOT NULL CHECK (severity IN ('INFO','WARN','ERROR','CRITICAL')),
|
||
first_seen_at TEXT NOT NULL,
|
||
last_seen_at TEXT NOT NULL,
|
||
occurrences INTEGER NOT NULL DEFAULT 1,
|
||
last_event_id INTEGER NOT NULL REFERENCES alert_events(event_id),
|
||
shown_at TEXT, -- NULL 이면 표시 대기
|
||
shown_channel TEXT, -- toast|modal|messagebox|msgexe|webhook
|
||
show_attempts INTEGER NOT NULL DEFAULT 0,
|
||
snoozed_until TEXT,
|
||
resolved_at TEXT,
|
||
resolved_note TEXT
|
||
);
|
||
CREATE INDEX IF NOT EXISTS ix_alerts_pending
|
||
ON alerts(resolved_at, shown_at, severity);
|
||
CREATE INDEX IF NOT EXISTS ix_alerts_code ON alerts(code);
|
||
|
||
-- 일일 표시 총량 상한(L3) 계산용 뷰
|
||
CREATE VIEW IF NOT EXISTS v_alert_shown_today AS
|
||
SELECT COUNT(*) AS shown_count
|
||
FROM alerts
|
||
WHERE shown_at IS NOT NULL
|
||
AND substr(shown_at, 1, 10) = strftime('%Y-%m-%d', 'now', 'localtime');
|
||
```
|
||
|
||
**표시 대기 알림 조회 SQL** (`alerts.pending()` 의 본체)
|
||
|
||
```sql
|
||
SELECT a.alert_id, a.dedup_key, a.code, a.severity,
|
||
a.first_seen_at, a.last_seen_at, a.occurrences, a.show_attempts,
|
||
e.what, e.why, e.how, e.next_actions, e.context_json, e.log_dir, e.run_id
|
||
FROM alerts a
|
||
JOIN alert_events e ON e.event_id = a.last_event_id
|
||
WHERE a.resolved_at IS NULL
|
||
AND (a.snoozed_until IS NULL OR a.snoozed_until < :now)
|
||
AND (
|
||
a.shown_at IS NULL -- 아직 못 보여줌
|
||
OR (a.severity IN ('ERROR','CRITICAL') -- 재촉 대상
|
||
AND julianday(:now) - julianday(a.shown_at)
|
||
> :modal_repeat_minutes / 1440.0)
|
||
)
|
||
ORDER BY CASE a.severity WHEN 'CRITICAL' THEN 0 WHEN 'ERROR' THEN 1
|
||
WHEN 'WARN' THEN 2 ELSE 3 END,
|
||
a.last_seen_at DESC;
|
||
```
|
||
|
||
**연속 실패 일수 조회 SQL** (8절 에스컬레이션용)
|
||
|
||
```sql
|
||
WITH daily AS (
|
||
SELECT substr(started_at, 1, 10) AS d,
|
||
MAX(CASE WHEN status IN ('SUCCESS','PARTIAL') THEN 1 ELSE 0 END) AS ok
|
||
FROM runs
|
||
WHERE started_at >= date('now', '-14 days')
|
||
GROUP BY d
|
||
),
|
||
ranked AS (
|
||
SELECT d, ok, ROW_NUMBER() OVER (ORDER BY d DESC) AS rn
|
||
FROM daily
|
||
)
|
||
SELECT COALESCE(MIN(rn), 0) - 1 AS consecutive_failed_days
|
||
FROM ranked
|
||
WHERE ok = 1;
|
||
-- 성공 행이 하나도 없으면 결과가 -1 이 되므로 호출측에서
|
||
-- COUNT(*) FROM daily 로 대체 계산한다(코드 6.6 참조).
|
||
```
|
||
|
||
### 2.7 `state/alerts.json` 미러 스키마
|
||
|
||
Agent 프로세스가 **SQLite 를 열지 않고도** 알림 유무를 알 수 있어야 한다(배치가 DB 를 잠그고 있을 수 있고, PowerShell 폴백은 sqlite3 를 못 읽는다). 원자적 교체(`os.replace`)로 갱신한다.
|
||
|
||
```json
|
||
{
|
||
"schema": 1,
|
||
"updated_at": "2026-09-02T06:04:11+09:00",
|
||
"host": "DESKTOP-ABC",
|
||
"pending": [
|
||
{
|
||
"alert_id": 42,
|
||
"dedup_key": "AGY_AUTH",
|
||
"code": "AGY_AUTH",
|
||
"severity": "CRITICAL",
|
||
"first_seen_at": "2026-09-01T06:03:55+09:00",
|
||
"last_seen_at": "2026-09-02T06:03:58+09:00",
|
||
"occurrences": 2,
|
||
"title": "AI 요약을 만들지 못했습니다 — 로그인 만료",
|
||
"what": "AI 요약 단계가 로그인 만료로 중단됐습니다.",
|
||
"why": "Antigravity CLI(agy)의 Google 계정 인증이 만료되어 자동 갱신에 실패했습니다.",
|
||
"how": "아래 [로그인 창 열기]를 누르고 Google 계정으로 다시 로그인하세요. 약 1분 걸립니다.",
|
||
"next_actions": ["agy_relogin", "open_log_dir", "snooze", "dismiss"],
|
||
"log_dir": "D:\\workspace\\DMF_Crawler\\logs\\run_20260902_060012",
|
||
"run_id": "20260902_060012"
|
||
}
|
||
],
|
||
"counts": {"INFO": 0, "WARN": 1, "ERROR": 0, "CRITICAL": 1}
|
||
}
|
||
```
|
||
|
||
### 2.8 Windows 이벤트 로그 ID 배정
|
||
|
||
**결정적 제약**: `eventcreate.exe` 는 **`/ID` 를 1~1000 범위로만 받는다.** 범위를 벗어나면 `오류: 잘못된 인수/옵션` 으로 실패한다. 연구 문서 08 에 나온 `1001` 은 그대로 쓸 수 없다. 아래 표가 정본이다.
|
||
|
||
| ID | `/T` | 의미 | 기록 주체 |
|
||
|---|---|---|---|
|
||
| 100 | INFORMATION | 실행 시작 | pipeline |
|
||
| 110 | SUCCESS | 실행 성공(리포트 생성 완료) | pipeline |
|
||
| 120 | WARNING | 실행 부분 성공(PARTIAL) | pipeline |
|
||
| 130 | INFORMATION | 중복 실행 스킵(SKIPPED) | pipeline |
|
||
| 200 | WARNING | WARN 등급 알림 발생 | alerts |
|
||
| 300 | ERROR | ERROR 등급 알림 발생(리포트 미생성) | alerts |
|
||
| 400 | ERROR | CRITICAL 등급 알림 발생 | alerts |
|
||
| 410 | ERROR | 워치독 — 배치 미실행 감지 | watchdog |
|
||
| 420 | ERROR | 연속 실패 임계 도달 | watchdog |
|
||
| 430 | ERROR | 자동 실행 일시중지(7일차 에스컬레이션) | watchdog |
|
||
| 500 | INFORMATION | 알림 표시 성공 | notify.pump |
|
||
| 510 | WARNING | 알림 표시 실패 — 한 단계 강등(`NOTIFY_DEGRADED`) | notify.pump |
|
||
| 520 | WARNING | 알림 표시 억제(일일 상한 도달) | notify.pump |
|
||
| 900 | ERROR | 알리미 자신이 죽음(pump 예외) | notify.pump |
|
||
| 910 | ERROR | PowerShell 폴백 알림기가 발동함(= Python 계층이 죽었다는 증거) | notify.ps1 |
|
||
|
||
**소스 이름**: `notify.eventlog_source` = `"DMF Crawler"`. `eventcreate` 의 `/SO` 는 **이미 시스템에 등록된 원본 이름과 충돌하면 안 되고**, 지정한 이름은 Application 로그에 자동 등록된다. 표준 사용자 권한으로 Application 로그 쓰기는 가능하다. (⚠️ 미검증: 그룹 정책으로 Application 로그 쓰기가 제한된 환경에서의 동작)
|
||
|
||
**조회 명령**
|
||
|
||
```powershell
|
||
# 최근 20건
|
||
Get-WinEvent -FilterHashtable @{LogName='Application'; ProviderName='DMF Crawler'} -MaxEvents 20 |
|
||
Format-Table TimeCreated, Id, LevelDisplayName, Message -AutoSize -Wrap
|
||
|
||
# CRITICAL 만
|
||
Get-WinEvent -FilterHashtable @{LogName='Application'; ProviderName='DMF Crawler'; Id=400,410,420,430} |
|
||
Select-Object TimeCreated, Id, Message
|
||
```
|
||
|
||
---
|
||
|
||
## 3. 알림 문구 전집
|
||
|
||
### 3.1 4요소 계약
|
||
|
||
모든 알림은 아래 4요소를 **반드시** 갖는다. `raise_alert()` 는 하나라도 비면 `ValueError` 를 던진다 — 규격을 문서가 아니라 **코드 계약**으로 승격시킨다(아키텍처 §3.14).
|
||
|
||
| 요소 | 질문 | 작성 규칙 |
|
||
|---|---|---|
|
||
| **무엇** (`what`) | 무엇이 실패했는가 / 언제 | 한 문장. **시각을 반드시 포함**한다(`2026-09-02 06:04`). 기술 용어 대신 사용자가 아는 말로 — "fetch 스테이지"가 아니라 "식약처 데이터 받아오기" |
|
||
| **왜** (`why`) | 왜 그런가 | 한두 문장. 원인을 **추정이 아니라 관측된 사실**로 쓴다. 모르면 "원인을 특정하지 못했습니다"라고 정직하게 쓴다 |
|
||
| **어떻게** (`how`) | 지금 무엇을 하면 되는가 | **명령형 한 문장 + 소요 시간.** "약 1분 걸립니다" 같은 시간 표시가 착수율을 크게 올린다. 여러 방법이 있으면 가장 쉬운 것 하나만 |
|
||
| **다음 행동** (`next_actions`) | 누를 것 | **액션 키 배열.** 최소 1개(막다른 골목 금지, R7.7). `dismiss` 만 있는 실패 알림은 등록 거부된다 |
|
||
|
||
**추가 필수 필드**
|
||
|
||
| 필드 | 규칙 |
|
||
|---|---|
|
||
| `title` | 40자 이내. 토스트 제목줄에 잘리지 않아야 한다. 등급 접두어를 붙이지 않는다(색으로 표현) |
|
||
| `log_dir` | **절대경로.** 상대경로는 사용자가 어디서 여는지 몰라 쓸모없다 |
|
||
| `where` | 본문 말미에 자동 부착: `로그: <log_dir>` |
|
||
|
||
**금지 표현**
|
||
|
||
| 금지 | 이유 | 대체 |
|
||
|---|---|---|
|
||
| "오류가 발생했습니다" | 아무 정보가 없다 | 실제로 무엇이 실패했는지 |
|
||
| "관리자에게 문의하세요" | 1인 운영에서 관리자는 본인이다 | 구체적 조치 |
|
||
| 예외 클래스명·스택트레이스 | 비개발자가 읽을 수 없다 | 사람의 말. 스택은 로그에만 |
|
||
| "잠시 후 다시 시도하세요" (단독) | 언제까지 기다릴지 모른다 | "내일 06:00에 자동으로 다시 시도합니다" |
|
||
| API 키·토큰·URL 쿼리스트링 | 유출 | 앞 8자 지문만 |
|
||
|
||
### 3.2 시나리오 카탈로그
|
||
|
||
| # | 코드 | 등급 | 트리거 | dedup 에 날짜 포함 | 쿨다운 | 기본 채널 |
|
||
|---|---|---|---|---|---|---|
|
||
| S01 | `RUN_OK` | INFO | 실행 성공 | 예 | 1440분 | 로그·이벤트로그만 |
|
||
| S02 | `FETCH_FAILED` | WARN | 재시도 4회 소진 | 예 | 240분 | 토스트 |
|
||
| S03 | `SOURCE_CIRCUIT_OPEN` | CRITICAL | 서킷 CLOSED→OPEN 전환 | 아니오 | 60분 | 모달 + 웹훅 |
|
||
| S04 | `HTTP_BLOCKED_BODY` | WARN | 200인데 본문이 빈/HTML/차단 안내 | 예 | 240분 | 토스트 |
|
||
| S05 | `ZERO_RECORDS` | CRITICAL | `totalCount`>0인데 수집 0건, 또는 `totalCount`=0 | 예 | 60분 | 모달 + 웹훅 |
|
||
| S06 | `INTEGRITY_BLOCKED` | WARN | 무결성 게이트 2/4/5 차단 | 예 | 240분 | 토스트 |
|
||
| S07 | `INTEGRITY_DROP` | CRITICAL | 게이트 3 — `totalCount` 5% 이상 급감 | 예 | 60분 | 모달 + 웹훅 |
|
||
| S08 | `SCHEMA_DRIFT` | CRITICAL | 예상 필드 부재 / 널 비율 급증 | 예 | 60분 | 모달 + 웹훅 |
|
||
| S09 | `API_KEY_MISSING` | CRITICAL | DPAPI 복호화 결과 없음 | 아니오 | 60분 | 모달(강제) |
|
||
| S10 | `API_KEY_INVALID` | CRITICAL | `resultCode != "00"` / HTTP 401·403 | 아니오 | 60분 | 모달(강제) |
|
||
| S11 | `API_QUOTA_EXCEEDED` | WARN | 일일 트래픽 초과 오류 코드 | 예 | 240분 | 토스트 |
|
||
| S12 | `AGY_MISSING` | CRITICAL | `agy.exe` 경로 부재 | 아니오 | 60분 | 모달 |
|
||
| S13 | `AGY_AUTH` | CRITICAL | `classify_error(env) == AUTH` | 아니오 | 60분 | 모달 + 웹훅 |
|
||
| S14 | `AGY_QUOTA` | WARN | `classify_error(env) == QUOTA` | 예 | 240분 | 토스트 |
|
||
| S15 | `AGY_TIMEOUT` | INFO | `--print-timeout` 초과 | 예 | 1440분 | 로그만 |
|
||
| S16 | `REPORT_LOCKED` | WARN | `os.replace` → `PermissionError` 3회 | 예 | 240분 | 토스트 |
|
||
| S17 | `REPORT_FAILED` | ERROR | report 스테이지 실패, 파일 없음 | 예 | 60분 | 모달 |
|
||
| S18 | `DISK_LOW` | WARN | 여유 < `backup.min_free_gb` | 예 + scope=드라이브 | 240분 | 토스트 |
|
||
| S19 | `DB_LOCKED` | ERROR | `sqlite3.OperationalError: database is locked` | 예 | 60분 | 모달 |
|
||
| S20 | `DB_CORRUPT` | CRITICAL | `PRAGMA integrity_check` 실패 | 아니오 | 60분 | 모달(강제) |
|
||
| S21 | `MIGRATION_FAILED` | CRITICAL | 마이그레이션 중 예외 | 아니오 | 60분 | 모달(강제) |
|
||
| S22 | `WATCHDOG_STALE` | CRITICAL | heartbeat 나이 > 120분 | 아니오 | 60분 | 모달 + 웹훅 |
|
||
| S23 | `TASK_MISSING` | CRITICAL | `checks` ⑨ 실패 | 아니오 | 60분 | 모달 |
|
||
| S24 | `CONSECUTIVE_FAILURES` | CRITICAL | 3일 연속 FAILED | 아니오 | 60분 | 모달 + 웹훅 |
|
||
| S25 | `PYTHON_BROKEN` | CRITICAL | pump 스탬프 미갱신(PowerShell 폴백이 판정) | 아니오 | 240분 | MessageBox |
|
||
| S26 | `NOTIFY_DEGRADED` | WARN | 표시 채널이 한 단계 떨어짐 | 예 + scope=채널 | 240분 | 이벤트로그만 |
|
||
| S27 | `RUN_PAUSED` | CRITICAL | 7일 연속 실패로 자동 실행 중단 | 아니오 | 60분 | 모달 + 웹훅 |
|
||
|
||
> **"셀렉터 깨짐" 시나리오는 이 프로젝트에 존재하지 않는다.** ADR-01/ADR-06 이 HTML 스크래핑과 브라우저 자동화를 코드에서 제거했으므로 CSS/XPath 셀렉터라는 개념 자체가 없다. **기능적으로 등가인 실패는 S08 `SCHEMA_DRIFT`(API 응답 필드 구조 변경)** 이며, 아래 문구는 그 전제로 작성했다.
|
||
|
||
---
|
||
|
||
### 3.3 문구 전문
|
||
|
||
각 항목은 **토스트(WARN)** 또는 **모달(ERROR/CRITICAL)** 로 렌더링된 최종 결과다. `{중괄호}` 는 `context_json` 에서 치환된다.
|
||
|
||
---
|
||
|
||
#### S02 `FETCH_FAILED` — 크롤링(수집) 실패 · WARN
|
||
|
||
```
|
||
제목 식약처 데이터를 받아오지 못했습니다
|
||
|
||
본문 [무엇] {run_date} {run_time} 실행에서 식약처 DMF 목록을 받아오지 못했습니다.
|
||
{pages_ok}/{pages_total} 페이지까지만 받았습니다.
|
||
[왜] 공공데이터포털 API 응답이 {attempts}회 연속 실패했습니다.
|
||
마지막 오류: {last_error_short}
|
||
[어떻게] 오늘 리포트는 마지막으로 성공한 {stale_date} 자료로 만들었습니다.
|
||
내일 06:00에 자동으로 다시 시도합니다. 급하면 [지금 다시 실행]을 누르세요(약 2분).
|
||
|
||
로그: {log_dir}
|
||
|
||
버튼 [지금 다시 실행] [로그 열기] [리포트 열기] [닫기]
|
||
```
|
||
|
||
---
|
||
|
||
#### S03 `SOURCE_CIRCUIT_OPEN` — 소스 차단·연속 실패로 호출 중단 · CRITICAL
|
||
|
||
```
|
||
제목 식약처 API 호출을 24시간 중단했습니다
|
||
|
||
본문 [무엇] {run_date} {run_time} 기준, 식약처 데이터 수집이 {fail_count}회 연속 실패해
|
||
자동 호출을 중단(차단 회로 열림)했습니다.
|
||
[왜] 같은 오류가 반복되면 상대 서버에 부담을 주고 차단당할 수 있어,
|
||
{cooldown_hours}시간 동안 호출 자체를 멈춥니다.
|
||
최근 오류: {last_error_short}
|
||
[어떻게] 원인을 확인하려면 [진단 실행]을 누르세요(약 10초).
|
||
원인이 해결됐다고 판단되면 [지금 다시 실행]으로 즉시 재시도할 수 있습니다.
|
||
그대로 두면 {resume_at}에 자동으로 한 번 시험 호출합니다.
|
||
|
||
로그: {log_dir}
|
||
|
||
버튼 [진단 실행] [지금 다시 실행] [로그 열기] [나중에]
|
||
```
|
||
|
||
---
|
||
|
||
#### S04 `HTTP_BLOCKED_BODY` — 차단 감지(200인데 내용이 없음) · WARN
|
||
|
||
```
|
||
제목 API가 정상 응답 대신 안내 페이지를 보냈습니다
|
||
|
||
본문 [무엇] {run_date} {run_time} 실행에서 {page_no}페이지 요청에 대해
|
||
정상 코드(HTTP 200)가 왔지만 내용이 데이터가 아니었습니다.
|
||
[왜] 응답 본문이 {body_bytes}바이트로 최소 기준({min_bytes}바이트)에 못 미치거나
|
||
HTML 안내 페이지였습니다. 점검 중이거나 호출이 차단됐을 때 나타나는 형태입니다.
|
||
[어떻게] 받은 원문을 그대로 보관했습니다. [원문 폴더 열기]에서 직접 확인할 수 있습니다.
|
||
오늘 비교는 건너뛰었고 리포트는 이전 자료로 생성했습니다.
|
||
내일 06:00에 자동 재시도합니다.
|
||
|
||
원문: {raw_dir}
|
||
로그: {log_dir}
|
||
|
||
버튼 [원문 폴더 열기] [지금 다시 실행] [로그 열기] [닫기]
|
||
```
|
||
|
||
---
|
||
|
||
#### S05 `ZERO_RECORDS` — 0건 수집 · CRITICAL
|
||
|
||
```
|
||
제목 수집 결과가 0건입니다 — 비교를 중단했습니다
|
||
|
||
본문 [무엇] {run_date} {run_time} 실행에서 원료의약품 등록(DMF) 자료를 한 건도 받지 못했습니다.
|
||
(API가 알려준 전체 건수: {total_count}건, 실제 받은 건수: 0건)
|
||
[왜] 0건을 그대로 받아들이면 어제 있던 {prev_count}건 전부가
|
||
"취하됨"으로 잘못 기록됩니다. 그래서 저장과 비교를 모두 중단했습니다.
|
||
[어떻게] 오늘 리포트는 {stale_date} 자료로 만들었고 맨 위에 경고 배너가 붙어 있습니다.
|
||
[원문 폴더 열기]에서 실제 응답을 확인하세요(약 1분).
|
||
공공데이터포털 점검 중이면 내일 자동 복구됩니다.
|
||
|
||
원문: {raw_dir}
|
||
로그: {log_dir}
|
||
|
||
버튼 [원문 폴더 열기] [진단 실행] [지금 다시 실행] [나중에]
|
||
```
|
||
|
||
---
|
||
|
||
#### S06 `INTEGRITY_BLOCKED` — 무결성 게이트 차단 · WARN
|
||
|
||
```
|
||
제목 데이터 검증에 걸려 오늘 비교를 건너뛰었습니다
|
||
|
||
본문 [무엇] {run_date} {run_time} 실행에서 안전장치 "{gate_name}"에 걸려
|
||
오늘 자료를 기준값으로 저장하지 않았습니다.
|
||
[왜] {gate_detail}
|
||
(기준: {gate_threshold} / 실측: {gate_observed})
|
||
기준을 벗어난 자료를 저장하면 이후 모든 비교가 오염됩니다.
|
||
[어떻게] 오늘 리포트는 정상 생성됐지만 "오늘 변경분" 시트는 비어 있습니다.
|
||
받은 원문은 {raw_dir}에 보관돼 있습니다.
|
||
내일 정상 수집되면 자동으로 복구됩니다. 확인이 필요하면 [원문 폴더 열기].
|
||
|
||
로그: {log_dir}
|
||
|
||
버튼 [리포트 열기] [원문 폴더 열기] [로그 열기] [닫기]
|
||
```
|
||
|
||
---
|
||
|
||
#### S07 `INTEGRITY_DROP` — 전체 건수 급감 · CRITICAL
|
||
|
||
```
|
||
제목 전체 등록 건수가 {drop_pct}% 줄었습니다 — 확인이 필요합니다
|
||
|
||
본문 [무엇] {run_date} {run_time} 실행에서 전체 DMF 등록 건수가
|
||
{prev_count}건 → {curr_count}건으로 {drop_count}건({drop_pct}%) 줄었습니다.
|
||
[왜] 하루 만에 {threshold_pct}% 이상 줄어드는 것은 정상적인 변동이 아닙니다.
|
||
대량 취하가 실제로 일어났거나, API가 일부 자료만 내려준 경우입니다.
|
||
오탐으로 "전건 취하" 리포트가 나가는 것을 막기 위해 비교를 중단했습니다.
|
||
[어떻게] [원문 폴더 열기]에서 오늘 받은 응답의 totalCount를 직접 확인하세요(약 2분).
|
||
실제로 정상적인 감소라면 [지금 다시 실행 (검증 우회)]를 눌러 반영할 수 있습니다.
|
||
|
||
원문: {raw_dir}
|
||
로그: {log_dir}
|
||
|
||
버튼 [원문 폴더 열기] [지금 다시 실행 (검증 우회)] [로그 열기] [나중에]
|
||
```
|
||
|
||
---
|
||
|
||
#### S08 `SCHEMA_DRIFT` — API 응답 구조 변경(구 "셀렉터 깨짐" 등가) · CRITICAL
|
||
|
||
```
|
||
제목 식약처 API 응답 형식이 바뀐 것 같습니다
|
||
|
||
본문 [무엇] {run_date} {run_time} 실행에서 필수 항목 {missing_fields_str}을(를)
|
||
응답에서 찾지 못했습니다. 값이 빈 비율이 {null_ratio_pct}%까지 올랐습니다.
|
||
(평소 {baseline_null_pct}% 이하)
|
||
[왜] 공공데이터포털 쪽에서 응답 필드 이름이나 구조를 바꿨을 가능성이 큽니다.
|
||
잘못 해석한 자료를 저장하지 않기 위해 저장과 비교를 중단했습니다.
|
||
[어떻게] 오늘 받은 원문 전체를 보관했습니다. 이 원문만 있으면 나중에 통째로
|
||
다시 해석할 수 있으니 자료는 유실되지 않습니다.
|
||
[원문 폴더 열기]로 실제 응답을 확인하고, 필드 이름이 바뀌었다면
|
||
프로그램의 매핑을 고쳐야 합니다. AI 진단 결과가 있으면 {proposal_path}에 있습니다.
|
||
|
||
원문: {raw_dir}
|
||
로그: {log_dir}
|
||
|
||
버튼 [원문 폴더 열기] [AI 진단 결과 열기] [진단 실행] [나중에]
|
||
```
|
||
|
||
> **AI 제안은 자동 적용되지 않는다**(ADR-25). `state/proposals/` 에 저장만 하고 사람이 승인한다. 이 버튼은 파일을 여는 것 이상을 하지 않는다.
|
||
|
||
---
|
||
|
||
#### S09 `API_KEY_MISSING` — API 키 미설정 · CRITICAL (강제 창)
|
||
|
||
```
|
||
제목 공공데이터포털 인증키가 없습니다
|
||
|
||
본문 [무엇] {run_date} {run_time}에 배치를 시작하려 했으나
|
||
공공데이터포털 인증키가 저장돼 있지 않아 실행하지 못했습니다.
|
||
[왜] 이 프로그램은 식약처 원료의약품 등록 자료를 공공데이터포털 공식 API로 받아옵니다.
|
||
API를 쓰려면 무료 인증키가 필요하고, 아직 한 번도 입력되지 않았습니다.
|
||
[어떻게] [인증키 입력]을 누르면 입력 창이 열립니다.
|
||
키가 없다면 [발급 페이지 열기]로 공공데이터포털에서 신청하세요(무료, 약 3분).
|
||
입력하면 즉시 실제 호출로 검증한 뒤 이 PC에만 암호화해 저장합니다.
|
||
|
||
* 이 알림은 키가 저장될 때까지 계속 표시됩니다.
|
||
|
||
버튼 [인증키 입력] [발급 페이지 열기] [나중에]
|
||
```
|
||
|
||
---
|
||
|
||
#### S10 `API_KEY_INVALID` — 인증키 무효·만료 · CRITICAL (강제 창)
|
||
|
||
```
|
||
제목 공공데이터포털 인증키가 거부됐습니다
|
||
|
||
본문 [무엇] {run_date} {run_time} 실행에서 식약처 API가 인증키를 거부했습니다.
|
||
(응답 코드: {result_code} / {result_msg})
|
||
[왜] 저장된 키(앞 8자: {key_fingerprint}…)가 만료됐거나,
|
||
해당 서비스의 활용 신청이 승인되지 않았거나, 키를 잘못 붙여넣었을 수 있습니다.
|
||
특히 "인코딩 키"를 붙여넣으면 이 오류가 납니다 — **디코딩 키**를 써야 합니다.
|
||
[어떻게] [인증키 다시 입력]을 눌러 공공데이터포털 마이페이지의
|
||
**일반 인증키(Decoding)** 값을 붙여넣으세요(약 2분).
|
||
입력 즉시 실제 호출로 검증하므로 맞는지 바로 알 수 있습니다.
|
||
|
||
로그: {log_dir}
|
||
|
||
버튼 [인증키 다시 입력] [마이페이지 열기] [로그 열기] [나중에]
|
||
```
|
||
|
||
---
|
||
|
||
#### S11 `API_QUOTA_EXCEEDED` — 일일 트래픽 초과 · WARN
|
||
|
||
```
|
||
제목 오늘 API 사용량을 모두 썼습니다
|
||
|
||
본문 [무엇] {run_date} {run_time} 실행에서 공공데이터포털이
|
||
일일 트래픽 초과를 알려왔습니다. ({error_code})
|
||
[왜] 개발계정의 하루 호출 한도(기본 10,000회)를 넘었습니다.
|
||
오늘 {calls_today}회 호출했습니다. 한도는 매일 자정에 초기화됩니다.
|
||
[어떻게] 오늘은 더 호출하지 않고 마지막으로 성공한 {stale_date} 자료로 리포트를 만들었습니다.
|
||
내일 06:00에 자동으로 정상 수집합니다. 아무것도 하지 않아도 됩니다.
|
||
매일 반복된다면 공공데이터포털에서 운영계정 전환을 신청하세요.
|
||
|
||
로그: {log_dir}
|
||
|
||
버튼 [리포트 열기] [로그 열기] [닫기]
|
||
```
|
||
|
||
---
|
||
|
||
#### S12 `AGY_MISSING` — agy 미설치 · CRITICAL
|
||
|
||
```
|
||
제목 AI 요약 도구(agy)가 설치돼 있지 않습니다
|
||
|
||
본문 [무엇] {run_date} {run_time} 실행에서 AI 요약 단계를 건너뛰었습니다.
|
||
Antigravity CLI(agy) 실행 파일을 찾지 못했습니다.
|
||
찾아본 경로: {agy_path}
|
||
[왜] AI 요약은 매일 변경 내용을 자연어 브리핑으로 정리하는 부가 기능입니다.
|
||
도구가 설치되지 않았거나 경로가 바뀌었습니다.
|
||
[어떻게] [지금 설치]를 누르면 공식 설치 스크립트를 자동으로 실행합니다(약 2분, 인터넷 필요).
|
||
설치 후 최초 1회 Google 계정 로그인이 필요하며 창이 자동으로 열립니다.
|
||
AI 요약이 필요 없다면 [AI 기능 끄기]를 눌러 이 알림을 영구히 멈출 수 있습니다.
|
||
|
||
* 오늘 리포트는 AI 요약 없이 정상 생성됐습니다.
|
||
|
||
버튼 [지금 설치] [AI 기능 끄기] [리포트 열기] [나중에]
|
||
```
|
||
|
||
---
|
||
|
||
#### S13 `AGY_AUTH` — agy 인증 만료 · CRITICAL (강제 창)
|
||
|
||
```
|
||
제목 AI 요약을 만들지 못했습니다 — 로그인 만료
|
||
|
||
본문 [무엇] {run_date} {run_time} 실행에서 AI 요약 단계가 중단됐습니다.
|
||
{occurrences}일째 같은 상태입니다.
|
||
[왜] Antigravity CLI(agy)의 Google 계정 인증이 만료되어 자동 갱신에 실패했습니다.
|
||
(agy 응답: {agy_error_short})
|
||
[어떻게] [로그인 창 열기]를 누르면 검은 콘솔 창이 뜨고 브라우저가 열립니다.
|
||
Google 계정으로 로그인한 뒤 창을 닫으면 끝입니다(약 1분).
|
||
로그인이 끝나면 자동으로 확인해서 이 알림을 지웁니다.
|
||
|
||
* 오늘 리포트는 AI 요약 없이 정상 생성됐습니다. 급하지 않다면 나중에 해도 됩니다.
|
||
|
||
로그: {log_dir}
|
||
|
||
버튼 [로그인 창 열기] [리포트 열기] [로그 열기] [나중에]
|
||
```
|
||
|
||
---
|
||
|
||
#### S14 `AGY_QUOTA` — agy 쿼터 소진 · WARN
|
||
|
||
```
|
||
제목 AI 요약을 건너뛰었습니다 — 사용 한도 소진
|
||
|
||
본문 [무엇] {run_date} {run_time} 실행에서 AI 요약을 만들지 못했습니다.
|
||
[왜] Antigravity CLI의 모델 사용 한도를 다 썼습니다.
|
||
(오늘 사용 토큰 {tokens_used}, 설정 상한 {tokens_cap})
|
||
[어떻게] 아무것도 하지 않아도 됩니다. 내일 06:00에 자동으로 다시 시도합니다.
|
||
오늘 리포트의 "대시보드" 시트 상단에 "AI 요약 없음: 사용 한도"라고 표시했습니다.
|
||
자주 반복되면 config.toml 의 agy.daily_token_cap 을 조정하세요.
|
||
|
||
로그: {log_dir}
|
||
|
||
버튼 [리포트 열기] [로그 열기] [닫기]
|
||
```
|
||
|
||
---
|
||
|
||
#### S16 `REPORT_LOCKED` — xlsx 파일 잠김 · WARN
|
||
|
||
```
|
||
제목 리포트를 다른 이름으로 저장했습니다
|
||
|
||
본문 [무엇] {run_date} {run_time} 실행에서 오늘 리포트를 원래 파일 이름으로
|
||
저장하지 못하고 "{fallback_name}"으로 저장했습니다.
|
||
[왜] "{target_name}" 파일이 Excel에서 열려 있어 덮어쓸 수 없었습니다.
|
||
{retries}회 다시 시도했지만 계속 잠겨 있었습니다.
|
||
[어떻게] Excel에서 해당 파일을 닫고 [리포트 다시 만들기]를 누르면
|
||
원래 이름으로 정리됩니다(약 20초).
|
||
지금 당장은 [폴더 열기]로 "{fallback_name}"을 그대로 열어 보셔도 됩니다.
|
||
|
||
폴더: {report_dir}
|
||
로그: {log_dir}
|
||
|
||
버튼 [리포트 다시 만들기] [폴더 열기] [닫기]
|
||
```
|
||
|
||
---
|
||
|
||
#### S17 `REPORT_FAILED` — 리포트 생성 실패 · ERROR (모달)
|
||
|
||
```
|
||
제목 오늘 리포트를 만들지 못했습니다
|
||
|
||
본문 [무엇] {run_date} {run_time} 실행에서 Excel 리포트 생성 단계가 실패했습니다.
|
||
오늘 날짜의 리포트 파일이 없습니다.
|
||
[왜] {failure_summary}
|
||
(자료 수집과 비교는 정상적으로 끝났고 데이터베이스에 저장돼 있습니다.
|
||
문제는 파일을 만드는 마지막 단계입니다.)
|
||
[어떻게] 저장된 자료로 리포트만 다시 만들 수 있습니다.
|
||
[리포트 다시 만들기]를 누르세요(약 20초, 인터넷 불필요).
|
||
그래도 실패하면 [로그 열기]의 pipeline.log 마지막 30줄을 확인하세요.
|
||
|
||
로그: {log_dir}
|
||
|
||
버튼 [리포트 다시 만들기] [로그 열기] [진단 실행] [나중에]
|
||
```
|
||
|
||
---
|
||
|
||
#### S18 `DISK_LOW` — 디스크 부족 · WARN
|
||
|
||
```
|
||
제목 {drive} 드라이브 여유 공간이 부족합니다
|
||
|
||
본문 [무엇] {run_date} {run_time} 실행에서 {drive} 드라이브 여유 공간이
|
||
{free_gb}GB 남았습니다. (필요 최소 {min_gb}GB)
|
||
[왜] 공간이 부족하면 데이터베이스 백업이 실패하고,
|
||
더 줄어들면 리포트 저장도 실패합니다. 오늘은 백업만 건너뛰었습니다.
|
||
[어떻게] [정리하기]를 누르면 보존 기간이 지난 로그·원문·오래된 백업을
|
||
한 번에 지웁니다. 예상 확보량 약 {reclaim_mb}MB (약 10초).
|
||
그래도 부족하면 config.toml 의 backup.dir 을 다른 드라이브로 바꾸세요.
|
||
|
||
* 오늘 리포트는 정상 생성됐습니다.
|
||
|
||
로그: {log_dir}
|
||
|
||
버튼 [정리하기] [백업 폴더 열기] [로그 열기] [닫기]
|
||
```
|
||
|
||
---
|
||
|
||
#### S19 `DB_LOCKED` — 데이터베이스 잠김 · ERROR (모달)
|
||
|
||
```
|
||
제목 데이터베이스가 잠겨 있어 저장하지 못했습니다
|
||
|
||
본문 [무엇] {run_date} {run_time} 실행에서 수집한 자료를 저장하지 못했습니다.
|
||
{busy_timeout_s}초를 기다렸지만 데이터베이스가 계속 잠겨 있었습니다.
|
||
[왜] 다른 프로그램이 dmf.sqlite3 파일을 붙잡고 있습니다.
|
||
DB 브라우저 같은 도구를 열어 두었거나, 이전 실행이 아직 끝나지 않았을 수 있습니다.
|
||
[어떻게] SQLite 관련 프로그램을 모두 닫고 [지금 다시 실행]을 누르세요(약 2분).
|
||
아무것도 열어 둔 것이 없다면 PC를 재시작한 뒤 다시 시도하세요.
|
||
|
||
DB: {db_path}
|
||
로그: {log_dir}
|
||
|
||
버튼 [지금 다시 실행] [DB 폴더 열기] [로그 열기] [나중에]
|
||
```
|
||
|
||
---
|
||
|
||
#### S20 `DB_CORRUPT` — 데이터베이스 손상 · CRITICAL (강제 창)
|
||
|
||
```
|
||
제목 데이터베이스 파일이 손상됐습니다
|
||
|
||
본문 [무엇] {check_time} 점검에서 데이터베이스 무결성 검사가 실패했습니다.
|
||
배치를 시작하지 않고 중단했습니다.
|
||
(검사 결과: {integrity_result})
|
||
[왜] 저장 중 강제 종료나 디스크 오류로 파일이 깨졌을 수 있습니다.
|
||
손상된 파일에 계속 쓰면 남은 자료까지 잃습니다.
|
||
[어떻게] 백업본이 {backup_count}개 있습니다. 가장 최근 것은 {latest_backup_date}입니다.
|
||
[백업으로 복원]을 누르면 목록에서 고를 수 있습니다(약 30초).
|
||
복원하면 그 날짜 이후 자료는 다시 수집해야 하며, 자동으로 채워집니다.
|
||
|
||
현재 DB: {db_path}
|
||
백업 폴더: {backup_dir}
|
||
|
||
버튼 [백업으로 복원] [백업 폴더 열기] [진단 실행] [나중에]
|
||
```
|
||
|
||
---
|
||
|
||
#### S21 `MIGRATION_FAILED` — 스키마 마이그레이션 실패 · CRITICAL (강제 창)
|
||
|
||
```
|
||
제목 데이터베이스 업그레이드에 실패했습니다
|
||
|
||
본문 [무엇] {check_time}에 데이터베이스 구조 업그레이드({from_version} → {to_version})가
|
||
실패해 되돌렸습니다. 배치는 실행하지 않았습니다.
|
||
[왜] {failure_summary}
|
||
변경은 트랜잭션으로 묶여 있어 **자료는 손상되지 않았습니다.**
|
||
[어떻게] 업그레이드 직전 백업본을 아래 경로에 만들어 두었습니다.
|
||
프로그램 버전을 이전으로 되돌리거나, 개발자에게 이 문구와 로그를 전달하세요.
|
||
[백업 폴더 열기]로 백업본을 확인할 수 있습니다.
|
||
|
||
업그레이드 직전 백업: {pre_migration_backup}
|
||
로그: {log_dir}
|
||
|
||
버튼 [백업 폴더 열기] [로그 열기] [진단 실행] [나중에]
|
||
```
|
||
|
||
---
|
||
|
||
#### S22 `WATCHDOG_STALE` — 배치 미실행 워치독 경보 · CRITICAL (강제 창)
|
||
|
||
```
|
||
제목 06:00 자동 실행이 되지 않았습니다
|
||
|
||
본문 [무엇] 지금 {now_time} 기준으로 오늘 06:00 배치가 실행된 흔적이 없습니다.
|
||
마지막으로 성공한 실행은 {last_success_at} ({stale_hours}시간 전)입니다.
|
||
[왜] 다음 중 하나입니다.
|
||
· PC가 06:00에 꺼져 있었고 아직 따라잡기가 실행되지 않음
|
||
· 작업 스케줄러 항목이 꺼졌거나 삭제됨
|
||
· 실행이 시작됐지만 제한 시간({exec_limit_min}분)을 넘겨 강제 종료됨
|
||
[어떻게] [지금 실행]을 누르면 즉시 오늘 자료를 수집합니다(약 2분).
|
||
반복된다면 [작업 상태 확인]으로 스케줄러 등록 상태를 점검하세요.
|
||
|
||
마지막 로그: {log_dir}
|
||
|
||
버튼 [지금 실행] [작업 상태 확인] [로그 폴더 열기] [나중에]
|
||
```
|
||
|
||
---
|
||
|
||
#### S23 `TASK_MISSING` — 작업 스케줄러 등록 소실 · CRITICAL
|
||
|
||
```
|
||
제목 자동 실행 등록이 사라졌습니다
|
||
|
||
본문 [무엇] {check_time} 점검에서 Windows 작업 스케줄러 항목 {missing_tasks_str}을(를)
|
||
찾지 못했습니다.
|
||
[왜] 시스템 정리 도구나 다른 프로그램이 지웠거나, 사용자가 직접 비활성화했을 수 있습니다.
|
||
등록이 없으면 매일 06:00 자동 실행이 되지 않습니다.
|
||
[어떻게] 의도적으로 끈 것이 아니라면 [자동 실행 다시 등록]을 누르세요(약 10초).
|
||
같은 이름의 작업 3개를 다시 만듭니다. 기존 설정은 그대로 유지됩니다.
|
||
|
||
* 자동으로 다시 등록하지 않는 이유: 사용자가 일부러 끈 것을 되살리면 안 되기 때문입니다.
|
||
|
||
버튼 [자동 실행 다시 등록] [작업 스케줄러 열기] [진단 실행] [나중에]
|
||
```
|
||
|
||
---
|
||
|
||
#### S24 `CONSECUTIVE_FAILURES` — 연속 3일 실패 · CRITICAL (강제 창)
|
||
|
||
```
|
||
제목 {failed_days}일 연속으로 실패하고 있습니다
|
||
|
||
본문 [무엇] {first_failed_date}부터 {last_failed_date}까지 {failed_days}일 연속
|
||
배치가 실패했습니다. 그 기간 리포트가 만들어지지 않았습니다.
|
||
[왜] 가장 많이 나온 원인 3가지입니다.
|
||
1. {top_cause_1} ({top_cause_1_count}회)
|
||
2. {top_cause_2} ({top_cause_2_count}회)
|
||
3. {top_cause_3} ({top_cause_3_count}회)
|
||
[어떻게] [전체 진단]을 누르면 전제조건 12가지를 한 번에 점검하고
|
||
문제 항목마다 해결 버튼을 보여 줍니다(약 15초).
|
||
대부분 인증키 또는 로그인 만료입니다.
|
||
|
||
* {pause_day}일째에도 해결되지 않으면 자동 실행을 잠시 멈추고 다시 안내합니다.
|
||
|
||
로그: {log_dir}
|
||
|
||
버튼 [전체 진단] [지금 다시 실행] [로그 폴더 열기] [나중에]
|
||
```
|
||
|
||
---
|
||
|
||
#### S25 `PYTHON_BROKEN` — 알림 계층 자체가 죽음 · CRITICAL (MessageBox 폴백)
|
||
|
||
이 문구만은 **PowerShell 이 렌더링**한다(`scripts/notify.ps1`). Python 이 죽었을 때 뜨는 유일한 화면이므로 템플릿 치환을 최소화하고 상수 문자열 위주로 구성한다.
|
||
|
||
```
|
||
제목 DMF 크롤러 — 프로그램이 실행되지 않습니다
|
||
|
||
본문 [무엇] DMF 크롤러의 알림 프로그램이 {stale_min}분째 응답하지 않습니다.
|
||
(마지막 정상 동작: {last_pump_at})
|
||
[왜] Python 실행 환경(.venv)이 손상됐거나 삭제됐을 가능성이 큽니다.
|
||
이 상태에서는 매일 06:00 자동 수집도 함께 멈춥니다.
|
||
[어떻게] 프로젝트 폴더의 bootstrap.cmd 를 더블클릭해 다시 설치하세요(약 3분).
|
||
기존 데이터와 설정은 그대로 유지됩니다.
|
||
|
||
폴더: {project_root}
|
||
Windows 이벤트 로그(Application) 원본 "DMF Crawler" 에 기록을 남겼습니다.
|
||
|
||
버튼 [확인] ← MessageBox 는 버튼을 커스터마이즈하지 않는다(§6.1 참조)
|
||
```
|
||
|
||
---
|
||
|
||
#### S27 `RUN_PAUSED` — 자동 실행 일시중지 · CRITICAL (강제 창)
|
||
|
||
```
|
||
제목 자동 실행을 잠시 멈췄습니다
|
||
|
||
본문 [무엇] {failed_days}일 연속 실패해 {pause_at}부터 매일 06:00 자동 실행을 멈췄습니다.
|
||
[왜] 고쳐지지 않은 상태로 매일 같은 실패를 반복하면
|
||
공공데이터포털에 불필요한 호출이 계속 쌓이기 때문입니다.
|
||
멈춤은 작업 스케줄러를 지우는 것이 아니라 플래그 파일 하나로 이뤄집니다.
|
||
[어떻게] [전체 진단]으로 원인을 먼저 해결한 뒤 [자동 실행 재개]를 누르세요.
|
||
재개 버튼은 진단이 모두 통과해야 활성화됩니다.
|
||
|
||
멈춤 플래그: {pause_flag_path}
|
||
로그: {log_dir}
|
||
|
||
버튼 [전체 진단] [자동 실행 재개] [로그 폴더 열기] [나중에]
|
||
```
|
||
|
||
---
|
||
|
||
#### 나머지 INFO / 로그 전용 문구
|
||
|
||
| 코드 | 제목 | 본문(1줄 요약) |
|
||
|---|---|---|
|
||
| `RUN_OK` | 오늘 리포트가 준비됐습니다 | `{run_date} 수집 완료 — 신규 {new_n}건 / 변경 {chg_n}건 / 취하 {wdr_n}건. 리포트: {report_path}` |
|
||
| `AGY_TIMEOUT` | (표시 안 함) | `AI 요약이 {timeout}을 초과해 중단됨. 리포트는 정상 생성. 내일 자동 재시도.` |
|
||
| `NOTIFY_DEGRADED` | (표시 안 함) | `알림 표시가 {from_channel} → {to_channel} 로 강등됨. 사유: {reason}` |
|
||
| `NOTIFY_SUPPRESSED` | (표시 안 함) | `일일 알림 표시 상한 {cap}건 도달. 오늘 남은 알림 {pending_n}건은 표시하지 않음.` |
|
||
|
||
### 3.4 `notify/messages.py` 전문
|
||
|
||
위 문구의 **단일 정본**이다. 문서와 코드가 어긋나지 않도록, 이 파일이 원본이고 §3.3 은 그 렌더링 결과다.
|
||
|
||
```python
|
||
# src/dmf_crawler/notify/messages.py
|
||
"""알림 문구 템플릿 레지스트리.
|
||
|
||
계약(요구 R7.3):
|
||
모든 템플릿은 what / why / how / next_actions 4요소를 갖는다.
|
||
하나라도 비면 모듈 임포트 시점에 AssertionError 로 즉시 죽는다.
|
||
-> 문구 누락이 06:00 런타임까지 잠복하지 않는다.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
from dataclasses import dataclass, field
|
||
from typing import Any, Mapping
|
||
|
||
from dmf_crawler.alerts import Severity
|
||
|
||
# ---------------------------------------------------------------- 액션 키
|
||
# gui/steps.py 의 ACTIONS 레지스트리와 키가 1:1로 일치해야 한다.
|
||
VALID_ACTIONS: frozenset[str] = frozenset({
|
||
"run_now", # 지금 다시 실행
|
||
"run_now_force", # 검증 우회 재실행 (--force)
|
||
"report_only", # 리포트만 다시 만들기
|
||
"open_log_dir", # 로그 폴더 열기
|
||
"open_report_dir", # 리포트 폴더 열기
|
||
"open_report_file", # 리포트 파일 바로 열기
|
||
"open_raw_dir", # API 원문 폴더 열기
|
||
"open_backup_dir", # 백업 폴더 열기
|
||
"open_db_dir", # DB 폴더 열기
|
||
"open_proposal", # AI 진단 결과 파일 열기
|
||
"enter_api_key", # 인증키 입력 창
|
||
"open_api_portal", # 공공데이터포털 발급/마이페이지 열기
|
||
"agy_relogin", # agy 재로그인 콘솔 열기
|
||
"install_agy", # agy 설치
|
||
"disable_agy", # AI 기능 끄기
|
||
"reregister_tasks", # 작업 스케줄러 3종 재등록
|
||
"open_task_scheduler",# 작업 스케줄러 GUI 열기
|
||
"restore_backup", # 백업 복원 마법사
|
||
"cleanup_disk", # 로그/원문/백업 정리
|
||
"open_doctor", # 전체 진단 화면
|
||
"resume_schedule", # 자동 실행 재개(일시중지 해제)
|
||
"snooze", # 나중에 (기본 60분)
|
||
"dismiss", # 닫기
|
||
})
|
||
|
||
# 실패 알림에서 이것만 있으면 "막다른 골목"이다 (요구 R7.7).
|
||
_TERMINAL_ONLY = frozenset({"snooze", "dismiss"})
|
||
|
||
|
||
@dataclass(frozen=True, slots=True)
|
||
class AlertTemplate:
|
||
code: str
|
||
severity: Severity
|
||
title: str
|
||
what: str
|
||
why: str
|
||
how: str
|
||
next_actions: tuple[str, ...]
|
||
date_scoped: bool = True # dedup_key 에 run_date 를 넣는가
|
||
cooldown_minutes: int | None = None # None 이면 등급 기본값 사용
|
||
eventlog_id: int = 0 # 0 이면 등급 기본값 사용
|
||
note: str = "" # 본문 하단 * 주석 (선택)
|
||
|
||
def render(self, ctx: Mapping[str, Any]) -> "RenderedAlert":
|
||
safe = _SafeDict(ctx)
|
||
body_lines = [
|
||
f"[무엇] {self.what.format_map(safe)}",
|
||
f"[왜] {self.why.format_map(safe)}",
|
||
f"[어떻게] {self.how.format_map(safe)}",
|
||
]
|
||
if self.note:
|
||
body_lines += ["", self.note.format_map(safe)]
|
||
for label, key in (("원문", "raw_dir"), ("로그", "log_dir")):
|
||
if ctx.get(key):
|
||
body_lines += [f"{label}: {ctx[key]}"]
|
||
return RenderedAlert(
|
||
code=self.code,
|
||
severity=self.severity,
|
||
title=self.title.format_map(safe),
|
||
what=self.what.format_map(safe),
|
||
why=self.why.format_map(safe),
|
||
how=self.how.format_map(safe),
|
||
body="\n".join(body_lines),
|
||
next_actions=self.next_actions,
|
||
)
|
||
|
||
|
||
@dataclass(frozen=True, slots=True)
|
||
class RenderedAlert:
|
||
code: str
|
||
severity: Severity
|
||
title: str
|
||
what: str
|
||
why: str
|
||
how: str
|
||
body: str
|
||
next_actions: tuple[str, ...]
|
||
|
||
|
||
class _SafeDict(dict):
|
||
"""치환값이 없어도 죽지 않는다. 알림기는 절대 예외로 죽으면 안 된다."""
|
||
|
||
def __init__(self, src: Mapping[str, Any]) -> None:
|
||
super().__init__(src)
|
||
|
||
def __missing__(self, key: str) -> str: # noqa: D105
|
||
return "(정보 없음)"
|
||
|
||
|
||
def _t(**kw: Any) -> AlertTemplate:
|
||
return AlertTemplate(**kw)
|
||
|
||
|
||
TEMPLATES: dict[str, AlertTemplate] = {
|
||
|
||
"RUN_OK": _t(
|
||
code="RUN_OK", severity=Severity.INFO, eventlog_id=110,
|
||
title="오늘 리포트가 준비됐습니다",
|
||
what="{run_date} {run_time} 수집이 정상적으로 끝났습니다.",
|
||
why="신규 {new_n}건 / 변경 {chg_n}건 / 취하 {wdr_n}건이 확인됐습니다.",
|
||
how="리포트를 열어 확인하세요. 파일: {report_path}",
|
||
next_actions=("open_report_file", "dismiss"),
|
||
cooldown_minutes=1440,
|
||
),
|
||
|
||
"FETCH_FAILED": _t(
|
||
code="FETCH_FAILED", severity=Severity.WARN, eventlog_id=200,
|
||
title="식약처 데이터를 받아오지 못했습니다",
|
||
what="{run_date} {run_time} 실행에서 식약처 DMF 목록을 받아오지 못했습니다. "
|
||
"{pages_ok}/{pages_total} 페이지까지만 받았습니다.",
|
||
why="공공데이터포털 API 응답이 {attempts}회 연속 실패했습니다. "
|
||
"마지막 오류: {last_error_short}",
|
||
how="오늘 리포트는 마지막으로 성공한 {stale_date} 자료로 만들었습니다. "
|
||
"내일 06:00에 자동으로 다시 시도합니다. "
|
||
"급하면 [지금 다시 실행]을 누르세요(약 2분).",
|
||
next_actions=("run_now", "open_log_dir", "open_report_file", "dismiss"),
|
||
),
|
||
|
||
"SOURCE_CIRCUIT_OPEN": _t(
|
||
code="SOURCE_CIRCUIT_OPEN", severity=Severity.CRITICAL, eventlog_id=400,
|
||
date_scoped=False,
|
||
title="식약처 API 호출을 {cooldown_hours}시간 중단했습니다",
|
||
what="{run_date} {run_time} 기준, 식약처 데이터 수집이 {fail_count}회 연속 실패해 "
|
||
"자동 호출을 중단(차단 회로 열림)했습니다.",
|
||
why="같은 오류가 반복되면 상대 서버에 부담을 주고 차단당할 수 있어, "
|
||
"{cooldown_hours}시간 동안 호출 자체를 멈춥니다. 최근 오류: {last_error_short}",
|
||
how="원인을 확인하려면 [진단 실행]을 누르세요(약 10초). "
|
||
"원인이 해결됐다고 판단되면 [지금 다시 실행]으로 즉시 재시도할 수 있습니다. "
|
||
"그대로 두면 {resume_at}에 자동으로 한 번 시험 호출합니다.",
|
||
next_actions=("open_doctor", "run_now", "open_log_dir", "snooze"),
|
||
),
|
||
|
||
"HTTP_BLOCKED_BODY": _t(
|
||
code="HTTP_BLOCKED_BODY", severity=Severity.WARN, eventlog_id=200,
|
||
title="API가 정상 응답 대신 안내 페이지를 보냈습니다",
|
||
what="{run_date} {run_time} 실행에서 {page_no}페이지 요청에 대해 "
|
||
"정상 코드(HTTP 200)가 왔지만 내용이 데이터가 아니었습니다.",
|
||
why="응답 본문이 {body_bytes}바이트로 최소 기준({min_bytes}바이트)에 못 미치거나 "
|
||
"HTML 안내 페이지였습니다. 점검 중이거나 호출이 차단됐을 때 나타나는 형태입니다.",
|
||
how="받은 원문을 그대로 보관했습니다. [원문 폴더 열기]에서 직접 확인할 수 있습니다. "
|
||
"오늘 비교는 건너뛰었고 리포트는 이전 자료로 생성했습니다. "
|
||
"내일 06:00에 자동 재시도합니다.",
|
||
next_actions=("open_raw_dir", "run_now", "open_log_dir", "dismiss"),
|
||
),
|
||
|
||
"ZERO_RECORDS": _t(
|
||
code="ZERO_RECORDS", severity=Severity.CRITICAL, eventlog_id=400,
|
||
title="수집 결과가 0건입니다 — 비교를 중단했습니다",
|
||
what="{run_date} {run_time} 실행에서 원료의약품 등록(DMF) 자료를 한 건도 받지 못했습니다. "
|
||
"(API가 알려준 전체 건수: {total_count}건, 실제 받은 건수: 0건)",
|
||
why="0건을 그대로 받아들이면 어제 있던 {prev_count}건 전부가 '취하됨'으로 "
|
||
"잘못 기록됩니다. 그래서 저장과 비교를 모두 중단했습니다.",
|
||
how="오늘 리포트는 {stale_date} 자료로 만들었고 맨 위에 경고 배너가 붙어 있습니다. "
|
||
"[원문 폴더 열기]에서 실제 응답을 확인하세요(약 1분). "
|
||
"공공데이터포털 점검 중이면 내일 자동 복구됩니다.",
|
||
next_actions=("open_raw_dir", "open_doctor", "run_now", "snooze"),
|
||
),
|
||
|
||
"INTEGRITY_BLOCKED": _t(
|
||
code="INTEGRITY_BLOCKED", severity=Severity.WARN, eventlog_id=200,
|
||
title="데이터 검증에 걸려 오늘 비교를 건너뛰었습니다",
|
||
what="{run_date} {run_time} 실행에서 안전장치 '{gate_name}'에 걸려 "
|
||
"오늘 자료를 기준값으로 저장하지 않았습니다.",
|
||
why="{gate_detail} (기준: {gate_threshold} / 실측: {gate_observed}) "
|
||
"기준을 벗어난 자료를 저장하면 이후 모든 비교가 오염됩니다.",
|
||
how="오늘 리포트는 정상 생성됐지만 '오늘 변경분' 시트는 비어 있습니다. "
|
||
"받은 원문은 보관돼 있습니다. 내일 정상 수집되면 자동으로 복구됩니다. "
|
||
"확인이 필요하면 [원문 폴더 열기]를 누르세요.",
|
||
next_actions=("open_report_file", "open_raw_dir", "open_log_dir", "dismiss"),
|
||
),
|
||
|
||
"INTEGRITY_DROP": _t(
|
||
code="INTEGRITY_DROP", severity=Severity.CRITICAL, eventlog_id=400,
|
||
title="전체 등록 건수가 {drop_pct}% 줄었습니다 — 확인이 필요합니다",
|
||
what="{run_date} {run_time} 실행에서 전체 DMF 등록 건수가 "
|
||
"{prev_count}건에서 {curr_count}건으로 {drop_count}건({drop_pct}%) 줄었습니다.",
|
||
why="하루 만에 {threshold_pct}% 이상 줄어드는 것은 정상적인 변동이 아닙니다. "
|
||
"대량 취하가 실제로 일어났거나, API가 일부 자료만 내려준 경우입니다. "
|
||
"오탐으로 '전건 취하' 리포트가 나가는 것을 막기 위해 비교를 중단했습니다.",
|
||
how="[원문 폴더 열기]에서 오늘 받은 응답의 totalCount를 직접 확인하세요(약 2분). "
|
||
"실제로 정상적인 감소라면 [지금 다시 실행 (검증 우회)]를 눌러 반영할 수 있습니다.",
|
||
next_actions=("open_raw_dir", "run_now_force", "open_log_dir", "snooze"),
|
||
),
|
||
|
||
"SCHEMA_DRIFT": _t(
|
||
code="SCHEMA_DRIFT", severity=Severity.CRITICAL, eventlog_id=400,
|
||
title="식약처 API 응답 형식이 바뀐 것 같습니다",
|
||
what="{run_date} {run_time} 실행에서 필수 항목 {missing_fields_str}을(를) "
|
||
"응답에서 찾지 못했습니다. 값이 빈 비율이 {null_ratio_pct}%까지 올랐습니다. "
|
||
"(평소 {baseline_null_pct}% 이하)",
|
||
why="공공데이터포털 쪽에서 응답 필드 이름이나 구조를 바꿨을 가능성이 큽니다. "
|
||
"잘못 해석한 자료를 저장하지 않기 위해 저장과 비교를 중단했습니다.",
|
||
how="오늘 받은 원문 전체를 보관했습니다. 이 원문만 있으면 나중에 통째로 "
|
||
"다시 해석할 수 있으니 자료는 유실되지 않습니다. "
|
||
"[원문 폴더 열기]로 실제 응답을 확인하세요. "
|
||
"AI 진단 결과가 있으면 [AI 진단 결과 열기]에서 볼 수 있습니다.",
|
||
next_actions=("open_raw_dir", "open_proposal", "open_doctor", "snooze"),
|
||
note="* AI가 제안한 수정은 자동으로 적용되지 않습니다. 사람이 확인한 뒤 반영합니다.",
|
||
),
|
||
|
||
"API_KEY_MISSING": _t(
|
||
code="API_KEY_MISSING", severity=Severity.CRITICAL, eventlog_id=400,
|
||
date_scoped=False,
|
||
title="공공데이터포털 인증키가 없습니다",
|
||
what="{run_date} {run_time}에 배치를 시작하려 했으나 공공데이터포털 인증키가 "
|
||
"저장돼 있지 않아 실행하지 못했습니다.",
|
||
why="이 프로그램은 식약처 원료의약품 등록 자료를 공공데이터포털 공식 API로 받아옵니다. "
|
||
"API를 쓰려면 무료 인증키가 필요하고, 아직 한 번도 입력되지 않았습니다.",
|
||
how="[인증키 입력]을 누르면 입력 창이 열립니다. "
|
||
"키가 없다면 [발급 페이지 열기]로 공공데이터포털에서 신청하세요(무료, 약 3분). "
|
||
"입력하면 즉시 실제 호출로 검증한 뒤 이 PC에만 암호화해 저장합니다.",
|
||
next_actions=("enter_api_key", "open_api_portal", "snooze"),
|
||
note="* 이 알림은 키가 저장될 때까지 계속 표시됩니다.",
|
||
),
|
||
|
||
"API_KEY_INVALID": _t(
|
||
code="API_KEY_INVALID", severity=Severity.CRITICAL, eventlog_id=400,
|
||
date_scoped=False,
|
||
title="공공데이터포털 인증키가 거부됐습니다",
|
||
what="{run_date} {run_time} 실행에서 식약처 API가 인증키를 거부했습니다. "
|
||
"(응답 코드: {result_code} / {result_msg})",
|
||
why="저장된 키(앞 8자: {key_fingerprint})가 만료됐거나, 해당 서비스의 활용 신청이 "
|
||
"승인되지 않았거나, 키를 잘못 붙여넣었을 수 있습니다. "
|
||
"특히 '인코딩 키'를 붙여넣으면 이 오류가 납니다 — 디코딩 키를 써야 합니다.",
|
||
how="[인증키 다시 입력]을 눌러 공공데이터포털 마이페이지의 "
|
||
"일반 인증키(Decoding) 값을 붙여넣으세요(약 2분). "
|
||
"입력 즉시 실제 호출로 검증하므로 맞는지 바로 알 수 있습니다.",
|
||
next_actions=("enter_api_key", "open_api_portal", "open_log_dir", "snooze"),
|
||
),
|
||
|
||
"API_QUOTA_EXCEEDED": _t(
|
||
code="API_QUOTA_EXCEEDED", severity=Severity.WARN, eventlog_id=200,
|
||
title="오늘 API 사용량을 모두 썼습니다",
|
||
what="{run_date} {run_time} 실행에서 공공데이터포털이 일일 트래픽 초과를 "
|
||
"알려왔습니다. ({error_code})",
|
||
why="개발계정의 하루 호출 한도(기본 10,000회)를 넘었습니다. "
|
||
"오늘 {calls_today}회 호출했습니다. 한도는 매일 자정에 초기화됩니다.",
|
||
how="오늘은 더 호출하지 않고 마지막으로 성공한 {stale_date} 자료로 리포트를 만들었습니다. "
|
||
"내일 06:00에 자동으로 정상 수집합니다. 아무것도 하지 않아도 됩니다. "
|
||
"매일 반복된다면 공공데이터포털에서 운영계정 전환을 신청하세요.",
|
||
next_actions=("open_report_file", "open_log_dir", "dismiss"),
|
||
),
|
||
|
||
"AGY_MISSING": _t(
|
||
code="AGY_MISSING", severity=Severity.CRITICAL, eventlog_id=400,
|
||
date_scoped=False,
|
||
title="AI 요약 도구(agy)가 설치돼 있지 않습니다",
|
||
what="{run_date} {run_time} 실행에서 AI 요약 단계를 건너뛰었습니다. "
|
||
"Antigravity CLI(agy) 실행 파일을 찾지 못했습니다. 찾아본 경로: {agy_path}",
|
||
why="AI 요약은 매일 변경 내용을 자연어 브리핑으로 정리하는 부가 기능입니다. "
|
||
"도구가 설치되지 않았거나 경로가 바뀌었습니다.",
|
||
how="[지금 설치]를 누르면 공식 설치 스크립트를 자동으로 실행합니다(약 2분, 인터넷 필요). "
|
||
"설치 후 최초 1회 Google 계정 로그인이 필요하며 창이 자동으로 열립니다. "
|
||
"AI 요약이 필요 없다면 [AI 기능 끄기]를 눌러 이 알림을 영구히 멈출 수 있습니다.",
|
||
next_actions=("install_agy", "disable_agy", "open_report_file", "snooze"),
|
||
note="* 오늘 리포트는 AI 요약 없이 정상 생성됐습니다.",
|
||
),
|
||
|
||
"AGY_AUTH": _t(
|
||
code="AGY_AUTH", severity=Severity.CRITICAL, eventlog_id=400,
|
||
date_scoped=False,
|
||
title="AI 요약을 만들지 못했습니다 — 로그인 만료",
|
||
what="{run_date} {run_time} 실행에서 AI 요약 단계가 중단됐습니다. "
|
||
"{occurrences}일째 같은 상태입니다.",
|
||
why="Antigravity CLI(agy)의 Google 계정 인증이 만료되어 자동 갱신에 실패했습니다. "
|
||
"(agy 응답: {agy_error_short})",
|
||
how="[로그인 창 열기]를 누르면 검은 콘솔 창이 뜨고 브라우저가 열립니다. "
|
||
"Google 계정으로 로그인한 뒤 창을 닫으면 끝입니다(약 1분). "
|
||
"로그인이 끝나면 자동으로 확인해서 이 알림을 지웁니다.",
|
||
next_actions=("agy_relogin", "open_report_file", "open_log_dir", "snooze"),
|
||
note="* 오늘 리포트는 AI 요약 없이 정상 생성됐습니다. 급하지 않다면 나중에 해도 됩니다.",
|
||
),
|
||
|
||
"AGY_QUOTA": _t(
|
||
code="AGY_QUOTA", severity=Severity.WARN, eventlog_id=200,
|
||
title="AI 요약을 건너뛰었습니다 — 사용 한도 소진",
|
||
what="{run_date} {run_time} 실행에서 AI 요약을 만들지 못했습니다.",
|
||
why="Antigravity CLI의 모델 사용 한도를 다 썼습니다. "
|
||
"(오늘 사용 토큰 {tokens_used}, 설정 상한 {tokens_cap})",
|
||
how="아무것도 하지 않아도 됩니다. 내일 06:00에 자동으로 다시 시도합니다. "
|
||
"오늘 리포트의 '대시보드' 시트 상단에 'AI 요약 없음: 사용 한도'라고 표시했습니다. "
|
||
"자주 반복되면 config.toml 의 agy.daily_token_cap 을 조정하세요.",
|
||
next_actions=("open_report_file", "open_log_dir", "dismiss"),
|
||
),
|
||
|
||
"AGY_TIMEOUT": _t(
|
||
code="AGY_TIMEOUT", severity=Severity.INFO, eventlog_id=100,
|
||
title="AI 요약이 시간 안에 끝나지 않았습니다",
|
||
what="{run_date} {run_time} 실행에서 AI 요약이 {timeout} 안에 끝나지 않아 중단했습니다.",
|
||
why="모델 응답이 느렸거나 네트워크가 불안정했습니다.",
|
||
how="리포트는 정상 생성됐습니다. 내일 자동으로 다시 시도합니다.",
|
||
next_actions=("dismiss",),
|
||
cooldown_minutes=1440,
|
||
),
|
||
|
||
"REPORT_LOCKED": _t(
|
||
code="REPORT_LOCKED", severity=Severity.WARN, eventlog_id=200,
|
||
title="리포트를 다른 이름으로 저장했습니다",
|
||
what="{run_date} {run_time} 실행에서 오늘 리포트를 원래 파일 이름으로 저장하지 못하고 "
|
||
"'{fallback_name}'으로 저장했습니다.",
|
||
why="'{target_name}' 파일이 Excel에서 열려 있어 덮어쓸 수 없었습니다. "
|
||
"{retries}회 다시 시도했지만 계속 잠겨 있었습니다.",
|
||
how="Excel에서 해당 파일을 닫고 [리포트 다시 만들기]를 누르면 원래 이름으로 "
|
||
"정리됩니다(약 20초). 지금 당장은 [폴더 열기]로 그대로 열어 보셔도 됩니다.",
|
||
next_actions=("report_only", "open_report_dir", "dismiss"),
|
||
),
|
||
|
||
"REPORT_FAILED": _t(
|
||
code="REPORT_FAILED", severity=Severity.ERROR, eventlog_id=300,
|
||
title="오늘 리포트를 만들지 못했습니다",
|
||
what="{run_date} {run_time} 실행에서 Excel 리포트 생성 단계가 실패했습니다. "
|
||
"오늘 날짜의 리포트 파일이 없습니다.",
|
||
why="{failure_summary} 자료 수집과 비교는 정상적으로 끝났고 데이터베이스에 "
|
||
"저장돼 있습니다. 문제는 파일을 만드는 마지막 단계입니다.",
|
||
how="저장된 자료로 리포트만 다시 만들 수 있습니다. "
|
||
"[리포트 다시 만들기]를 누르세요(약 20초, 인터넷 불필요). "
|
||
"그래도 실패하면 [로그 열기]의 pipeline.log 마지막 30줄을 확인하세요.",
|
||
next_actions=("report_only", "open_log_dir", "open_doctor", "snooze"),
|
||
),
|
||
|
||
"DISK_LOW": _t(
|
||
code="DISK_LOW", severity=Severity.WARN, eventlog_id=200,
|
||
title="{drive} 드라이브 여유 공간이 부족합니다",
|
||
what="{run_date} {run_time} 실행에서 {drive} 드라이브 여유 공간이 "
|
||
"{free_gb}GB 남았습니다. (필요 최소 {min_gb}GB)",
|
||
why="공간이 부족하면 데이터베이스 백업이 실패하고, 더 줄어들면 리포트 저장도 "
|
||
"실패합니다. 오늘은 백업만 건너뛰었습니다.",
|
||
how="[정리하기]를 누르면 보존 기간이 지난 로그·원문·오래된 백업을 한 번에 지웁니다. "
|
||
"예상 확보량 약 {reclaim_mb}MB (약 10초). "
|
||
"그래도 부족하면 config.toml 의 backup.dir 을 다른 드라이브로 바꾸세요.",
|
||
next_actions=("cleanup_disk", "open_backup_dir", "open_log_dir", "dismiss"),
|
||
note="* 오늘 리포트는 정상 생성됐습니다.",
|
||
),
|
||
|
||
"DB_LOCKED": _t(
|
||
code="DB_LOCKED", severity=Severity.ERROR, eventlog_id=300,
|
||
title="데이터베이스가 잠겨 있어 저장하지 못했습니다",
|
||
what="{run_date} {run_time} 실행에서 수집한 자료를 저장하지 못했습니다. "
|
||
"{busy_timeout_s}초를 기다렸지만 데이터베이스가 계속 잠겨 있었습니다.",
|
||
why="다른 프로그램이 dmf.sqlite3 파일을 붙잡고 있습니다. "
|
||
"DB 브라우저 같은 도구를 열어 두었거나, 이전 실행이 아직 끝나지 않았을 수 있습니다.",
|
||
how="SQLite 관련 프로그램을 모두 닫고 [지금 다시 실행]을 누르세요(약 2분). "
|
||
"아무것도 열어 둔 것이 없다면 PC를 재시작한 뒤 다시 시도하세요.",
|
||
next_actions=("run_now", "open_db_dir", "open_log_dir", "snooze"),
|
||
),
|
||
|
||
"DB_CORRUPT": _t(
|
||
code="DB_CORRUPT", severity=Severity.CRITICAL, eventlog_id=400,
|
||
date_scoped=False,
|
||
title="데이터베이스 파일이 손상됐습니다",
|
||
what="{check_time} 점검에서 데이터베이스 무결성 검사가 실패했습니다. "
|
||
"배치를 시작하지 않고 중단했습니다. (검사 결과: {integrity_result})",
|
||
why="저장 중 강제 종료나 디스크 오류로 파일이 깨졌을 수 있습니다. "
|
||
"손상된 파일에 계속 쓰면 남은 자료까지 잃습니다.",
|
||
how="백업본이 {backup_count}개 있습니다. 가장 최근 것은 {latest_backup_date}입니다. "
|
||
"[백업으로 복원]을 누르면 목록에서 고를 수 있습니다(약 30초). "
|
||
"복원하면 그 날짜 이후 자료는 다시 수집해야 하며, 자동으로 채워집니다.",
|
||
next_actions=("restore_backup", "open_backup_dir", "open_doctor", "snooze"),
|
||
),
|
||
|
||
"MIGRATION_FAILED": _t(
|
||
code="MIGRATION_FAILED", severity=Severity.CRITICAL, eventlog_id=400,
|
||
date_scoped=False,
|
||
title="데이터베이스 업그레이드에 실패했습니다",
|
||
what="{check_time}에 데이터베이스 구조 업그레이드({from_version} → {to_version})가 "
|
||
"실패해 되돌렸습니다. 배치는 실행하지 않았습니다.",
|
||
why="{failure_summary} 변경은 트랜잭션으로 묶여 있어 자료는 손상되지 않았습니다.",
|
||
how="업그레이드 직전 백업본을 {pre_migration_backup} 에 만들어 두었습니다. "
|
||
"프로그램 버전을 이전으로 되돌리거나, 이 문구와 로그를 개발자에게 전달하세요.",
|
||
next_actions=("open_backup_dir", "open_log_dir", "open_doctor", "snooze"),
|
||
),
|
||
|
||
"WATCHDOG_STALE": _t(
|
||
code="WATCHDOG_STALE", severity=Severity.CRITICAL, eventlog_id=410,
|
||
date_scoped=False,
|
||
title="06:00 자동 실행이 되지 않았습니다",
|
||
what="지금 {now_time} 기준으로 오늘 06:00 배치가 실행된 흔적이 없습니다. "
|
||
"마지막으로 성공한 실행은 {last_success_at} ({stale_hours}시간 전)입니다.",
|
||
why="다음 중 하나입니다. "
|
||
"(1) PC가 06:00에 꺼져 있었고 아직 따라잡기가 실행되지 않음 "
|
||
"(2) 작업 스케줄러 항목이 꺼졌거나 삭제됨 "
|
||
"(3) 실행이 시작됐지만 제한 시간({exec_limit_min}분)을 넘겨 강제 종료됨",
|
||
how="[지금 실행]을 누르면 즉시 오늘 자료를 수집합니다(약 2분). "
|
||
"반복된다면 [작업 상태 확인]으로 스케줄러 등록 상태를 점검하세요.",
|
||
next_actions=("run_now", "open_task_scheduler", "open_log_dir", "snooze"),
|
||
),
|
||
|
||
"TASK_MISSING": _t(
|
||
code="TASK_MISSING", severity=Severity.CRITICAL, eventlog_id=400,
|
||
date_scoped=False,
|
||
title="자동 실행 등록이 사라졌습니다",
|
||
what="{check_time} 점검에서 Windows 작업 스케줄러 항목 {missing_tasks_str}을(를) "
|
||
"찾지 못했습니다.",
|
||
why="시스템 정리 도구나 다른 프로그램이 지웠거나, 사용자가 직접 비활성화했을 수 "
|
||
"있습니다. 등록이 없으면 매일 06:00 자동 실행이 되지 않습니다.",
|
||
how="의도적으로 끈 것이 아니라면 [자동 실행 다시 등록]을 누르세요(약 10초). "
|
||
"같은 이름의 작업 3개를 다시 만듭니다. 기존 설정은 그대로 유지됩니다.",
|
||
next_actions=("reregister_tasks", "open_task_scheduler", "open_doctor", "snooze"),
|
||
note="* 자동으로 다시 등록하지 않는 이유: 사용자가 일부러 끈 것을 되살리면 안 되기 때문입니다.",
|
||
),
|
||
|
||
"CONSECUTIVE_FAILURES": _t(
|
||
code="CONSECUTIVE_FAILURES", severity=Severity.CRITICAL, eventlog_id=420,
|
||
date_scoped=False,
|
||
title="{failed_days}일 연속으로 실패하고 있습니다",
|
||
what="{first_failed_date}부터 {last_failed_date}까지 {failed_days}일 연속 배치가 "
|
||
"실패했습니다. 그 기간 리포트가 만들어지지 않았습니다.",
|
||
why="가장 많이 나온 원인 3가지입니다. "
|
||
"(1) {top_cause_1} ({top_cause_1_count}회) "
|
||
"(2) {top_cause_2} ({top_cause_2_count}회) "
|
||
"(3) {top_cause_3} ({top_cause_3_count}회)",
|
||
how="[전체 진단]을 누르면 전제조건 12가지를 한 번에 점검하고 "
|
||
"문제 항목마다 해결 버튼을 보여 줍니다(약 15초). "
|
||
"대부분 인증키 또는 로그인 만료입니다.",
|
||
next_actions=("open_doctor", "run_now", "open_log_dir", "snooze"),
|
||
note="* {pause_day}일째에도 해결되지 않으면 자동 실행을 잠시 멈추고 다시 안내합니다.",
|
||
),
|
||
|
||
"RUN_PAUSED": _t(
|
||
code="RUN_PAUSED", severity=Severity.CRITICAL, eventlog_id=430,
|
||
date_scoped=False,
|
||
title="자동 실행을 잠시 멈췄습니다",
|
||
what="{failed_days}일 연속 실패해 {pause_at}부터 매일 06:00 자동 실행을 멈췄습니다.",
|
||
why="고쳐지지 않은 상태로 매일 같은 실패를 반복하면 공공데이터포털에 "
|
||
"불필요한 호출이 계속 쌓이기 때문입니다. "
|
||
"멈춤은 작업 스케줄러를 지우는 것이 아니라 플래그 파일 하나로 이뤄집니다.",
|
||
how="[전체 진단]으로 원인을 먼저 해결한 뒤 [자동 실행 재개]를 누르세요. "
|
||
"재개 버튼은 진단이 모두 통과해야 활성화됩니다.",
|
||
next_actions=("open_doctor", "resume_schedule", "open_log_dir", "snooze"),
|
||
),
|
||
|
||
"NOTIFY_DEGRADED": _t(
|
||
code="NOTIFY_DEGRADED", severity=Severity.WARN, eventlog_id=510,
|
||
title="알림 표시 방식이 바뀌었습니다",
|
||
what="{now_time}에 알림을 {from_channel} 방식으로 띄우지 못했습니다.",
|
||
why="{reason}",
|
||
how="{to_channel} 방식으로 대신 표시했습니다. 반복되면 [진단 실행]을 눌러 확인하세요.",
|
||
next_actions=("open_doctor", "dismiss"),
|
||
),
|
||
|
||
"NOTIFY_SUPPRESSED": _t(
|
||
code="NOTIFY_SUPPRESSED", severity=Severity.INFO, eventlog_id=520,
|
||
title="오늘 알림 표시를 멈췄습니다",
|
||
what="{now_time} 기준 오늘 알림 표시가 상한 {cap}건에 도달했습니다.",
|
||
why="같은 문제가 반복돼 알림이 지나치게 많이 뜨는 것을 막기 위한 조치입니다.",
|
||
how="남은 {pending_n}건은 [전체 진단] 화면에서 한꺼번에 확인할 수 있습니다.",
|
||
next_actions=("open_doctor", "dismiss"),
|
||
cooldown_minutes=1440,
|
||
),
|
||
}
|
||
|
||
|
||
# ---------------------------------------------------- 임포트 시점 계약 검증
|
||
def _validate_registry() -> None:
|
||
for code, tpl in TEMPLATES.items():
|
||
assert tpl.code == code, f"{code}: code 필드 불일치"
|
||
for field_name in ("title", "what", "why", "how"):
|
||
value = getattr(tpl, field_name)
|
||
assert value and value.strip(), f"{code}: {field_name} 가 비었다 (요구 R7.3)"
|
||
assert tpl.next_actions, f"{code}: next_actions 가 비었다 (요구 R7.7)"
|
||
unknown = set(tpl.next_actions) - VALID_ACTIONS
|
||
assert not unknown, f"{code}: 알 수 없는 액션 키 {sorted(unknown)}"
|
||
if tpl.severity.at_least(Severity.WARN):
|
||
actionable = set(tpl.next_actions) - _TERMINAL_ONLY
|
||
assert actionable, (
|
||
f"{code}: 실패 알림에 실행 가능한 액션이 없다 — 막다른 골목 금지(R7.7)"
|
||
)
|
||
assert len(tpl.title) <= 60, f"{code}: title 이 너무 길다 ({len(tpl.title)}자)"
|
||
|
||
|
||
_validate_registry()
|
||
|
||
|
||
def render(code: str, ctx: Mapping[str, Any]) -> RenderedAlert:
|
||
"""알림 코드와 컨텍스트로 최종 문구를 만든다. 미등록 코드도 죽지 않는다."""
|
||
tpl = TEMPLATES.get(code)
|
||
if tpl is None:
|
||
return RenderedAlert(
|
||
code=code, severity=Severity.ERROR,
|
||
title=f"알 수 없는 오류가 기록됐습니다 ({code})",
|
||
what=f"{code} 오류가 발생했습니다.",
|
||
why="이 오류에 대한 안내 문구가 아직 등록되지 않았습니다.",
|
||
how="[로그 열기]로 pipeline.log 를 확인하세요.",
|
||
body=(f"[무엇] {code} 오류가 발생했습니다.\n"
|
||
f"[왜] 이 오류에 대한 안내 문구가 아직 등록되지 않았습니다.\n"
|
||
f"[어떻게] [로그 열기]로 pipeline.log 를 확인하세요.\n"
|
||
f"로그: {ctx.get('log_dir', '(정보 없음)')}"),
|
||
next_actions=("open_log_dir", "open_doctor", "dismiss"),
|
||
)
|
||
return tpl.render(ctx)
|
||
```
|
||
|
||
---
|
||
|
||
## 4. 버튼 액션 구현
|
||
|
||
### 4.1 액션 키 레지스트리
|
||
|
||
토스트 버튼과 복구 GUI 버튼은 **같은 레지스트리를 호출한다.** 액션은 `src/dmf_crawler/gui/steps.py` 에 산다(아키텍처 트리: "단계별 액션: 키 입력·발급페이지 열기·agy 설치·재로그인·작업 등록").
|
||
|
||
| 액션 키 | 라벨 | 하는 일 | 창을 닫는가 | 성공 검증 |
|
||
|---|---|---|---|---|
|
||
| `run_now` | 지금 다시 실행 | `dmf run --trigger manual` 을 자식 프로세스로 기동, 진행 창 표시 | 아니오(진행률로 전환) | 종료 코드 0 |
|
||
| `run_now_force` | 지금 다시 실행 (검증 우회) | `dmf run --trigger manual --force` | 아니오 | 종료 코드 0 |
|
||
| `report_only` | 리포트 다시 만들기 | `dmf report-only` | 아니오 | 리포트 파일 mtime 갱신 |
|
||
| `open_log_dir` | 로그 열기 | `explorer.exe <log_dir>` | 아니오 | — |
|
||
| `open_report_dir` | 폴더 열기 | `explorer.exe <report_dir>` | 아니오 | — |
|
||
| `open_report_file` | 리포트 열기 | `os.startfile(report_path)` | 예 | — |
|
||
| `open_raw_dir` | 원문 폴더 열기 | `explorer.exe <data/raw/YYYY-MM-DD>` | 아니오 | — |
|
||
| `open_backup_dir` | 백업 폴더 열기 | `explorer.exe <backup_dir>` | 아니오 | — |
|
||
| `open_db_dir` | DB 폴더 열기 | `explorer.exe /select,<db_path>` | 아니오 | — |
|
||
| `open_proposal` | AI 진단 결과 열기 | `os.startfile(proposal_path)`, 없으면 폴더 | 아니오 | — |
|
||
| `enter_api_key` | 인증키 입력 | 키 입력 다이얼로그 → 실호출 검증 → DPAPI 저장 | 아니오 | `checks.run_one("api_key_live")` |
|
||
| `open_api_portal` | 발급 페이지 열기 | `webbrowser.open(공공데이터포털 URL)` | 아니오 | — |
|
||
| `agy_relogin` | 로그인 창 열기 | 새 콘솔 창에 대화형 `agy` 기동 (§5) | 아니오 | `checks.run_one("agy_auth")` |
|
||
| `install_agy` | 지금 설치 | `scripts/bootstrap_agy.ps1` 무인 실행 + 진행률 | 아니오 | `agy --version` 성공 |
|
||
| `disable_agy` | AI 기능 끄기 | `config.local.toml` 에 `[agy] enabled = false` 기록 | 예 | 설정 재로드 |
|
||
| `reregister_tasks` | 자동 실행 다시 등록 | `dmf install-task` | 아니오 | `checks.run_one("tasks")` |
|
||
| `open_task_scheduler` | 작업 스케줄러 열기 | `mmc.exe taskschd.msc` | 아니오 | — |
|
||
| `restore_backup` | 백업으로 복원 | 백업 목록 다이얼로그 → 현재 DB 대피 → 복사 | 아니오 | `PRAGMA integrity_check` |
|
||
| `cleanup_disk` | 정리하기 | 보존 기간 지난 로그·원문·백업 삭제 후 확보량 표시 | 아니오 | 여유 공간 재측정 |
|
||
| `open_doctor` | 전체 진단 / 진단 실행 | `gui.app.launch(mode="inspect")` | 아니오 | — |
|
||
| `resume_schedule` | 자동 실행 재개 | `state/paused.flag` 삭제. **진단 전부 통과해야 활성화** | 예 | 플래그 부재 |
|
||
| `snooze` | 나중에 | `alerts.snooze(alert_id, minutes)` | 예 | — |
|
||
| `dismiss` | 닫기 | `alerts.mark_shown(alert_id)` 만 하고 닫음 | 예 | — |
|
||
|
||
**설계 규칙 3개**
|
||
|
||
1. **모든 액션은 예외를 던지지 않는다.** `ActionResult(ok, message)` 를 반환한다. 액션이 죽어서 복구 창이 사라지는 것은 최악이다.
|
||
2. **성공 검증이 있는 액션은 검증을 통과해야만 알림을 해소한다**(R-D4). 버튼을 눌렀다는 사실만으로는 해소하지 않는다.
|
||
3. **모든 액션은 `logs/agent/actions.jsonl` 에 기록된다.** 사용자가 무엇을 눌렀고 결과가 무엇이었는지 사후 추적이 가능해야 "고쳤는데 또 뜬다"를 진단할 수 있다.
|
||
|
||
### 4.2 `gui/steps.py` 전문
|
||
|
||
```python
|
||
# src/dmf_crawler/gui/steps.py
|
||
"""알림·복구 화면의 버튼 액션 구현.
|
||
|
||
계약:
|
||
- 모든 액션은 ActionResult 를 반환하며 예외를 밖으로 던지지 않는다.
|
||
- 액션 키는 notify.messages.VALID_ACTIONS 와 1:1 로 일치한다.
|
||
- 액션 실행은 전부 logs/agent/actions.jsonl 에 기록된다.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import os
|
||
import shutil
|
||
import subprocess
|
||
import sys
|
||
import webbrowser
|
||
from dataclasses import dataclass
|
||
from datetime import datetime, timedelta
|
||
from pathlib import Path
|
||
from typing import Any, Callable, Mapping
|
||
|
||
from dmf_crawler import paths
|
||
from dmf_crawler.config import Config
|
||
|
||
# 공공데이터포털 — 인증키 발급/조회
|
||
API_PORTAL_URL = "https://www.data.go.kr/iim/api/selectAPIAcountView.do"
|
||
# agy 공식 설치 스크립트 (docs/research/05a-agy-cli-ssot.md §3.2)
|
||
AGY_INSTALL_URL = "https://antigravity.google/cli/install.ps1"
|
||
|
||
# 콘솔 창을 만들지 않는다. pythonw 로 떠 있는 GUI 에서 검은 창이 번쩍이면 안 된다.
|
||
CREATE_NO_WINDOW = 0x08000000
|
||
# 새 콘솔 창을 "보이게" 만든다. agy 재로그인에서만 쓴다.
|
||
CREATE_NEW_CONSOLE = 0x00000010
|
||
|
||
|
||
@dataclass(frozen=True, slots=True)
|
||
class ActionResult:
|
||
ok: bool
|
||
message: str
|
||
close_window: bool = False
|
||
verify_check_key: str | None = None # 성공 검증에 쓸 checks 키
|
||
|
||
|
||
# ------------------------------------------------------------------ 유틸
|
||
def _venv_python(windowed: bool = False) -> Path:
|
||
"""현재 프로젝트 .venv 의 인터프리터. windowed=True 면 pythonw."""
|
||
exe = "pythonw.exe" if windowed else "python.exe"
|
||
candidate = paths.PROJECT_ROOT / ".venv" / "Scripts" / exe
|
||
if candidate.exists():
|
||
return candidate
|
||
return Path(sys.executable)
|
||
|
||
|
||
def _explorer(target: Path, select: bool = False) -> ActionResult:
|
||
if not target.exists():
|
||
parent = target.parent
|
||
if not parent.exists():
|
||
return ActionResult(False, f"경로를 찾을 수 없습니다: {target}")
|
||
target, select = parent, False
|
||
args = ["explorer.exe"]
|
||
args.append(f"/select,{target}" if select else str(target))
|
||
# explorer.exe 는 성공해도 종료 코드 1 을 반환하는 일이 잦다. 코드를 보지 않는다.
|
||
subprocess.Popen(args, creationflags=CREATE_NO_WINDOW)
|
||
return ActionResult(True, f"탐색기를 열었습니다: {target}")
|
||
|
||
|
||
def _spawn_cli(cfg: Config, argv: list[str], *, new_console: bool = False,
|
||
env_extra: Mapping[str, str] | None = None) -> subprocess.Popen:
|
||
env = os.environ.copy()
|
||
# 배치 중 agy 자동 업데이트가 끼어드는 것을 막는다 (agy SSOT §3.4)
|
||
env["AGY_CLI_DISABLE_AUTO_UPDATE"] = "true"
|
||
if env_extra:
|
||
env.update(env_extra)
|
||
flags = CREATE_NEW_CONSOLE if new_console else CREATE_NO_WINDOW
|
||
py = _venv_python(windowed=not new_console)
|
||
return subprocess.Popen(
|
||
[str(py), "-m", "dmf_crawler", *argv],
|
||
cwd=str(paths.PROJECT_ROOT),
|
||
env=env,
|
||
creationflags=flags,
|
||
)
|
||
|
||
|
||
def _log_action(key: str, result: ActionResult, ctx: Mapping[str, Any]) -> None:
|
||
"""액션 감사 로그. 실패해도 조용히 넘어간다."""
|
||
try:
|
||
log_dir = paths.LOGS_DIR / "agent"
|
||
log_dir.mkdir(parents=True, exist_ok=True)
|
||
record = {
|
||
"at": datetime.now().astimezone().isoformat(timespec="seconds"),
|
||
"action": key,
|
||
"ok": result.ok,
|
||
"message": result.message,
|
||
"alert_code": ctx.get("code"),
|
||
"alert_id": ctx.get("alert_id"),
|
||
}
|
||
with (log_dir / "actions.jsonl").open("a", encoding="utf-8") as fh:
|
||
fh.write(json.dumps(record, ensure_ascii=False) + "\n")
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
# ------------------------------------------------------------ 액션 구현부
|
||
def act_run_now(cfg: Config, ctx: Mapping[str, Any]) -> ActionResult:
|
||
_spawn_cli(cfg, ["run", "--trigger", "manual"])
|
||
return ActionResult(True, "수집을 시작했습니다. 약 2분 걸립니다.",
|
||
verify_check_key="last_run")
|
||
|
||
|
||
def act_run_now_force(cfg: Config, ctx: Mapping[str, Any]) -> ActionResult:
|
||
_spawn_cli(cfg, ["run", "--trigger", "manual", "--force"])
|
||
return ActionResult(True, "검증을 우회해 수집을 시작했습니다.",
|
||
verify_check_key="last_run")
|
||
|
||
|
||
def act_report_only(cfg: Config, ctx: Mapping[str, Any]) -> ActionResult:
|
||
_spawn_cli(cfg, ["report-only"])
|
||
return ActionResult(True, "리포트를 다시 만들고 있습니다. 약 20초 걸립니다.",
|
||
verify_check_key="report_writable")
|
||
|
||
|
||
def act_open_log_dir(cfg: Config, ctx: Mapping[str, Any]) -> ActionResult:
|
||
raw = ctx.get("log_dir")
|
||
target = Path(raw) if raw else paths.LOGS_DIR
|
||
return _explorer(target)
|
||
|
||
|
||
def act_open_report_dir(cfg: Config, ctx: Mapping[str, Any]) -> ActionResult:
|
||
return _explorer(paths.reports_dir(cfg))
|
||
|
||
|
||
def act_open_report_file(cfg: Config, ctx: Mapping[str, Any]) -> ActionResult:
|
||
raw = ctx.get("report_path")
|
||
target = Path(raw) if raw else paths.latest_report_path(cfg)
|
||
if not target.exists():
|
||
return _explorer(paths.reports_dir(cfg))
|
||
try:
|
||
os.startfile(str(target)) # noqa: S606 — Windows 전용, 의도된 호출
|
||
except OSError as exc:
|
||
return ActionResult(False, f"리포트를 열지 못했습니다: {exc}")
|
||
return ActionResult(True, "리포트를 열었습니다.", close_window=True)
|
||
|
||
|
||
def act_open_raw_dir(cfg: Config, ctx: Mapping[str, Any]) -> ActionResult:
|
||
raw = ctx.get("raw_dir")
|
||
target = Path(raw) if raw else paths.RAW_DIR
|
||
return _explorer(target)
|
||
|
||
|
||
def act_open_backup_dir(cfg: Config, ctx: Mapping[str, Any]) -> ActionResult:
|
||
return _explorer(paths.backup_dir(cfg))
|
||
|
||
|
||
def act_open_db_dir(cfg: Config, ctx: Mapping[str, Any]) -> ActionResult:
|
||
return _explorer(paths.db_path(cfg), select=True)
|
||
|
||
|
||
def act_open_proposal(cfg: Config, ctx: Mapping[str, Any]) -> ActionResult:
|
||
raw = ctx.get("proposal_path")
|
||
if raw and Path(raw).exists():
|
||
try:
|
||
os.startfile(raw) # noqa: S606
|
||
return ActionResult(True, "AI 진단 결과를 열었습니다.")
|
||
except OSError as exc:
|
||
return ActionResult(False, f"파일을 열지 못했습니다: {exc}")
|
||
return _explorer(paths.PROPOSALS_DIR)
|
||
|
||
|
||
def act_open_api_portal(cfg: Config, ctx: Mapping[str, Any]) -> ActionResult:
|
||
webbrowser.open(API_PORTAL_URL)
|
||
return ActionResult(True, "브라우저에서 공공데이터포털을 열었습니다.")
|
||
|
||
|
||
def act_open_task_scheduler(cfg: Config, ctx: Mapping[str, Any]) -> ActionResult:
|
||
subprocess.Popen(["mmc.exe", "taskschd.msc"], creationflags=CREATE_NO_WINDOW)
|
||
return ActionResult(True, "작업 스케줄러를 열었습니다. "
|
||
"'작업 스케줄러 라이브러리'에서 DMF_Crawler 로 시작하는 "
|
||
"항목 3개를 확인하세요.")
|
||
|
||
|
||
def act_reregister_tasks(cfg: Config, ctx: Mapping[str, Any]) -> ActionResult:
|
||
proc = _spawn_cli(cfg, ["install-task"])
|
||
try:
|
||
rc = proc.wait(timeout=60)
|
||
except subprocess.TimeoutExpired:
|
||
return ActionResult(False, "등록이 60초 안에 끝나지 않았습니다. "
|
||
"관리자 권한이 필요할 수 있습니다.")
|
||
if rc != 0:
|
||
return ActionResult(False, f"등록에 실패했습니다(코드 {rc}). "
|
||
"이 창을 관리자 권한으로 다시 실행해 보세요.")
|
||
return ActionResult(True, "자동 실행 작업 3개를 다시 등록했습니다.",
|
||
verify_check_key="tasks")
|
||
|
||
|
||
def act_disable_agy(cfg: Config, ctx: Mapping[str, Any]) -> ActionResult:
|
||
local = paths.CONFIG_DIR / "config.local.toml"
|
||
try:
|
||
existing = local.read_text(encoding="utf-8") if local.exists() else ""
|
||
if "[agy]" in existing:
|
||
lines = []
|
||
in_agy = False
|
||
wrote = False
|
||
for line in existing.splitlines():
|
||
stripped = line.strip()
|
||
if stripped.startswith("["):
|
||
if in_agy and not wrote:
|
||
lines.append("enabled = false")
|
||
wrote = True
|
||
in_agy = stripped == "[agy]"
|
||
if in_agy and stripped.startswith("enabled"):
|
||
lines.append("enabled = false")
|
||
wrote = True
|
||
continue
|
||
lines.append(line)
|
||
if in_agy and not wrote:
|
||
lines.append("enabled = false")
|
||
new_text = "\n".join(lines) + "\n"
|
||
else:
|
||
new_text = existing.rstrip() + "\n\n[agy]\nenabled = false\n"
|
||
tmp = local.with_suffix(".toml.tmp")
|
||
tmp.write_text(new_text, encoding="utf-8")
|
||
os.replace(tmp, local)
|
||
except OSError as exc:
|
||
return ActionResult(False, f"설정을 저장하지 못했습니다: {exc}")
|
||
return ActionResult(True, "AI 요약 기능을 껐습니다. 리포트는 계속 생성됩니다.",
|
||
close_window=True)
|
||
|
||
|
||
def act_cleanup_disk(cfg: Config, ctx: Mapping[str, Any]) -> ActionResult:
|
||
freed = 0
|
||
now = datetime.now()
|
||
|
||
def _prune(root: Path, days: int) -> None:
|
||
nonlocal freed
|
||
if not root.exists():
|
||
return
|
||
cutoff = now - timedelta(days=days)
|
||
for child in sorted(root.iterdir()):
|
||
try:
|
||
mtime = datetime.fromtimestamp(child.stat().st_mtime)
|
||
if mtime >= cutoff:
|
||
continue
|
||
size = sum(f.stat().st_size for f in child.rglob("*") if f.is_file()) \
|
||
if child.is_dir() else child.stat().st_size
|
||
if child.is_dir():
|
||
shutil.rmtree(child, ignore_errors=True)
|
||
else:
|
||
child.unlink(missing_ok=True)
|
||
freed += size
|
||
except OSError:
|
||
continue
|
||
|
||
_prune(paths.LOGS_DIR, cfg.logging.retain_days)
|
||
_prune(paths.RAW_DIR, cfg.source.archive_retain_days)
|
||
|
||
# 백업은 날짜가 아니라 개수 기준(보존 정책과 동일)
|
||
backups = sorted(paths.backup_dir(cfg).glob("dmf_*.sqlite3"))
|
||
for old in backups[:-cfg.backup.keep_count] if len(backups) > cfg.backup.keep_count else []:
|
||
try:
|
||
freed += old.stat().st_size
|
||
old.unlink()
|
||
except OSError:
|
||
continue
|
||
|
||
mb = freed / (1024 * 1024)
|
||
return ActionResult(True, f"{mb:,.0f}MB 를 정리했습니다.",
|
||
verify_check_key="disk_free")
|
||
|
||
|
||
def act_snooze(cfg: Config, ctx: Mapping[str, Any]) -> ActionResult:
|
||
minutes = int(ctx.get("snooze_minutes", cfg.notify.snooze_minutes))
|
||
return ActionResult(True, f"{minutes}분 뒤에 다시 알려드립니다.", close_window=True)
|
||
|
||
|
||
def act_dismiss(cfg: Config, ctx: Mapping[str, Any]) -> ActionResult:
|
||
return ActionResult(True, "닫았습니다.", close_window=True)
|
||
|
||
|
||
# --- 아래 4개는 별도 절에서 상세히 다룬다 -------------------------------
|
||
# act_enter_api_key : 온보딩 마법사 명세(docs/design/04-onboarding-wizard.md)
|
||
# act_restore_backup : 백업 복원 마법사(같은 문서)
|
||
# act_open_doctor : gui.app.launch(mode="inspect")
|
||
# act_install_agy : §5.4
|
||
# act_agy_relogin : §5.2 <- 이 문서의 핵심
|
||
def act_open_doctor(cfg: Config, ctx: Mapping[str, Any]) -> ActionResult:
|
||
from dmf_crawler.gui import app as gui_app
|
||
gui_app.launch(mode="inspect")
|
||
return ActionResult(True, "진단 화면을 열었습니다.")
|
||
|
||
|
||
def act_resume_schedule(cfg: Config, ctx: Mapping[str, Any]) -> ActionResult:
|
||
from dmf_crawler import checks
|
||
failing = [c for c in checks.run_all(cfg) if not c.ok]
|
||
if failing:
|
||
names = ", ".join(c.title for c in failing[:3])
|
||
return ActionResult(False,
|
||
f"아직 해결되지 않은 항목이 있습니다: {names}. "
|
||
f"먼저 [전체 진단]에서 해결하세요.")
|
||
try:
|
||
paths.PAUSE_FLAG.unlink(missing_ok=True)
|
||
except OSError as exc:
|
||
return ActionResult(False, f"멈춤 해제에 실패했습니다: {exc}")
|
||
return ActionResult(True, "자동 실행을 재개했습니다. 내일 06:00부터 정상 동작합니다.",
|
||
close_window=True)
|
||
|
||
|
||
# ------------------------------------------------------------- 레지스트리
|
||
ACTIONS: dict[str, tuple[str, Callable[[Config, Mapping[str, Any]], ActionResult]]] = {
|
||
"run_now": ("지금 다시 실행", act_run_now),
|
||
"run_now_force": ("지금 다시 실행 (검증 우회)", act_run_now_force),
|
||
"report_only": ("리포트 다시 만들기", act_report_only),
|
||
"open_log_dir": ("로그 열기", act_open_log_dir),
|
||
"open_report_dir": ("폴더 열기", act_open_report_dir),
|
||
"open_report_file": ("리포트 열기", act_open_report_file),
|
||
"open_raw_dir": ("원문 폴더 열기", act_open_raw_dir),
|
||
"open_backup_dir": ("백업 폴더 열기", act_open_backup_dir),
|
||
"open_db_dir": ("DB 폴더 열기", act_open_db_dir),
|
||
"open_proposal": ("AI 진단 결과 열기", act_open_proposal),
|
||
"open_api_portal": ("발급 페이지 열기", act_open_api_portal),
|
||
"open_task_scheduler": ("작업 스케줄러 열기", act_open_task_scheduler),
|
||
"reregister_tasks": ("자동 실행 다시 등록", act_reregister_tasks),
|
||
"disable_agy": ("AI 기능 끄기", act_disable_agy),
|
||
"cleanup_disk": ("정리하기", act_cleanup_disk),
|
||
"open_doctor": ("전체 진단", act_open_doctor),
|
||
"resume_schedule": ("자동 실행 재개", act_resume_schedule),
|
||
"snooze": ("나중에", act_snooze),
|
||
"dismiss": ("닫기", act_dismiss),
|
||
# §5 에서 정의
|
||
"agy_relogin": ("로그인 창 열기", None), # noqa: 아래에서 채움
|
||
"install_agy": ("지금 설치", None),
|
||
# 온보딩 마법사 문서에서 정의
|
||
"enter_api_key": ("인증키 입력", None),
|
||
"restore_backup": ("백업으로 복원", None),
|
||
}
|
||
|
||
|
||
def label_of(key: str) -> str:
|
||
entry = ACTIONS.get(key)
|
||
return entry[0] if entry else key
|
||
|
||
|
||
def invoke(key: str, cfg: Config, ctx: Mapping[str, Any]) -> ActionResult:
|
||
"""액션 실행 단일 진입점. 절대 예외를 던지지 않는다."""
|
||
entry = ACTIONS.get(key)
|
||
if entry is None or entry[1] is None:
|
||
result = ActionResult(False, f"'{key}' 동작이 아직 구현되지 않았습니다.")
|
||
_log_action(key, result, ctx)
|
||
return result
|
||
try:
|
||
result = entry[1](cfg, ctx)
|
||
except Exception as exc: # noqa: BLE001 — 액션은 절대 창을 죽이면 안 된다
|
||
result = ActionResult(False, f"동작 중 문제가 생겼습니다: {exc}")
|
||
_log_action(key, result, ctx)
|
||
return result
|
||
```
|
||
|
||
### 4.3 성공 검증과 알림 해소의 연결
|
||
|
||
버튼을 누른 뒤 **검증 키가 있는 액션**은 다음 흐름을 탄다.
|
||
|
||
```python
|
||
# gui/app.py 안 (발췌)
|
||
def on_action_clicked(self, alert: AlertRow, action_key: str) -> None:
|
||
result = steps.invoke(action_key, self.cfg, alert.as_context())
|
||
self.status_bar.set(result.message, ok=result.ok)
|
||
|
||
if result.ok and result.verify_check_key:
|
||
# 즉시 검증하지 않고, 최대 120초 동안 2초 간격으로 폴링한다.
|
||
# (run_now 처럼 자식 프로세스가 끝나야 결과가 나오는 액션이 있다)
|
||
self.after(2000, lambda: self._poll_verify(alert, result.verify_check_key, 0))
|
||
|
||
if result.close_window:
|
||
if action_key == "snooze":
|
||
alerts.snooze(self.conn, alert.alert_id,
|
||
minutes=self.cfg.notify.snooze_minutes)
|
||
else:
|
||
alerts.mark_shown(self.conn, alert.alert_id, channel="modal")
|
||
self.destroy()
|
||
|
||
|
||
def _poll_verify(self, alert: AlertRow, check_key: str, elapsed_s: int) -> None:
|
||
outcome = checks.run_one(check_key, self.cfg)
|
||
if outcome.ok:
|
||
alerts.resolve(self.conn, alert.code,
|
||
note=f"사용자 조치 후 {check_key} 통과")
|
||
self.status_bar.set("해결됐습니다. 이 알림을 닫습니다.", ok=True)
|
||
self.after(1500, self.destroy)
|
||
return
|
||
if elapsed_s >= 120:
|
||
self.status_bar.set(
|
||
f"아직 해결되지 않았습니다: {outcome.detail}", ok=False)
|
||
return
|
||
self.after(2000, lambda: self._poll_verify(alert, check_key, elapsed_s + 2))
|
||
```
|
||
|
||
> **왜 폴링인가**: `run_now` 는 자식 프로세스를 띄우고 즉시 반환한다. 버튼을 누른 직후 검증하면 항상 실패한다. 사용자가 "고쳤는데 왜 안 없어지지"라고 느끼는 지점이 정확히 여기다. 최대 120초 폴링이 이 간극을 메운다.
|
||
|
||
### 4.4 프로토콜 핸들러 (`dmf://`) — 지금은 불필요, 등록 방법은 완비
|
||
|
||
**결론부터: 기본 경로에서는 등록하지 않는다.** 우리 토스트는 tkinter 창이고, 버튼은 같은 프로세스 안에서 `steps.invoke()` 를 직접 호출한다. OS 를 경유할 이유가 없다.
|
||
|
||
**필요해지는 경우**는 두 가지다.
|
||
|
||
1. 진짜 Windows 토스트(액션 센터 잔류)를 선택 의존성으로 켜는 날. XML 토스트의 `<action activationType="protocol" arguments="dmf://run_now">` 는 프로토콜 등록 없이는 동작하지 않는다.
|
||
2. 리포트 xlsx 안에서 하이퍼링크로 액션을 걸고 싶을 때(예: 대시보드 시트의 "지금 다시 실행" 링크).
|
||
|
||
**등록 방법 — HKCU 만 쓴다(관리자 권한 불필요).**
|
||
|
||
```powershell
|
||
# scripts/register_protocol.ps1
|
||
# dmf:// 프로토콜 핸들러를 현재 사용자에게만 등록한다. 관리자 권한이 필요 없다.
|
||
[CmdletBinding()]
|
||
param(
|
||
[string]$ProjectRoot = (Split-Path -Parent $PSScriptRoot),
|
||
[switch]$Unregister
|
||
)
|
||
|
||
$ErrorActionPreference = 'Stop'
|
||
$key = 'HKCU:\Software\Classes\dmf'
|
||
|
||
if ($Unregister) {
|
||
if (Test-Path $key) { Remove-Item $key -Recurse -Force }
|
||
Write-Host 'dmf:// 프로토콜 등록을 해제했습니다.'
|
||
return
|
||
}
|
||
|
||
$pythonw = Join-Path $ProjectRoot '.venv\Scripts\pythonw.exe'
|
||
if (-not (Test-Path $pythonw)) { throw "pythonw.exe 를 찾을 수 없습니다: $pythonw" }
|
||
|
||
# "URL Protocol" 이라는 (값이 빈) 값 이름이 있어야 셸이 프로토콜로 인식한다.
|
||
New-Item -Path $key -Force | Out-Null
|
||
Set-ItemProperty -Path $key -Name '(default)' -Value 'URL:DMF Crawler Protocol'
|
||
Set-ItemProperty -Path $key -Name 'URL Protocol' -Value ''
|
||
|
||
New-Item -Path "$key\DefaultIcon" -Force | Out-Null
|
||
Set-ItemProperty -Path "$key\DefaultIcon" -Name '(default)' -Value "$pythonw,0"
|
||
|
||
New-Item -Path "$key\shell\open\command" -Force | Out-Null
|
||
# %1 은 전체 URL(dmf://run_now?alert_id=42)이 통째로 넘어온다. 반드시 큰따옴표로 감싼다.
|
||
$cmd = "`"$pythonw`" -m dmf_crawler handle-uri `"%1`""
|
||
Set-ItemProperty -Path "$key\shell\open\command" -Name '(default)' -Value $cmd
|
||
|
||
Write-Host "dmf:// 프로토콜을 등록했습니다."
|
||
Write-Host "테스트: Win+R 에 다음을 입력하세요 → dmf://open_doctor"
|
||
```
|
||
|
||
**동등한 .reg 파일** (수동 배포용)
|
||
|
||
```reg
|
||
Windows Registry Editor Version 5.00
|
||
|
||
[HKEY_CURRENT_USER\Software\Classes\dmf]
|
||
@="URL:DMF Crawler Protocol"
|
||
"URL Protocol"=""
|
||
|
||
[HKEY_CURRENT_USER\Software\Classes\dmf\DefaultIcon]
|
||
@="D:\\workspace\\DMF_Crawler\\.venv\\Scripts\\pythonw.exe,0"
|
||
|
||
[HKEY_CURRENT_USER\Software\Classes\dmf\shell\open\command]
|
||
@="\"D:\\workspace\\DMF_Crawler\\.venv\\Scripts\\pythonw.exe\" -m dmf_crawler handle-uri \"%1\""
|
||
```
|
||
|
||
**수신측 — `dmf handle-uri` 서브커맨드**
|
||
|
||
URI 는 **외부에서 들어오는 신뢰할 수 없는 입력**이다. 화이트리스트 검증 없이 실행하면 임의 명령 실행 취약점이 된다.
|
||
|
||
```python
|
||
# src/dmf_crawler/cli.py (발췌) — handle-uri 구현
|
||
from urllib.parse import urlparse, parse_qs
|
||
|
||
def cmd_handle_uri(args) -> int:
|
||
"""dmf://<action_key>?alert_id=<n> 형태만 허용한다."""
|
||
from dmf_crawler.gui import steps
|
||
from dmf_crawler.config import load_config
|
||
from dmf_crawler.notify import messages
|
||
|
||
parsed = urlparse(args.uri)
|
||
if parsed.scheme != "dmf":
|
||
return 2
|
||
# netloc 에 액션 키가 온다: dmf://run_now -> netloc == "run_now"
|
||
action_key = (parsed.netloc or parsed.path.lstrip("/")).strip().lower()
|
||
|
||
# 화이트리스트 검증. 등록되지 않은 키는 무조건 거부한다.
|
||
if action_key not in messages.VALID_ACTIONS:
|
||
return 2
|
||
|
||
qs = parse_qs(parsed.query)
|
||
ctx: dict[str, object] = {}
|
||
raw_id = qs.get("alert_id", [""])[0]
|
||
if raw_id.isdigit(): # 숫자만 허용
|
||
ctx["alert_id"] = int(raw_id)
|
||
|
||
cfg = load_config()
|
||
result = steps.invoke(action_key, cfg, ctx)
|
||
return 0 if result.ok else 1
|
||
```
|
||
|
||
| 검증 항목 | 규칙 |
|
||
|---|---|
|
||
| scheme | `dmf` 가 아니면 즉시 거부 |
|
||
| 액션 키 | `VALID_ACTIONS` 화이트리스트에 없으면 거부. **경로·명령 문자열을 URI 에서 받지 않는다** |
|
||
| `alert_id` | 숫자만. 그 외 쿼리 파라미터는 전부 버린다 |
|
||
| 파일 경로 | **URI 로 절대 받지 않는다.** 경로는 항상 `paths.py` 와 DB 에서만 온다 |
|
||
|
||
---
|
||
|
||
## 5. agy 재로그인 유도
|
||
|
||
### 5.1 문제 정의와 Session 0 제약
|
||
|
||
| 사실 | 출처 | 함의 |
|
||
|---|---|---|
|
||
| `agy` OAuth 토큰은 `~/.gemini/antigravity-cli/antigravity-oauth-token` **평문 파일** | agy SSOT §4.2 실측 | 작업 스케줄러를 **동일 사용자 계정**으로 돌리면 인증이 통과한다. SYSTEM 계정 금지 |
|
||
| Windows Credential Manager 에는 항목이 없다 | agy SSOT §4.2 실측 (`cmdkey /list` 무결과) | S4U(암호 미저장)로 실행해도 키링 잠금 문제가 없다. **다만 사용자 프로필이 로드돼야 한다** |
|
||
| `access_token` 은 만료된다 | agy SSOT §4.2 | refresh 실패 시 배치가 인증 오류로 죽는다. **미인증 감지 → 사용자 알림 경로가 필수** |
|
||
| 최초 1회는 대화형 로그인이 필수 | agy SSOT §0 | 헤드리스는 캐시된 자격증명만 쓴다. 로그인은 브라우저 OAuth 를 요구한다 |
|
||
| 로그인 흐름은 **기본 브라우저를 연다** | agy SSOT §4.1 | 데스크톱이 없는 세션에서는 브라우저가 뜨지 않는다 |
|
||
|
||
**Session 0 제약이 만드는 막힘**
|
||
|
||
```
|
||
06:00 DMF_Crawler_Daily (S4U, 비대화형)
|
||
-> agy -p ... 실행
|
||
-> 토큰 만료, refresh 실패
|
||
-> agy 가 브라우저를 열려고 시도
|
||
-> S4U 세션에는 데스크톱이 없다 -> 아무 창도 안 뜬다
|
||
-> agy 가 stderr 로 인증 프롬프트를 출력하지만 읽을 사람이 없다
|
||
-> --print-timeout 까지 대기하다 타임아웃
|
||
===> 배치가 침묵 속에 매일 실패한다
|
||
```
|
||
|
||
**우회 = 구조적 분리 (ADR-10/11)**
|
||
|
||
```
|
||
[배치 S4U] agy 실패 -> classify_error == AUTH
|
||
-> AgyEnvelope 를 값으로 반환 (예외 없음)
|
||
-> alerts.raise_alert(code="AGY_AUTH", severity=CRITICAL)
|
||
-> 리포트는 AI 요약 없이 정상 생성, 종료 코드 0
|
||
|
|
||
| (최대 15분)
|
||
v
|
||
[Agent Interactive] pending() 에서 AGY_AUTH 발견
|
||
-> 강제 모달 표시, [로그인 창 열기] 버튼
|
||
|
|
||
| 사용자 클릭
|
||
v
|
||
[새 콘솔 창] CREATE_NEW_CONSOLE 로 agy 대화형 기동
|
||
-> agy 가 기본 브라우저를 연다 (여기는 데스크톱이 있다)
|
||
-> 사용자가 Google 로그인
|
||
-> 토큰 파일 갱신
|
||
|
|
||
v
|
||
[Agent] 토큰 파일 mtime 변화 감지 -> 헬스 프롬프트 1회로 실증
|
||
-> 성공하면 alerts.resolve("AGY_AUTH")
|
||
```
|
||
|
||
**절대 하지 말 것**
|
||
|
||
| 금지 | 이유 |
|
||
|---|---|
|
||
| 배치(S4U)에서 `CREATE_NEW_CONSOLE` 로 agy 를 띄우기 | Session 0 에 콘솔이 만들어지고 아무도 못 본다. 프로세스만 영원히 남는다 |
|
||
| `psexec -i 1` 등으로 세션 주입 | 관리자 권한·보안 소프트웨어 충돌. 로그온 세션이 없으면 여전히 실패 |
|
||
| 토큰 파일을 코드가 직접 갱신 | 리프레시 프로토콜을 재구현하는 것. agy 가 바뀌면 즉시 깨진다 |
|
||
| `GEMINI_API_KEY` 로 전환해 로그인 자체를 없애기 | 가능은 하다(agy SSOT §4.1). 그러나 **다른 과금 체계**로 넘어가는 결정이므로 알림 설계가 임의로 할 수 없다. 부록에 미결로 남긴다 |
|
||
|
||
### 5.2 `act_agy_relogin` 전문
|
||
|
||
```python
|
||
# src/dmf_crawler/gui/steps.py (이어서)
|
||
"""agy 재로그인 — 새 콘솔 창을 띄우는 유일한 액션."""
|
||
from __future__ import annotations
|
||
|
||
import subprocess
|
||
import threading
|
||
import time
|
||
from pathlib import Path
|
||
from typing import Any, Mapping
|
||
|
||
AGY_TOKEN_PATH = Path.home() / ".gemini" / "antigravity-cli" / "antigravity-oauth-token"
|
||
AGY_DEFAULT_EXE = Path(os.environ.get("LOCALAPPDATA", "")) / "agy" / "bin" / "agy.exe"
|
||
|
||
# 사용자가 콘솔 창에서 무엇을 해야 하는지 안내하는 배너.
|
||
# agy 자체는 한국어 안내를 하지 않으므로 우리가 감싼다.
|
||
_RELOGIN_BANNER = r"""
|
||
@echo off
|
||
chcp 65001 > nul
|
||
title DMF 크롤러 - Antigravity CLI 로그인
|
||
echo.
|
||
echo ============================================================
|
||
echo Antigravity CLI (agy) 로그인
|
||
echo ============================================================
|
||
echo.
|
||
echo 1. 잠시 후 기본 브라우저가 자동으로 열립니다.
|
||
echo 2. Google 계정으로 로그인하세요.
|
||
echo 3. 브라우저에 "로그인 완료" 가 뜨면 이 창으로 돌아오세요.
|
||
echo 4. 이 창에 프롬프트가 보이면 /quit 를 입력하고 Enter 를 누르세요.
|
||
echo.
|
||
echo * 브라우저가 열리지 않으면 이 창에 표시되는 URL 을 복사해
|
||
echo 브라우저 주소창에 붙여넣으세요.
|
||
echo.
|
||
echo ============================================================
|
||
echo.
|
||
"%AGY_EXE%"
|
||
echo.
|
||
echo ============================================================
|
||
echo 로그인 절차가 끝났습니다. 이 창은 닫아도 됩니다.
|
||
echo DMF 크롤러 창으로 돌아가면 자동으로 확인합니다.
|
||
echo ============================================================
|
||
echo.
|
||
pause
|
||
"""
|
||
|
||
|
||
def _agy_exe(cfg: Config) -> Path:
|
||
configured = (cfg.agy.binary_path or "").strip()
|
||
if configured:
|
||
return Path(configured)
|
||
return AGY_DEFAULT_EXE
|
||
|
||
|
||
def act_agy_relogin(cfg: Config, ctx: Mapping[str, Any]) -> ActionResult:
|
||
"""새 콘솔 창에서 대화형 agy 를 띄운다.
|
||
|
||
핵심:
|
||
- CREATE_NEW_CONSOLE : 반드시 보이는 콘솔이어야 한다. pythonw 에서 뜨는
|
||
자식은 부모의 콘솔을 물려받지 않으므로 이 플래그가 없으면
|
||
stdin 이 없어 agy 가 즉시 종료된다.
|
||
- AGY_CLI_DISABLE_AUTO_UPDATE : 로그인 중 바이너리가 교체되는 사고를 막는다.
|
||
- .cmd 래퍼 : agy 를 직접 띄우면 사용자가 무엇을 해야 하는지 모른다.
|
||
한국어 안내 배너를 앞뒤로 감싼다.
|
||
"""
|
||
exe = _agy_exe(cfg)
|
||
if not exe.exists():
|
||
return ActionResult(
|
||
False,
|
||
f"agy 실행 파일이 없습니다: {exe}\n"
|
||
f"먼저 [지금 설치]를 눌러 설치하세요.")
|
||
|
||
# 래퍼 배치 파일을 로그 디렉터리에 만든다(임시 폴더는 백신이 막는 경우가 있다).
|
||
wrapper_dir = paths.STATE_DIR / "tmp"
|
||
wrapper_dir.mkdir(parents=True, exist_ok=True)
|
||
wrapper = wrapper_dir / "agy_login.cmd"
|
||
try:
|
||
wrapper.write_text(_RELOGIN_BANNER, encoding="utf-8")
|
||
except OSError as exc:
|
||
return ActionResult(False, f"로그인 도우미 파일을 만들지 못했습니다: {exc}")
|
||
|
||
env = os.environ.copy()
|
||
env["AGY_EXE"] = str(exe)
|
||
env["AGY_CLI_DISABLE_AUTO_UPDATE"] = "true"
|
||
|
||
token_mtime_before = _token_mtime()
|
||
|
||
try:
|
||
subprocess.Popen(
|
||
["cmd.exe", "/c", str(wrapper)],
|
||
cwd=str(paths.PROJECT_ROOT),
|
||
env=env,
|
||
creationflags=CREATE_NEW_CONSOLE, # <- 이것이 전부다
|
||
close_fds=True,
|
||
)
|
||
except OSError as exc:
|
||
return ActionResult(False, f"로그인 창을 열지 못했습니다: {exc}")
|
||
|
||
# 백그라운드로 토큰 파일 변화를 지켜본다. 최대 10분.
|
||
threading.Thread(
|
||
target=_watch_token_change,
|
||
args=(cfg, token_mtime_before),
|
||
daemon=True,
|
||
name="agy-token-watch",
|
||
).start()
|
||
|
||
return ActionResult(
|
||
True,
|
||
"로그인 창을 열었습니다. 브라우저에서 Google 계정으로 로그인하세요. "
|
||
"완료되면 이 창이 자동으로 확인합니다.",
|
||
verify_check_key="agy_auth")
|
||
|
||
|
||
def _token_mtime() -> float:
|
||
try:
|
||
return AGY_TOKEN_PATH.stat().st_mtime
|
||
except OSError:
|
||
return 0.0
|
||
|
||
|
||
def _watch_token_change(cfg: Config, before: float, timeout_s: int = 600) -> None:
|
||
"""토큰 파일이 갱신되면 실제 호출 1회로 인증을 실증한다."""
|
||
deadline = time.monotonic() + timeout_s
|
||
while time.monotonic() < deadline:
|
||
time.sleep(3)
|
||
if _token_mtime() <= before:
|
||
continue
|
||
# 파일이 갱신됐다. 쓰기가 끝날 시간을 준 뒤 실증한다.
|
||
time.sleep(2)
|
||
if verify_agy_auth(cfg).ok:
|
||
return
|
||
# 갱신은 됐는데 실증 실패 -> 계속 지켜본다(사용자가 재시도 중일 수 있다)
|
||
before = _token_mtime()
|
||
|
||
|
||
def verify_agy_auth(cfg: Config) -> ActionResult:
|
||
"""헤드리스 최소 호출로 인증 상태를 실증한다.
|
||
|
||
agy SSOT §4.3 의 권장 헬스체크를 그대로 옮긴 것이다.
|
||
주의: 이 호출도 토큰을 소비한다. 재로그인 직후와 checks 화면에서만 호출하고,
|
||
06:00 배치에서는 절대 호출하지 않는다(ADR-16: 인증 상태는 실작업 결과로 판정).
|
||
"""
|
||
exe = _agy_exe(cfg)
|
||
if not exe.exists():
|
||
return ActionResult(False, "agy 실행 파일이 없습니다.")
|
||
|
||
env = os.environ.copy()
|
||
env["AGY_CLI_DISABLE_AUTO_UPDATE"] = "true"
|
||
try:
|
||
proc = subprocess.run(
|
||
[str(exe), "-p", "Reply with exactly: PONG",
|
||
"--output-format", "json", "--print-timeout", "90s"],
|
||
capture_output=True, text=True, encoding="utf-8", errors="replace",
|
||
env=env, timeout=120, creationflags=CREATE_NO_WINDOW,
|
||
)
|
||
except subprocess.TimeoutExpired:
|
||
return ActionResult(False, "확인 요청이 시간 안에 끝나지 않았습니다.")
|
||
except OSError as exc:
|
||
return ActionResult(False, f"agy 를 실행하지 못했습니다: {exc}")
|
||
|
||
# agy SSOT §13: 종료 코드와 status 를 둘 다 확인해야 한다.
|
||
if proc.returncode != 0:
|
||
return ActionResult(False, f"agy 가 오류로 끝났습니다(코드 {proc.returncode}).")
|
||
|
||
from dmf_crawler.agy import extract
|
||
envelope = extract.parse_envelope(proc.stdout)
|
||
if envelope is None:
|
||
return ActionResult(False, "agy 응답을 해석하지 못했습니다.")
|
||
if envelope.get("status") != "SUCCESS":
|
||
return ActionResult(False, f"인증이 아직 유효하지 않습니다: {envelope.get('error')}")
|
||
return ActionResult(True, "agy 로그인이 정상 확인됐습니다.")
|
||
```
|
||
|
||
**`CREATE_NEW_CONSOLE` 이 반드시 필요한 이유**
|
||
|
||
| 실행 주체 | 콘솔 상속 | `agy` 대화형 동작 |
|
||
|---|---|---|
|
||
| `pythonw.exe`(GUI) 에서 플래그 없이 `Popen` | 부모에 콘솔이 없으므로 자식도 없음 | **stdin 부재 → 즉시 종료**. 사용자는 아무것도 못 본다 |
|
||
| `CREATE_NO_WINDOW` | 콘솔은 생기지만 **숨겨짐** | 프롬프트가 보이지 않아 로그인 코드를 붙여넣을 수 없다 |
|
||
| **`CREATE_NEW_CONSOLE`** | 새 콘솔이 **보이게** 생성 | ✅ 정답. 브라우저가 열리고 프롬프트가 보인다 |
|
||
| `start` 를 셸로 호출(`shell=True`) | 동작은 하지만 인자 이스케이프가 취약 | 경로에 공백이 있으면 깨진다. 쓰지 않는다 |
|
||
|
||
### 5.3 재로그인 성공 판정
|
||
|
||
**세 단계로 판정한다. 하나라도 건너뛰면 오탐이 난다.**
|
||
|
||
| 단계 | 판정 | 실패 시 |
|
||
|---|---|---|
|
||
| 1. 토큰 파일 mtime 변화 | `antigravity-oauth-token` 의 mtime 이 클릭 시점보다 커졌는가 | 계속 대기(최대 10분) |
|
||
| 2. 토큰 파일 형태 확인 | JSON 이고 `token.access_token` 키가 있는가. **값은 절대 로그에 남기지 않는다** | 손상 판정 → 재로그인 재안내 |
|
||
| 3. 실호출 실증 | `agy -p "Reply with exactly: PONG"` 가 종료 코드 0 + `status == "SUCCESS"` | 만료 상태 유지 → 알림 해소하지 않음 |
|
||
|
||
**3단계를 반드시 하는 이유**: 파일이 갱신됐다고 인증이 유효하다는 보장이 없다. 다른 계정으로 로그인했거나, 쓰기가 중간에 끊겼거나, 조직 정책으로 토큰이 즉시 무효화됐을 수 있다. **"파일이 바뀌었으니 됐겠지"는 조용한 실패의 전형이다.**
|
||
|
||
**단, 3단계는 토큰을 소비한다.** 그래서 호출 시점을 엄격히 제한한다.
|
||
|
||
| 호출해도 되는 곳 | 호출하면 안 되는 곳 |
|
||
|---|---|
|
||
| 재로그인 직후(토큰 mtime 변화 감지 시) | 06:00 배치의 preflight |
|
||
| `dmf doctor` / 진단 GUI 를 사용자가 직접 열었을 때 | Agent 의 15분 주기 pump |
|
||
| 온보딩 마법사의 agy 단계 | `checks.run_all()` 의 자동 실행 경로 |
|
||
|
||
> `checks` ⑦ "agy 인증 상태"는 기본적으로 **`agy_calls` 테이블의 최근 결과로 판정**한다(아키텍처 §3.15). 실호출은 사용자가 명시적으로 요청했을 때만 한다.
|
||
|
||
### 5.4 `act_install_agy` 전문
|
||
|
||
```python
|
||
# src/dmf_crawler/gui/steps.py (이어서)
|
||
|
||
def act_install_agy(cfg: Config, ctx: Mapping[str, Any]) -> ActionResult:
|
||
"""scripts/bootstrap_agy.ps1 을 무인 실행한다.
|
||
|
||
설치가 끝나면 최초 1회 로그인이 필요하므로, 성공 시 곧바로
|
||
act_agy_relogin 을 이어서 호출한다.
|
||
"""
|
||
script = paths.SCRIPTS_DIR / "bootstrap_agy.ps1"
|
||
if not script.exists():
|
||
return ActionResult(False, f"설치 스크립트를 찾을 수 없습니다: {script}")
|
||
|
||
try:
|
||
proc = subprocess.run(
|
||
["powershell.exe", "-NoProfile", "-NonInteractive",
|
||
"-ExecutionPolicy", "Bypass", "-File", str(script)],
|
||
capture_output=True, text=True, encoding="utf-8", errors="replace",
|
||
timeout=300, creationflags=CREATE_NO_WINDOW,
|
||
)
|
||
except subprocess.TimeoutExpired:
|
||
return ActionResult(False, "설치가 5분 안에 끝나지 않았습니다. "
|
||
"인터넷 연결을 확인하고 다시 시도하세요.")
|
||
except OSError as exc:
|
||
return ActionResult(False, f"설치 스크립트를 실행하지 못했습니다: {exc}")
|
||
|
||
if proc.returncode != 0:
|
||
tail = (proc.stderr or proc.stdout or "").strip().splitlines()[-3:]
|
||
return ActionResult(False, "설치에 실패했습니다.\n" + "\n".join(tail))
|
||
|
||
if not _agy_exe(cfg).exists():
|
||
return ActionResult(False, "설치는 끝났지만 실행 파일을 찾지 못했습니다. "
|
||
"PC를 재시작한 뒤 다시 시도하세요.")
|
||
|
||
# 설치 직후에는 반드시 로그인이 필요하다. 바로 이어서 띄운다.
|
||
relogin = act_agy_relogin(cfg, ctx)
|
||
if relogin.ok:
|
||
return ActionResult(True, "설치가 끝났습니다. 이어서 로그인 창을 열었습니다.",
|
||
verify_check_key="agy_auth")
|
||
return ActionResult(True, "설치가 끝났습니다. [로그인 창 열기]를 눌러 "
|
||
"Google 계정 로그인을 진행하세요.",
|
||
verify_check_key="agy_auth")
|
||
```
|
||
|
||
**`scripts/bootstrap_agy.ps1` 전문**
|
||
|
||
```powershell
|
||
# scripts/bootstrap_agy.ps1
|
||
# agy 존재 확인 -> 미설치 시 공식 install.ps1 무인 실행 -> 버전 출력
|
||
# 종료 코드: 0 = 사용 가능, 1 = 실패
|
||
[CmdletBinding()]
|
||
param(
|
||
[switch]$Force # 이미 설치돼 있어도 재설치
|
||
)
|
||
|
||
$ErrorActionPreference = 'Stop'
|
||
$agyExe = Join-Path $env:LOCALAPPDATA 'agy\bin\agy.exe'
|
||
|
||
# 설치 중 자동 업데이터가 끼어들지 않게 한다 (agy SSOT §3.4)
|
||
$env:AGY_CLI_DISABLE_AUTO_UPDATE = 'true'
|
||
|
||
function Test-Agy {
|
||
param([string]$Path)
|
||
if (-not (Test-Path $Path)) { return $false }
|
||
try {
|
||
$v = & $Path --version 2>&1
|
||
Write-Host "agy 확인됨: $Path ($v)"
|
||
return $true
|
||
} catch {
|
||
return $false
|
||
}
|
||
}
|
||
|
||
if ((Test-Agy -Path $agyExe) -and (-not $Force)) {
|
||
exit 0
|
||
}
|
||
|
||
if ($Force -and (Test-Path $agyExe)) {
|
||
# install.ps1 은 기존 설치를 감지하면 아무것도 하지 않고 0 으로 빠진다
|
||
# (agy SSOT §3.3 3단계). 재설치하려면 바이너리를 먼저 지워야 한다.
|
||
Write-Host '기존 agy 바이너리를 제거합니다(재설치 요청).'
|
||
Remove-Item $agyExe -Force
|
||
}
|
||
|
||
Write-Host 'Antigravity CLI 를 설치합니다. 인터넷 연결이 필요합니다...'
|
||
try {
|
||
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||
$script = Invoke-RestMethod -Uri 'https://antigravity.google/cli/install.ps1' -TimeoutSec 60
|
||
Invoke-Expression $script
|
||
} catch {
|
||
Write-Error "설치 스크립트 실행에 실패했습니다: $($_.Exception.Message)"
|
||
exit 1
|
||
}
|
||
|
||
if (Test-Agy -Path $agyExe) {
|
||
Write-Host '설치가 완료됐습니다.'
|
||
Write-Host '최초 1회 Google 계정 로그인이 필요합니다.'
|
||
exit 0
|
||
}
|
||
|
||
Write-Error "설치는 끝났지만 $agyExe 를 찾을 수 없습니다."
|
||
exit 1
|
||
```
|
||
|
||
> **`where.exe agy` 를 쓰지 않는 이유**: agy SSOT §3.2 실측에 따르면 winget 설치 이력이 있으면 `%LOCALAPPDATA%\Microsoft\WinGet\Links\agy.EXE` 가 함께 잡힌다. **배치 스크립트는 절대 경로를 고정해서 쓴다.**
|
||
|
||
---
|
||
|
||
## 6. 알림 스크립트 전문
|
||
|
||
### 6.0 파일 구성과 아키텍처 트리 증분 (AMD-02)
|
||
|
||
| 파일 | 상태 | 역할 |
|
||
|---|---|---|
|
||
| `src/dmf_crawler/notify/toast.py` | 아키텍처 트리에 있음 | tkinter 자동소멸 토스트 |
|
||
| `src/dmf_crawler/notify/eventlog.py` | 아키텍처 트리에 있음 | `eventcreate.exe` 래퍼 |
|
||
| `src/dmf_crawler/notify/messages.py` | 아키텍처 트리에 있음 | 문구 템플릿(§3.4) |
|
||
| `src/dmf_crawler/notify/pump.py` | 아키텍처 트리에 있음 | 대화형 에이전트 본체 |
|
||
| `src/dmf_crawler/notify/webhook.py` | **신규(AMD-02)** | 웹훅·dead-man switch. 선택 기능이므로 파일 하나로 격리 |
|
||
| `scripts/notify.ps1` | **신규(AMD-02)** | Python 계층이 죽었을 때의 최후 알림기 |
|
||
| `scripts/register_protocol.ps1` | **신규(AMD-02)** | `dmf://` 등록(§4.4). 기본 경로에서는 실행하지 않음 |
|
||
|
||
**`scripts/notify.ps1` 을 신설하는 근거**: 실패 시나리오 #32(Python/venv 손상)에서 tkinter 토스트는 **원리적으로** 뜰 수 없다. 아키텍처는 이 경우 "Event Log 가 유일한 흔적"이라고 적었는데, 이벤트 로그는 사용자가 보러 가지 않으면 아무 소용이 없다. PowerShell 은 Windows 에 내장돼 있고 우리 venv 와 독립적이므로, 이 하나의 시나리오를 위해 파일 하나를 추가할 가치가 있다.
|
||
|
||
---
|
||
|
||
### 6.1 `scripts/notify.ps1` 전문
|
||
|
||
```powershell
|
||
<#
|
||
.SYNOPSIS
|
||
DMF 크롤러 최후 알림기 (Python 독립).
|
||
|
||
.DESCRIPTION
|
||
Python/venv 가 손상돼 notify/pump.py 가 돌지 못할 때를 위한 폴백 알림기다.
|
||
DMF_Crawler_Agent 작업의 "두 번째 액션"으로 등록되어, pump 가 스탬프를
|
||
남기지 못했을 때만 화면에 뜬다. 정상 상태에서는 아무것도 하지 않는다.
|
||
|
||
표시 사다리:
|
||
1) System.Windows.Forms.MessageBox (기본)
|
||
2) msg.exe * <메시지> (.NET 로드 실패 시)
|
||
3) Windows 이벤트 로그 (항상 병행)
|
||
|
||
.PARAMETER Mode
|
||
Guard : pump 스탬프 나이를 검사해 필요할 때만 알린다 (스케줄러가 쓰는 모드)
|
||
Force : 무조건 state/alerts.json 의 미해소 알림을 표시한다 (테스트용)
|
||
Test : 더미 알림 1건을 표시한다 (설치 검증용)
|
||
|
||
.PARAMETER ProjectRoot
|
||
프로젝트 루트. 기본값은 이 스크립트의 부모 디렉터리.
|
||
|
||
.EXAMPLE
|
||
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\notify.ps1 -Mode Guard
|
||
#>
|
||
[CmdletBinding()]
|
||
param(
|
||
[ValidateSet('Guard', 'Force', 'Test')]
|
||
[string]$Mode = 'Guard',
|
||
|
||
[string]$ProjectRoot = (Split-Path -Parent $PSScriptRoot),
|
||
|
||
[int]$StaleMinutes = 45
|
||
)
|
||
|
||
$ErrorActionPreference = 'Continue' # 알림기는 절대 죽지 않는다
|
||
$EventSource = 'DMF Crawler'
|
||
|
||
# ------------------------------------------------------------------ 경로
|
||
$StateDir = Join-Path $ProjectRoot 'state'
|
||
$StampPath = Join-Path $StateDir 'pump.stamp'
|
||
$AlertsPath = Join-Path $StateDir 'alerts.json'
|
||
$BootstrapPath = Join-Path $ProjectRoot 'bootstrap.cmd'
|
||
|
||
# ------------------------------------------------------- 이벤트 로그 기록
|
||
function Write-DmfEvent {
|
||
param(
|
||
[Parameter(Mandatory)][int]$Id,
|
||
[ValidateSet('INFORMATION', 'WARNING', 'ERROR', 'SUCCESS')]
|
||
[string]$Type = 'WARNING',
|
||
[Parameter(Mandatory)][string]$Message
|
||
)
|
||
# eventcreate.exe 는 /ID 를 1~1000 으로만 받는다.
|
||
if ($Id -lt 1 -or $Id -gt 1000) { $Id = 910 }
|
||
# /D 는 명령줄 길이 제한이 있다. 안전하게 자른다.
|
||
$desc = $Message -replace '\r?\n', ' | '
|
||
if ($desc.Length -gt 900) { $desc = $desc.Substring(0, 900) + '...' }
|
||
try {
|
||
& eventcreate.exe /L APPLICATION /SO $EventSource /T $Type /ID $Id /D $desc 2>&1 |
|
||
Out-Null
|
||
} catch {
|
||
# 이벤트 로그 기록 실패는 무시한다. 화면 알림이 본체다.
|
||
}
|
||
}
|
||
|
||
# --------------------------------------------------------- 표시 사다리 1단
|
||
function Show-MessageBoxAlert {
|
||
param(
|
||
[Parameter(Mandatory)][string]$Title,
|
||
[Parameter(Mandatory)][string]$Body,
|
||
[ValidateSet('Error', 'Warning', 'Information')]
|
||
[string]$Icon = 'Error'
|
||
)
|
||
try {
|
||
Add-Type -AssemblyName System.Windows.Forms -ErrorAction Stop
|
||
Add-Type -AssemblyName System.Drawing -ErrorAction Stop
|
||
} catch {
|
||
return $false
|
||
}
|
||
try {
|
||
# MB_SYSTEMMODAL 에 해당하는 TopMost 를 주기 위해 더미 폼을 소유자로 쓴다.
|
||
# 이것이 없으면 다른 창 뒤로 숨어 사용자가 영영 못 본다.
|
||
$owner = New-Object System.Windows.Forms.Form
|
||
$owner.TopMost = $true
|
||
$owner.ShowInTaskbar = $false
|
||
$owner.StartPosition = 'CenterScreen'
|
||
$owner.Size = New-Object System.Drawing.Size(1, 1)
|
||
$owner.Opacity = 0
|
||
$owner.Show()
|
||
|
||
[void][System.Windows.Forms.MessageBox]::Show(
|
||
$owner,
|
||
$Body,
|
||
$Title,
|
||
[System.Windows.Forms.MessageBoxButtons]::OK,
|
||
[System.Windows.Forms.MessageBoxIcon]::$Icon,
|
||
[System.Windows.Forms.MessageBoxDefaultButton]::Button1
|
||
)
|
||
$owner.Close()
|
||
$owner.Dispose()
|
||
return $true
|
||
} catch {
|
||
return $false
|
||
}
|
||
}
|
||
|
||
# --------------------------------------------------------- 표시 사다리 2단
|
||
function Show-MsgExeAlert {
|
||
param([Parameter(Mandatory)][string]$Body)
|
||
# msg.exe 는 Windows Home 에디션에 없는 경우가 있다. 존재부터 확인한다.
|
||
$msg = Get-Command msg.exe -ErrorAction SilentlyContinue
|
||
if (-not $msg) { return $false }
|
||
try {
|
||
# 한 줄로 눌러 보낸다. msg.exe 는 개행을 잘 다루지 못한다.
|
||
$flat = ($Body -replace '\r?\n', ' ')
|
||
if ($flat.Length -gt 250) { $flat = $flat.Substring(0, 250) + '...' }
|
||
& $msg.Source '*' '/TIME:120' $flat 2>&1 | Out-Null
|
||
return $true
|
||
} catch {
|
||
return $false
|
||
}
|
||
}
|
||
|
||
# ------------------------------------------------------------ 표시 오케스트레이션
|
||
function Invoke-Alert {
|
||
param(
|
||
[Parameter(Mandatory)][string]$Title,
|
||
[Parameter(Mandatory)][string]$Body,
|
||
[int]$EventId = 910,
|
||
[string]$EventType = 'ERROR'
|
||
)
|
||
Write-DmfEvent -Id $EventId -Type $EventType -Message "$Title | $Body"
|
||
|
||
if (Show-MessageBoxAlert -Title $Title -Body $Body -Icon Error) {
|
||
Write-Host "[notify.ps1] MessageBox 로 표시했습니다."
|
||
return
|
||
}
|
||
Write-DmfEvent -Id 510 -Type 'WARNING' `
|
||
-Message 'MessageBox 표시 실패. msg.exe 로 강등합니다.'
|
||
|
||
if (Show-MsgExeAlert -Body "$Title`n$Body") {
|
||
Write-Host "[notify.ps1] msg.exe 로 표시했습니다."
|
||
return
|
||
}
|
||
Write-DmfEvent -Id 510 -Type 'ERROR' `
|
||
-Message '모든 화면 알림 채널이 실패했습니다. 이벤트 로그만 남습니다.'
|
||
Write-Host "[notify.ps1] 화면 표시에 모두 실패했습니다."
|
||
}
|
||
|
||
# --------------------------------------------------------------- 모드별 동작
|
||
function Get-StampAgeMinutes {
|
||
if (-not (Test-Path $StampPath)) { return [int]::MaxValue }
|
||
try {
|
||
$mtime = (Get-Item $StampPath).LastWriteTime
|
||
return [int]((Get-Date) - $mtime).TotalMinutes
|
||
} catch {
|
||
return [int]::MaxValue
|
||
}
|
||
}
|
||
|
||
function Invoke-GuardMode {
|
||
$age = Get-StampAgeMinutes
|
||
if ($age -le $StaleMinutes) {
|
||
# 정상. 아무것도 하지 않는다. 이것이 대부분의 실행 경로다.
|
||
Write-Host "[notify.ps1] 정상 (스탬프 ${age}분 전). 아무것도 하지 않습니다."
|
||
return 0
|
||
}
|
||
|
||
$lastText = if (Test-Path $StampPath) {
|
||
(Get-Item $StampPath).LastWriteTime.ToString('yyyy-MM-dd HH:mm')
|
||
} else {
|
||
'기록 없음'
|
||
}
|
||
|
||
$title = 'DMF 크롤러 - 프로그램이 실행되지 않습니다'
|
||
$body = @"
|
||
[무엇] DMF 크롤러의 알림 프로그램이 ${age}분째 응답하지 않습니다.
|
||
(마지막 정상 동작: $lastText)
|
||
|
||
[왜] Python 실행 환경(.venv)이 손상됐거나 삭제됐을 가능성이 큽니다.
|
||
이 상태에서는 매일 06:00 자동 수집도 함께 멈춥니다.
|
||
|
||
[어떻게] 아래 폴더의 bootstrap.cmd 를 더블클릭해 다시 설치하세요(약 3분).
|
||
기존 데이터와 설정은 그대로 유지됩니다.
|
||
|
||
폴더: $ProjectRoot
|
||
설치: $BootstrapPath
|
||
|
||
* 자세한 기록은 이벤트 뷰어 > Windows 로그 > 응용 프로그램에서
|
||
원본 "DMF Crawler" 로 확인할 수 있습니다.
|
||
"@
|
||
Invoke-Alert -Title $title -Body $body -EventId 910 -EventType 'ERROR'
|
||
|
||
# 폴더를 함께 열어 준다. 사용자가 경로를 타이핑하지 않아도 되게.
|
||
try { Start-Process explorer.exe -ArgumentList $ProjectRoot } catch { }
|
||
return 1
|
||
}
|
||
|
||
function Invoke-ForceMode {
|
||
if (-not (Test-Path $AlertsPath)) {
|
||
Write-Host "[notify.ps1] $AlertsPath 가 없습니다."
|
||
return 0
|
||
}
|
||
try {
|
||
$data = Get-Content -Path $AlertsPath -Raw -Encoding UTF8 | ConvertFrom-Json
|
||
} catch {
|
||
Invoke-Alert -Title 'DMF 크롤러 - 알림 파일을 읽지 못했습니다' `
|
||
-Body "state\alerts.json 이 손상됐습니다.`n경로: $AlertsPath" `
|
||
-EventId 910
|
||
return 1
|
||
}
|
||
|
||
$pending = @($data.pending | Where-Object { $_.severity -in @('ERROR', 'CRITICAL') })
|
||
if ($pending.Count -eq 0) {
|
||
Write-Host "[notify.ps1] 표시할 ERROR/CRITICAL 알림이 없습니다."
|
||
return 0
|
||
}
|
||
|
||
foreach ($a in $pending) {
|
||
$body = @"
|
||
[무엇] $($a.what)
|
||
|
||
[왜] $($a.why)
|
||
|
||
[어떻게] $($a.how)
|
||
|
||
발생: $($a.last_seen_at) (누적 $($a.occurrences)회)
|
||
로그: $($a.log_dir)
|
||
"@
|
||
Invoke-Alert -Title "DMF 크롤러 - $($a.title)" -Body $body `
|
||
-EventId 400 -EventType 'ERROR'
|
||
}
|
||
return 1
|
||
}
|
||
|
||
function Invoke-TestMode {
|
||
$body = @"
|
||
[무엇] 이것은 알림 채널 점검용 시험 메시지입니다.
|
||
|
||
[왜] scripts\notify.ps1 -Mode Test 로 직접 실행했습니다.
|
||
|
||
[어떻게] 이 창이 보인다면 폴백 알림 채널이 정상입니다. [확인]을 누르세요.
|
||
|
||
프로젝트: $ProjectRoot
|
||
표시 시각: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')
|
||
"@
|
||
Invoke-Alert -Title 'DMF 크롤러 - 알림 시험' -Body $body `
|
||
-EventId 500 -EventType 'INFORMATION'
|
||
return 0
|
||
}
|
||
|
||
# ------------------------------------------------------------------- 진입점
|
||
switch ($Mode) {
|
||
'Guard' { exit (Invoke-GuardMode) }
|
||
'Force' { exit (Invoke-ForceMode) }
|
||
'Test' { exit (Invoke-TestMode) }
|
||
}
|
||
```
|
||
|
||
**Agent 작업에 2번째 액션으로 등록** (`scripts/install_tasks.ps1` 발췌)
|
||
|
||
```powershell
|
||
# DMF_Crawler_Agent — 액션 2개. 순서대로 실행된다.
|
||
$agentActions = @(
|
||
# 1) 정상 경로: Python 알림 펌프. 콘솔 창이 뜨지 않는다.
|
||
(New-ScheduledTaskAction -Execute $PythonwExe `
|
||
-Argument '-m dmf_crawler notify-pump --once' `
|
||
-WorkingDirectory $ProjectRoot),
|
||
|
||
# 2) 폴백 경로: pump 가 스탬프를 못 남겼을 때만 동작한다.
|
||
# 정상 상태에서는 즉시 종료되므로 부담이 없다.
|
||
(New-ScheduledTaskAction -Execute 'powershell.exe' `
|
||
-Argument ("-NoProfile -NonInteractive -WindowStyle Hidden " +
|
||
"-ExecutionPolicy Bypass -File `"$ProjectRoot\scripts\notify.ps1`" " +
|
||
"-Mode Guard -StaleMinutes 45") `
|
||
-WorkingDirectory $ProjectRoot)
|
||
)
|
||
|
||
$agentTrigger = @(
|
||
(New-ScheduledTaskTrigger -AtLogOn -User $TargetUser),
|
||
(New-ScheduledTaskTrigger -Once -At (Get-Date).Date.AddMinutes(1) `
|
||
-RepetitionInterval (New-TimeSpan -Minutes 15))
|
||
)
|
||
|
||
# LogonType Interactive 가 절대적으로 중요하다. 이것이 UI 를 띄울 수 있는 유일한 조건이다.
|
||
$agentPrincipal = New-ScheduledTaskPrincipal -UserId $TargetUser `
|
||
-LogonType Interactive -RunLevel Limited
|
||
|
||
$agentSettings = New-ScheduledTaskSettingsSet `
|
||
-AllowStartIfOnBatteries -DontStopIfGoingOnBatteries `
|
||
-StartWhenAvailable -MultipleInstances IgnoreNew `
|
||
-ExecutionTimeLimit (New-TimeSpan -Minutes 10) -Priority 7
|
||
|
||
Register-ScheduledTask -TaskName 'DMF_Crawler_Agent' `
|
||
-Action $agentActions -Trigger $agentTrigger `
|
||
-Principal $agentPrincipal -Settings $agentSettings -Force | Out-Null
|
||
```
|
||
|
||
> **작업 스케줄러의 다중 액션은 순차 실행이며, 앞 액션의 종료 코드를 보지 않는다.** 즉 pump 가 죽어도 2번 액션은 반드시 실행된다 — 이것이 폴백이 성립하는 이유다. 반대로 pump 가 정상이면 `notify.ps1 -Mode Guard` 는 스탬프를 보고 즉시 0으로 빠지므로 중복 알림이 나지 않는다.
|
||
|
||
---
|
||
|
||
### 6.2 `src/dmf_crawler/notify/toast.py` 전문
|
||
|
||
```python
|
||
# src/dmf_crawler/notify/toast.py
|
||
"""tkinter 로 그린 우하단 자동소멸 알림 창.
|
||
|
||
왜 tkinter 인가 (ADR-12):
|
||
- 표준 라이브러리다. 의존성이 늘지 않는다.
|
||
- 집중 지원(방해 금지) 모드의 영향을 받지 않는다. OS 알림 API 를 쓰지 않기 때문.
|
||
- BurntToast/win11toast 는 외부 설치가 전제여서,
|
||
"설치가 깨지면 알림도 안 뜬다"는 순환 실패를 만든다.
|
||
|
||
한계 (정직하게 기록):
|
||
- 액션 센터에 남지 않는다. 놓치면 사라진다.
|
||
-> 그래서 alerts 테이블과 state/alerts.json 이 원본이고 이 창은 사본일 뿐이다.
|
||
- 배타적 전체 화면 앱 위에는 뜨지 못한다. -> is_presentation_mode() 로 회피한다.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import ctypes
|
||
import tkinter as tk
|
||
from dataclasses import dataclass
|
||
from typing import Callable, Sequence
|
||
|
||
# --------------------------------------------------------------- 디자인 토큰
|
||
# 리포트와 같은 Okabe-Ito 계열. 색각이상 안전.
|
||
_BG = "#1B1D23"
|
||
_FG = "#F2F3F5"
|
||
_FG_DIM = "#A8ADB7"
|
||
_BORDER = "#3A3F4B"
|
||
_ACCENT = {
|
||
"INFO": "#0072B2", # 파랑
|
||
"WARN": "#E69F00", # 주황
|
||
"ERROR": "#D55E00", # 주황빨강
|
||
"CRITICAL": "#CC3311", # 빨강
|
||
}
|
||
_BTN_BG = "#2A2E38"
|
||
_BTN_BG_HOVER = "#3A3F4B"
|
||
_FONT_FAMILY = "맑은 고딕"
|
||
|
||
_MARGIN_RIGHT = 24
|
||
_MARGIN_BOTTOM = 56 # 작업 표시줄을 피한다
|
||
_WIDTH = 440
|
||
_GAP = 12 # 토스트 여러 장을 쌓을 때의 간격
|
||
|
||
# 이미 떠 있는 토스트들의 높이 누적(스택 배치용)
|
||
_stack_offset = 0
|
||
|
||
|
||
@dataclass(frozen=True, slots=True)
|
||
class ToastButton:
|
||
key: str
|
||
label: str
|
||
|
||
|
||
@dataclass(frozen=True, slots=True)
|
||
class ToastResult:
|
||
clicked: str | None # 눌린 버튼의 key. 자동 소멸이면 None
|
||
shown: bool # 창이 실제로 화면에 그려졌는가
|
||
|
||
|
||
def enable_dpi_awareness() -> None:
|
||
"""고DPI 화면에서 창이 흐려지는 것을 막는다. 실패해도 무시한다."""
|
||
try:
|
||
# PROCESS_PER_MONITOR_DPI_AWARE = 2
|
||
ctypes.windll.shcore.SetProcessDpiAwareness(2)
|
||
except Exception:
|
||
try:
|
||
ctypes.windll.user32.SetProcessDPIAware()
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
def is_presentation_mode() -> bool:
|
||
"""전체 화면 앱·프레젠테이션 모드인지 확인한다(B4 대응).
|
||
|
||
SHQueryUserNotificationState 반환값:
|
||
1 NOT_PRESENT 2 BUSY 3 RUNNING_D3D_FULL_SCREEN
|
||
4 PRESENTATION_MODE 5 ACCEPTS_NOTIFICATIONS 6 QUIET_TIME
|
||
7 APP (Windows 8+: 전체 화면 앱)
|
||
"""
|
||
try:
|
||
state = ctypes.c_int(0)
|
||
hr = ctypes.windll.shell32.SHQueryUserNotificationState(ctypes.byref(state))
|
||
if hr != 0:
|
||
return False
|
||
return state.value in (2, 3, 4, 7)
|
||
except Exception:
|
||
return False
|
||
|
||
|
||
def reset_stack() -> None:
|
||
"""pump 주기 시작 시 호출. 토스트 쌓임 위치를 초기화한다."""
|
||
global _stack_offset
|
||
_stack_offset = 0
|
||
|
||
|
||
def show(
|
||
*,
|
||
title: str,
|
||
body: str,
|
||
severity: str = "WARN",
|
||
buttons: Sequence[ToastButton] = (),
|
||
seconds: int = 12,
|
||
on_click: Callable[[str], None] | None = None,
|
||
) -> ToastResult:
|
||
"""토스트를 띄우고 닫힐 때까지 블록한다.
|
||
|
||
반환:
|
||
ToastResult(clicked=눌린 버튼 키 또는 None, shown=실제로 그려졌는지)
|
||
|
||
이 함수는 절대 예외를 밖으로 던지지 않는다. 실패하면 shown=False 로 알린다.
|
||
"""
|
||
global _stack_offset
|
||
try:
|
||
return _show_impl(title, body, severity, tuple(buttons), seconds, on_click)
|
||
except Exception:
|
||
return ToastResult(clicked=None, shown=False)
|
||
|
||
|
||
def _show_impl(
|
||
title: str,
|
||
body: str,
|
||
severity: str,
|
||
buttons: tuple[ToastButton, ...],
|
||
seconds: int,
|
||
on_click: Callable[[str], None] | None,
|
||
) -> ToastResult:
|
||
global _stack_offset
|
||
|
||
enable_dpi_awareness()
|
||
accent = _ACCENT.get(severity.upper(), _ACCENT["WARN"])
|
||
clicked: dict[str, str | None] = {"key": None}
|
||
|
||
root = tk.Tk()
|
||
root.withdraw()
|
||
|
||
win = tk.Toplevel(root)
|
||
win.overrideredirect(True) # 제목 표시줄 없음
|
||
win.attributes("-topmost", True)
|
||
win.configure(bg=_BORDER) # 바깥 1px 테두리 역할
|
||
|
||
# ------------------------------------------------------------ 레이아웃
|
||
outer = tk.Frame(win, bg=_BG)
|
||
outer.pack(padx=1, pady=1, fill="both", expand=True)
|
||
|
||
# 왼쪽 등급 색 띠
|
||
tk.Frame(outer, bg=accent, width=5).pack(side="left", fill="y")
|
||
|
||
inner = tk.Frame(outer, bg=_BG)
|
||
inner.pack(side="left", fill="both", expand=True, padx=16, pady=14)
|
||
|
||
tk.Label(
|
||
inner, text=title, bg=_BG, fg=_FG, justify="left", anchor="w",
|
||
font=(_FONT_FAMILY, 11, "bold"), wraplength=_WIDTH - 60,
|
||
).pack(fill="x")
|
||
|
||
tk.Label(
|
||
inner, text=body, bg=_BG, fg=_FG_DIM, justify="left", anchor="w",
|
||
font=(_FONT_FAMILY, 9), wraplength=_WIDTH - 60,
|
||
).pack(fill="x", pady=(8, 0))
|
||
|
||
# ------------------------------------------------------------- 버튼들
|
||
def _close(key: str | None) -> None:
|
||
clicked["key"] = key
|
||
try:
|
||
win.destroy()
|
||
root.quit()
|
||
except tk.TclError:
|
||
pass
|
||
|
||
if buttons:
|
||
bar = tk.Frame(inner, bg=_BG)
|
||
bar.pack(fill="x", pady=(14, 0))
|
||
for btn in buttons:
|
||
b = tk.Button(
|
||
bar, text=btn.label, bg=_BTN_BG, fg=_FG,
|
||
activebackground=_BTN_BG_HOVER, activeforeground=_FG,
|
||
relief="flat", bd=0, padx=12, pady=5, cursor="hand2",
|
||
font=(_FONT_FAMILY, 9),
|
||
command=lambda k=btn.key: _on_button(k, on_click, _close),
|
||
)
|
||
b.pack(side="left", padx=(0, 8))
|
||
b.bind("<Enter>", lambda e, w=b: w.configure(bg=_BTN_BG_HOVER))
|
||
b.bind("<Leave>", lambda e, w=b: w.configure(bg=_BTN_BG))
|
||
|
||
# ------------------------------------------------- 위치 계산(우하단 스택)
|
||
win.update_idletasks()
|
||
height = win.winfo_reqheight()
|
||
screen_w = win.winfo_screenwidth()
|
||
screen_h = win.winfo_screenheight()
|
||
x = screen_w - _WIDTH - _MARGIN_RIGHT
|
||
y = screen_h - _MARGIN_BOTTOM - height - _stack_offset
|
||
if y < 40: # 화면 위로 넘치면 스택을 접는다
|
||
_stack_offset = 0
|
||
y = screen_h - _MARGIN_BOTTOM - height
|
||
win.geometry(f"{_WIDTH}x{height}+{x}+{y}")
|
||
_stack_offset += height + _GAP
|
||
|
||
# --------------------------------------------------------- 동작 바인딩
|
||
# 본문 클릭 = 첫 번째 버튼과 같은 동작(가장 흔한 조작을 쉽게)
|
||
default_key = buttons[0].key if buttons else None
|
||
for widget in (inner, outer):
|
||
widget.bind("<Button-1>",
|
||
lambda e: _on_button(default_key, on_click, _close)
|
||
if default_key else _close(None))
|
||
# 오른쪽 클릭 = 그냥 닫기
|
||
win.bind("<Button-3>", lambda e: _close(None))
|
||
win.bind("<Escape>", lambda e: _close(None))
|
||
|
||
# 자동 소멸 타이머
|
||
timer_id = win.after(max(1, seconds) * 1000, lambda: _close(None))
|
||
|
||
# 마우스를 올리면 타이머를 멈춘다(읽는 중에 사라지면 안 된다)
|
||
def _pause(_e: object) -> None:
|
||
try:
|
||
win.after_cancel(timer_id)
|
||
except (tk.TclError, ValueError):
|
||
pass
|
||
|
||
def _resume(_e: object) -> None:
|
||
nonlocal timer_id
|
||
try:
|
||
timer_id = win.after(3000, lambda: _close(None))
|
||
except tk.TclError:
|
||
pass
|
||
|
||
win.bind("<Enter>", _pause)
|
||
win.bind("<Leave>", _resume)
|
||
|
||
# 페이드 인
|
||
try:
|
||
win.attributes("-alpha", 0.0)
|
||
for step in range(0, 11):
|
||
win.attributes("-alpha", step / 10.0 * 0.97)
|
||
win.update()
|
||
win.after(12)
|
||
except tk.TclError:
|
||
pass
|
||
|
||
root.mainloop()
|
||
try:
|
||
root.destroy()
|
||
except tk.TclError:
|
||
pass
|
||
|
||
return ToastResult(clicked=clicked["key"], shown=True)
|
||
|
||
|
||
def _on_button(
|
||
key: str | None,
|
||
on_click: Callable[[str], None] | None,
|
||
close: Callable[[str | None], None],
|
||
) -> None:
|
||
if key and on_click:
|
||
try:
|
||
on_click(key)
|
||
except Exception:
|
||
pass # 액션 실패가 창을 죽이면 안 된다
|
||
close(key)
|
||
|
||
|
||
def show_merged(
|
||
*,
|
||
count: int,
|
||
lines: Sequence[str],
|
||
severity: str,
|
||
seconds: int,
|
||
on_click: Callable[[str], None] | None = None,
|
||
) -> ToastResult:
|
||
"""여러 알림을 한 장으로 묶어 표시한다(L2 병합)."""
|
||
body = "\n".join(f" · {line}" for line in lines)
|
||
return show(
|
||
title=f"DMF 크롤러 — 확인이 필요한 항목 {count}건",
|
||
body=body,
|
||
severity=severity,
|
||
buttons=(
|
||
ToastButton("open_doctor", "자세히 보기"),
|
||
ToastButton("open_report_file", "리포트 열기"),
|
||
ToastButton("dismiss", "닫기"),
|
||
),
|
||
seconds=seconds,
|
||
on_click=on_click,
|
||
)
|
||
```
|
||
|
||
---
|
||
|
||
### 6.3 `src/dmf_crawler/notify/eventlog.py` 전문
|
||
|
||
```python
|
||
# src/dmf_crawler/notify/eventlog.py
|
||
"""Windows 이벤트 로그 병행 기록.
|
||
|
||
pywin32 를 쓰지 않는다(ADR 의존성 최소주의). Windows 내장 eventcreate.exe 를 부른다.
|
||
|
||
알려진 제약 (반드시 지킬 것):
|
||
1. /ID 는 1~1000 범위만 허용된다. 벗어나면 명령 자체가 실패한다.
|
||
-> EVENT_IDS 상수가 이 범위를 강제한다.
|
||
2. /D 는 명령줄 인자이므로 길이 제한이 있다. 900자로 자른다.
|
||
3. /SO 로 지정한 원본은 Application 로그에 자동 등록된다.
|
||
이미 시스템에 등록된 원본 이름과 충돌하면 실패하므로 고유한 이름을 쓴다.
|
||
4. PowerShell 7 에는 Write-EventLog/New-EventLog 가 없다.
|
||
eventcreate.exe 는 exe 이므로 셸 종류와 무관하게 동작한다.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import subprocess
|
||
from typing import Literal
|
||
|
||
from dmf_crawler.alerts import Severity
|
||
|
||
CREATE_NO_WINDOW = 0x08000000
|
||
|
||
EventType = Literal["INFORMATION", "WARNING", "ERROR", "SUCCESS"]
|
||
|
||
# 등급 -> eventcreate /T 매핑
|
||
_TYPE_BY_SEVERITY: dict[str, EventType] = {
|
||
"INFO": "INFORMATION",
|
||
"WARN": "WARNING",
|
||
"ERROR": "ERROR",
|
||
"CRITICAL": "ERROR", # eventcreate 에 CRITICAL 타입은 없다
|
||
}
|
||
|
||
# 등급 기본 ID (템플릿이 eventlog_id 를 지정하면 그것이 우선한다)
|
||
_ID_BY_SEVERITY: dict[str, int] = {
|
||
"INFO": 100, "WARN": 200, "ERROR": 300, "CRITICAL": 400,
|
||
}
|
||
|
||
# 이 문서 §2.8 의 ID 배정표
|
||
EVENT_IDS: dict[str, int] = {
|
||
"RUN_START": 100, "RUN_SUCCESS": 110, "RUN_PARTIAL": 120, "RUN_SKIPPED": 130,
|
||
"ALERT_WARN": 200, "ALERT_ERROR": 300, "ALERT_CRITICAL": 400,
|
||
"WATCHDOG_STALE": 410, "CONSECUTIVE_FAILURES": 420, "RUN_PAUSED": 430,
|
||
"NOTIFY_SHOWN": 500, "NOTIFY_DEGRADED": 510, "NOTIFY_SUPPRESSED": 520,
|
||
"PUMP_CRASHED": 900, "FALLBACK_FIRED": 910,
|
||
}
|
||
|
||
_MAX_DESC = 900
|
||
|
||
|
||
def write(
|
||
*,
|
||
source: str,
|
||
message: str,
|
||
severity: Severity | str = Severity.INFO,
|
||
event_id: int | None = None,
|
||
timeout_s: float = 10.0,
|
||
) -> bool:
|
||
"""이벤트 로그에 1건 기록한다. 실패해도 예외를 던지지 않고 False 를 반환한다."""
|
||
sev = str(severity)
|
||
etype: EventType = _TYPE_BY_SEVERITY.get(sev, "WARNING")
|
||
eid = event_id if event_id else _ID_BY_SEVERITY.get(sev, 200)
|
||
if not (1 <= eid <= 1000): # eventcreate 의 하드 제약
|
||
eid = _ID_BY_SEVERITY.get(sev, 200)
|
||
|
||
desc = " | ".join(line.strip() for line in message.splitlines() if line.strip())
|
||
if len(desc) > _MAX_DESC:
|
||
desc = desc[: _MAX_DESC - 3] + "..."
|
||
if not desc:
|
||
desc = "(내용 없음)"
|
||
|
||
try:
|
||
proc = subprocess.run(
|
||
["eventcreate.exe",
|
||
"/L", "APPLICATION",
|
||
"/SO", source,
|
||
"/T", etype,
|
||
"/ID", str(eid),
|
||
"/D", desc],
|
||
capture_output=True, text=True, encoding="cp949", errors="replace",
|
||
timeout=timeout_s, creationflags=CREATE_NO_WINDOW,
|
||
)
|
||
except (OSError, subprocess.TimeoutExpired):
|
||
return False
|
||
return proc.returncode == 0
|
||
|
||
|
||
def write_alert(cfg, alert_code: str, severity: Severity, title: str,
|
||
body: str, event_id: int | None = None) -> bool:
|
||
"""알림 1건을 이벤트 로그에 남긴다."""
|
||
return write(
|
||
source=cfg.notify.eventlog_source,
|
||
message=f"[{alert_code}] {title} :: {body}",
|
||
severity=severity,
|
||
event_id=event_id,
|
||
)
|
||
```
|
||
|
||
**수동 검증**
|
||
|
||
```powershell
|
||
# 기록
|
||
eventcreate.exe /L APPLICATION /SO "DMF Crawler" /T ERROR /ID 400 /D "테스트 알림"
|
||
|
||
# 확인
|
||
Get-WinEvent -FilterHashtable @{LogName='Application'; ProviderName='DMF Crawler'} -MaxEvents 5 |
|
||
Format-List TimeCreated, Id, LevelDisplayName, Message
|
||
```
|
||
|
||
---
|
||
|
||
### 6.4 `src/dmf_crawler/alerts.py` 전문
|
||
|
||
```python
|
||
# src/dmf_crawler/alerts.py
|
||
"""알림 의도의 기록·중복 억제·표시 추적.
|
||
|
||
원칙 (ADR-11):
|
||
이 모듈은 절대로 화면에 무엇을 띄우지 않는다. 기록만 한다.
|
||
표시는 notify/pump.py 가 로그온 세션에서 한다.
|
||
|
||
계약 (요구 R7.3):
|
||
raise_alert 는 what/why/how/next_actions 중 하나라도 비면 ValueError 를 던진다.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import os
|
||
import socket
|
||
import sqlite3
|
||
from dataclasses import dataclass
|
||
from datetime import datetime, timedelta
|
||
from enum import StrEnum
|
||
from pathlib import Path
|
||
from typing import Any, Mapping, Sequence
|
||
|
||
|
||
class Severity(StrEnum):
|
||
INFO = "INFO"
|
||
WARN = "WARN"
|
||
ERROR = "ERROR"
|
||
CRITICAL = "CRITICAL"
|
||
|
||
@property
|
||
def rank(self) -> int:
|
||
return {"INFO": 0, "WARN": 1, "ERROR": 2, "CRITICAL": 3}[self.value]
|
||
|
||
def at_least(self, other: "Severity") -> bool:
|
||
return self.rank >= other.rank
|
||
|
||
|
||
@dataclass(frozen=True, slots=True)
|
||
class AlertRow:
|
||
alert_id: int
|
||
dedup_key: str
|
||
code: str
|
||
severity: Severity
|
||
first_seen_at: str
|
||
last_seen_at: str
|
||
occurrences: int
|
||
show_attempts: int
|
||
title: str
|
||
what: str
|
||
why: str
|
||
how: str
|
||
next_actions: tuple[str, ...]
|
||
context: dict[str, Any]
|
||
log_dir: str | None
|
||
run_id: str | None
|
||
|
||
def as_context(self) -> dict[str, Any]:
|
||
ctx = dict(self.context)
|
||
ctx.update({
|
||
"alert_id": self.alert_id,
|
||
"code": self.code,
|
||
"occurrences": self.occurrences,
|
||
"log_dir": self.log_dir,
|
||
"run_id": self.run_id,
|
||
})
|
||
return ctx
|
||
|
||
@property
|
||
def body(self) -> str:
|
||
lines = [f"[무엇] {self.what}",
|
||
f"[왜] {self.why}",
|
||
f"[어떻게] {self.how}"]
|
||
if self.log_dir:
|
||
lines += ["", f"로그: {self.log_dir}"]
|
||
return "\n".join(lines)
|
||
|
||
|
||
def _now_iso() -> str:
|
||
return datetime.now().astimezone().isoformat(timespec="seconds")
|
||
|
||
|
||
def make_dedup_key(code: str, run_date: str | None,
|
||
scope: str | None = None) -> str:
|
||
parts = [code]
|
||
if run_date:
|
||
parts.append(run_date)
|
||
if scope:
|
||
parts.append(scope)
|
||
return "|".join(parts)
|
||
|
||
|
||
def raise_alert(
|
||
conn: sqlite3.Connection,
|
||
*,
|
||
run_id: str | None,
|
||
severity: Severity,
|
||
code: str,
|
||
what: str,
|
||
why: str,
|
||
how: str,
|
||
next_actions: Sequence[str],
|
||
context: Mapping[str, Any] | None = None,
|
||
log_dir: Path | None = None,
|
||
cooldown_minutes: int,
|
||
dedup_scope: str | None = None,
|
||
date_scoped: bool = True,
|
||
source: str = "pipeline",
|
||
) -> bool:
|
||
"""알림 의도를 기록한다.
|
||
|
||
반환:
|
||
True -> 새 알림이거나 쿨다운이 지나 표시 대상이 됐다
|
||
False -> 쿨다운 안이라 카운터만 올렸다
|
||
"""
|
||
# ---- 4요소 계약 강제 (요구 R7.3) --------------------------------
|
||
for name, value in (("what", what), ("why", why), ("how", how)):
|
||
if not value or not value.strip():
|
||
raise ValueError(f"알림 {code}: '{name}' 이 비었습니다. "
|
||
f"4요소(무엇/왜/어떻게/다음 행동)는 필수입니다.")
|
||
actions = tuple(a for a in next_actions if a)
|
||
if not actions:
|
||
raise ValueError(f"알림 {code}: next_actions 가 비었습니다. "
|
||
f"막다른 골목 알림은 금지입니다(R7.7).")
|
||
if severity.at_least(Severity.WARN) and set(actions) <= {"snooze", "dismiss"}:
|
||
raise ValueError(f"알림 {code}: 실행 가능한 액션이 없습니다(R7.7).")
|
||
|
||
now = _now_iso()
|
||
run_date = now[:10] if date_scoped else None
|
||
key = make_dedup_key(code, run_date, dedup_scope)
|
||
ctx_json = json.dumps(dict(context or {}), ensure_ascii=False)
|
||
log_dir_str = str(log_dir) if log_dir else None
|
||
|
||
cur = conn.cursor()
|
||
# 1) 발생 사실은 무조건 append
|
||
cur.execute(
|
||
"""INSERT INTO alert_events
|
||
(occurred_at, run_id, code, severity, dedup_key,
|
||
what, why, how, next_actions, context_json, log_dir, source)
|
||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||
(now, run_id, code, str(severity), key,
|
||
what, why, how, json.dumps(list(actions), ensure_ascii=False),
|
||
ctx_json, log_dir_str, source),
|
||
)
|
||
event_id = cur.lastrowid
|
||
|
||
# 2) 상태 행 조회
|
||
cur.execute(
|
||
"""SELECT alert_id, last_seen_at, occurrences, shown_at, resolved_at
|
||
FROM alerts WHERE dedup_key = ?""", (key,))
|
||
row = cur.fetchone()
|
||
|
||
if row is None or row["resolved_at"] is not None:
|
||
# 새 알림, 또는 이미 해소된 뒤 재발생 -> 새 상태로 시작
|
||
cur.execute(
|
||
"""INSERT INTO alerts
|
||
(dedup_key, code, severity, first_seen_at, last_seen_at,
|
||
occurrences, last_event_id, show_attempts)
|
||
VALUES (?,?,?,?,?,1,?,0)
|
||
ON CONFLICT(dedup_key) DO UPDATE SET
|
||
severity = excluded.severity,
|
||
last_seen_at = excluded.last_seen_at,
|
||
occurrences = alerts.occurrences + 1,
|
||
last_event_id = excluded.last_event_id,
|
||
shown_at = NULL,
|
||
shown_channel = NULL,
|
||
show_attempts = 0,
|
||
snoozed_until = NULL,
|
||
resolved_at = NULL,
|
||
resolved_note = NULL""",
|
||
(key, code, str(severity), now, now, event_id),
|
||
)
|
||
conn.commit()
|
||
return True
|
||
|
||
# 3) 기존 미해소 알림 -> 쿨다운 판정
|
||
last_seen = datetime.fromisoformat(row["last_seen_at"])
|
||
elapsed = datetime.now().astimezone() - last_seen
|
||
within_cooldown = elapsed < timedelta(minutes=cooldown_minutes)
|
||
|
||
if within_cooldown:
|
||
cur.execute(
|
||
"""UPDATE alerts
|
||
SET last_seen_at = ?, occurrences = occurrences + 1,
|
||
last_event_id = ?, severity = ?
|
||
WHERE alert_id = ?""",
|
||
(now, event_id, str(severity), row["alert_id"]),
|
||
)
|
||
conn.commit()
|
||
return False
|
||
|
||
cur.execute(
|
||
"""UPDATE alerts
|
||
SET last_seen_at = ?, occurrences = occurrences + 1,
|
||
last_event_id = ?, severity = ?,
|
||
shown_at = NULL, shown_channel = NULL, show_attempts = 0
|
||
WHERE alert_id = ?""",
|
||
(now, event_id, str(severity), row["alert_id"]),
|
||
)
|
||
conn.commit()
|
||
return True
|
||
|
||
|
||
def pending(conn: sqlite3.Connection, *,
|
||
modal_repeat_minutes: int = 60) -> list[AlertRow]:
|
||
"""표시 대기 알림 목록. 심각도 내림차순."""
|
||
now = _now_iso()
|
||
cur = conn.execute(
|
||
"""SELECT a.alert_id, a.dedup_key, a.code, a.severity,
|
||
a.first_seen_at, a.last_seen_at, a.occurrences, a.show_attempts,
|
||
e.what, e.why, e.how, e.next_actions, e.context_json,
|
||
e.log_dir, e.run_id
|
||
FROM alerts a
|
||
JOIN alert_events e ON e.event_id = a.last_event_id
|
||
WHERE a.resolved_at IS NULL
|
||
AND (a.snoozed_until IS NULL OR a.snoozed_until < :now)
|
||
AND (a.shown_at IS NULL
|
||
OR (a.severity IN ('ERROR','CRITICAL')
|
||
AND julianday(:now) - julianday(a.shown_at)
|
||
> :repeat / 1440.0))
|
||
ORDER BY CASE a.severity
|
||
WHEN 'CRITICAL' THEN 0 WHEN 'ERROR' THEN 1
|
||
WHEN 'WARN' THEN 2 ELSE 3 END,
|
||
a.last_seen_at DESC""",
|
||
{"now": now, "repeat": modal_repeat_minutes},
|
||
)
|
||
from dmf_crawler.notify import messages # 순환 방지를 위해 지연 임포트
|
||
|
||
rows: list[AlertRow] = []
|
||
for r in cur.fetchall():
|
||
ctx = json.loads(r["context_json"] or "{}")
|
||
tpl = messages.TEMPLATES.get(r["code"])
|
||
title = tpl.title.format_map(messages._SafeDict(ctx)) if tpl else r["code"]
|
||
rows.append(AlertRow(
|
||
alert_id=r["alert_id"], dedup_key=r["dedup_key"], code=r["code"],
|
||
severity=Severity(r["severity"]),
|
||
first_seen_at=r["first_seen_at"], last_seen_at=r["last_seen_at"],
|
||
occurrences=r["occurrences"], show_attempts=r["show_attempts"],
|
||
title=title, what=r["what"], why=r["why"], how=r["how"],
|
||
next_actions=tuple(json.loads(r["next_actions"])),
|
||
context=ctx, log_dir=r["log_dir"], run_id=r["run_id"],
|
||
))
|
||
return rows
|
||
|
||
|
||
def mark_shown(conn: sqlite3.Connection, alert_id: int, *, channel: str) -> None:
|
||
conn.execute(
|
||
"""UPDATE alerts
|
||
SET shown_at = ?, shown_channel = ?, show_attempts = show_attempts + 1
|
||
WHERE alert_id = ?""",
|
||
(_now_iso(), channel, alert_id))
|
||
conn.commit()
|
||
|
||
|
||
def mark_show_failed(conn: sqlite3.Connection, alert_id: int) -> int:
|
||
"""표시 실패 카운터를 올리고 현재 값을 반환한다(R-P3 승격 판정용)."""
|
||
conn.execute(
|
||
"UPDATE alerts SET show_attempts = show_attempts + 1 WHERE alert_id = ?",
|
||
(alert_id,))
|
||
conn.commit()
|
||
cur = conn.execute("SELECT show_attempts FROM alerts WHERE alert_id = ?",
|
||
(alert_id,))
|
||
row = cur.fetchone()
|
||
return int(row["show_attempts"]) if row else 0
|
||
|
||
|
||
def snooze(conn: sqlite3.Connection, alert_id: int, *, minutes: int) -> None:
|
||
until = (datetime.now().astimezone() +
|
||
timedelta(minutes=minutes)).isoformat(timespec="seconds")
|
||
conn.execute(
|
||
"""UPDATE alerts
|
||
SET snoozed_until = ?, shown_at = ?, shown_channel = 'modal',
|
||
show_attempts = show_attempts + 1
|
||
WHERE alert_id = ?""",
|
||
(until, _now_iso(), alert_id))
|
||
conn.commit()
|
||
|
||
|
||
def resolve(conn: sqlite3.Connection, code: str, note: str) -> int:
|
||
"""해당 코드의 미해소 알림을 전부 해소한다. 해소한 건수를 반환한다."""
|
||
cur = conn.execute(
|
||
"""UPDATE alerts SET resolved_at = ?, resolved_note = ?
|
||
WHERE code = ? AND resolved_at IS NULL""",
|
||
(_now_iso(), note, code))
|
||
conn.commit()
|
||
return cur.rowcount
|
||
|
||
|
||
def resolve_date_scoped(conn: sqlite3.Connection, note: str) -> int:
|
||
"""다음 실행이 성공했을 때 일자성 WARN 을 일괄 해소한다(R-D1)."""
|
||
cur = conn.execute(
|
||
"""UPDATE alerts SET resolved_at = ?, resolved_note = ?
|
||
WHERE resolved_at IS NULL
|
||
AND severity IN ('INFO','WARN')
|
||
AND instr(dedup_key, '|') > 0""",
|
||
(_now_iso(), note))
|
||
conn.commit()
|
||
return cur.rowcount
|
||
|
||
|
||
def shown_today(conn: sqlite3.Connection) -> int:
|
||
cur = conn.execute("SELECT shown_count FROM v_alert_shown_today")
|
||
row = cur.fetchone()
|
||
return int(row["shown_count"]) if row else 0
|
||
|
||
|
||
def mirror_to_file(conn: sqlite3.Connection, path: Path) -> None:
|
||
"""state/alerts.json 을 원자적으로 갱신한다.
|
||
|
||
PowerShell 폴백(notify.ps1)과 GUI 가 SQLite 잠금 없이 읽는 유일한 경로다.
|
||
실패해도 예외를 던지지 않는다.
|
||
"""
|
||
try:
|
||
rows = pending(conn)
|
||
counts = {s.value: 0 for s in Severity}
|
||
payload_rows = []
|
||
for row in rows:
|
||
counts[row.severity.value] += 1
|
||
payload_rows.append({
|
||
"alert_id": row.alert_id, "dedup_key": row.dedup_key,
|
||
"code": row.code, "severity": row.severity.value,
|
||
"first_seen_at": row.first_seen_at, "last_seen_at": row.last_seen_at,
|
||
"occurrences": row.occurrences, "title": row.title,
|
||
"what": row.what, "why": row.why, "how": row.how,
|
||
"next_actions": list(row.next_actions),
|
||
"log_dir": row.log_dir, "run_id": row.run_id,
|
||
})
|
||
payload = {
|
||
"schema": 1,
|
||
"updated_at": _now_iso(),
|
||
"host": socket.gethostname(),
|
||
"pending": payload_rows,
|
||
"counts": counts,
|
||
}
|
||
path.parent.mkdir(parents=True, exist_ok=True)
|
||
tmp = path.with_suffix(".json.tmp")
|
||
tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=2),
|
||
encoding="utf-8")
|
||
os.replace(tmp, path)
|
||
except Exception:
|
||
pass
|
||
```
|
||
|
||
---
|
||
|
||
### 6.5 `src/dmf_crawler/watchdog.py` 전문
|
||
|
||
```python
|
||
# src/dmf_crawler/watchdog.py
|
||
"""heartbeat 신선도 판정과 연속 실패 감시.
|
||
|
||
아키텍처 부록 결정: 워치독을 별도 작업으로 분리하지 않는다.
|
||
로그온 세션의 Agent(15분 주기) 안에서 판정하면 작업이 하나 줄고,
|
||
"알림이 뜨는 세션에서 판정한다"는 성질이 공짜로 따라온다.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import sqlite3
|
||
from dataclasses import dataclass
|
||
from datetime import datetime, timedelta
|
||
from pathlib import Path
|
||
|
||
from dmf_crawler import paths
|
||
from dmf_crawler.alerts import Severity, raise_alert
|
||
from dmf_crawler.config import Config
|
||
|
||
|
||
@dataclass(frozen=True, slots=True)
|
||
class Heartbeat:
|
||
exists: bool
|
||
last_success_at: datetime | None
|
||
run_id: str | None
|
||
status: str | None
|
||
report_path: str | None
|
||
log_dir: str | None
|
||
|
||
@property
|
||
def age_minutes(self) -> float:
|
||
if self.last_success_at is None:
|
||
return float("inf")
|
||
delta = datetime.now().astimezone() - self.last_success_at
|
||
return delta.total_seconds() / 60.0
|
||
|
||
|
||
def read_heartbeat(path: Path | None = None) -> Heartbeat:
|
||
target = path or paths.HEARTBEAT_PATH
|
||
try:
|
||
data = json.loads(target.read_text(encoding="utf-8"))
|
||
except (OSError, json.JSONDecodeError):
|
||
return Heartbeat(False, None, None, None, None, None)
|
||
try:
|
||
ts = datetime.fromisoformat(data["last_success_at"])
|
||
except (KeyError, ValueError):
|
||
ts = None
|
||
return Heartbeat(
|
||
exists=True, last_success_at=ts,
|
||
run_id=data.get("run_id"), status=data.get("status"),
|
||
report_path=data.get("report_path"), log_dir=data.get("log_dir"),
|
||
)
|
||
|
||
|
||
def write_heartbeat(cfg: Config, *, run_id: str, status: str,
|
||
report_path: str | None, log_dir: str) -> None:
|
||
"""성공/부분성공일 때만 호출한다. FAILED 에서는 절대 갱신하지 않는다.
|
||
|
||
(심사에서 후보 C 가 '항상 갱신'해 dead-man switch 를 설계상 무력화한 것이
|
||
최대 감점이었다. 여기서 같은 실수를 하면 워치독 전체가 무의미해진다.)
|
||
"""
|
||
payload = {
|
||
"last_success_at": datetime.now().astimezone().isoformat(timespec="seconds"),
|
||
"run_id": run_id,
|
||
"status": status,
|
||
"report_path": report_path,
|
||
"log_dir": log_dir,
|
||
}
|
||
paths.STATE_DIR.mkdir(parents=True, exist_ok=True)
|
||
tmp = paths.HEARTBEAT_PATH.with_suffix(".json.tmp")
|
||
tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||
import os
|
||
os.replace(tmp, paths.HEARTBEAT_PATH)
|
||
|
||
|
||
def touch_pump_stamp() -> None:
|
||
"""notify.ps1 폴백이 읽는 생존 신호. pump 가 매 주기 갱신한다."""
|
||
paths.STATE_DIR.mkdir(parents=True, exist_ok=True)
|
||
paths.PUMP_STAMP.write_text(
|
||
datetime.now().astimezone().isoformat(timespec="seconds"), encoding="utf-8")
|
||
|
||
|
||
def consecutive_failed_days(conn: sqlite3.Connection) -> int:
|
||
"""오늘부터 거슬러 올라가며 연속 실패한 날 수를 센다."""
|
||
cur = conn.execute(
|
||
"""SELECT substr(started_at, 1, 10) AS d,
|
||
MAX(CASE WHEN status IN ('SUCCESS','PARTIAL') THEN 1 ELSE 0 END) AS ok
|
||
FROM runs
|
||
WHERE started_at >= date('now', '-14 days')
|
||
GROUP BY d
|
||
ORDER BY d DESC""")
|
||
days = 0
|
||
for row in cur.fetchall():
|
||
if row["ok"] == 1:
|
||
break
|
||
days += 1
|
||
return days
|
||
|
||
|
||
def top_failure_causes(conn: sqlite3.Connection, limit: int = 3
|
||
) -> list[tuple[str, int]]:
|
||
"""최근 14일 실패 알림 코드 상위 N개. CONSECUTIVE_FAILURES 문구에 쓴다."""
|
||
cur = conn.execute(
|
||
"""SELECT code, COUNT(*) AS n
|
||
FROM alert_events
|
||
WHERE occurred_at >= datetime('now', '-14 days')
|
||
AND severity IN ('ERROR','CRITICAL')
|
||
AND code NOT IN ('CONSECUTIVE_FAILURES','RUN_PAUSED','WATCHDOG_STALE')
|
||
GROUP BY code
|
||
ORDER BY n DESC
|
||
LIMIT ?""", (limit,))
|
||
return [(r["code"], r["n"]) for r in cur.fetchall()]
|
||
|
||
|
||
def evaluate(conn: sqlite3.Connection, cfg: Config) -> list[str]:
|
||
"""워치독 판정 1회. 발생시킨 알림 코드 목록을 반환한다."""
|
||
fired: list[str] = []
|
||
hb = read_heartbeat()
|
||
now = datetime.now().astimezone()
|
||
|
||
# ---- 1) heartbeat 신선도 -------------------------------------------
|
||
# 06:00 실행 + 여유 2시간. 08:00 이전에는 판정하지 않는다.
|
||
scheduled_hour, scheduled_min = (int(x) for x in cfg.schedule.daily_time.split(":"))
|
||
today_due = now.replace(hour=scheduled_hour, minute=scheduled_min,
|
||
second=0, microsecond=0)
|
||
grace = timedelta(minutes=cfg.notify.watchdog_stale_minutes)
|
||
if now >= today_due + grace and hb.age_minutes > cfg.notify.watchdog_stale_minutes:
|
||
last_txt = (hb.last_success_at.strftime("%Y-%m-%d %H:%M")
|
||
if hb.last_success_at else "기록 없음")
|
||
stale_h = (int(hb.age_minutes // 60)
|
||
if hb.last_success_at else "알 수 없음")
|
||
if raise_alert(
|
||
conn, run_id=None, severity=Severity.CRITICAL, code="WATCHDOG_STALE",
|
||
what=f"지금 {now:%H:%M} 기준으로 오늘 {cfg.schedule.daily_time} 배치가 "
|
||
f"실행된 흔적이 없습니다. 마지막으로 성공한 실행은 "
|
||
f"{last_txt} ({stale_h}시간 전)입니다.",
|
||
why="(1) PC가 실행 시각에 꺼져 있었고 아직 따라잡기가 실행되지 않음 "
|
||
"(2) 작업 스케줄러 항목이 꺼졌거나 삭제됨 "
|
||
f"(3) 실행이 제한 시간({cfg.schedule.execution_time_limit_minutes}분)을 "
|
||
"넘겨 강제 종료됨",
|
||
how="[지금 실행]을 누르면 즉시 오늘 자료를 수집합니다(약 2분). "
|
||
"반복된다면 [작업 상태 확인]으로 스케줄러 등록 상태를 점검하세요.",
|
||
next_actions=("run_now", "open_task_scheduler", "open_log_dir", "snooze"),
|
||
context={"now_time": f"{now:%H:%M}", "last_success_at": last_txt,
|
||
"stale_hours": stale_h,
|
||
"exec_limit_min": cfg.schedule.execution_time_limit_minutes},
|
||
log_dir=Path(hb.log_dir) if hb.log_dir else None,
|
||
cooldown_minutes=cfg.notify.modal_repeat_minutes,
|
||
date_scoped=False, source="watchdog",
|
||
):
|
||
fired.append("WATCHDOG_STALE")
|
||
|
||
# ---- 2) 연속 실패 --------------------------------------------------
|
||
failed_days = consecutive_failed_days(conn)
|
||
if failed_days >= cfg.notify.escalation_days:
|
||
causes = top_failure_causes(conn, 3)
|
||
while len(causes) < 3:
|
||
causes.append(("(추가 원인 없음)", 0))
|
||
ctx = {
|
||
"failed_days": failed_days,
|
||
"first_failed_date": (now - timedelta(days=failed_days - 1)).strftime("%Y-%m-%d"),
|
||
"last_failed_date": now.strftime("%Y-%m-%d"),
|
||
"top_cause_1": causes[0][0], "top_cause_1_count": causes[0][1],
|
||
"top_cause_2": causes[1][0], "top_cause_2_count": causes[1][1],
|
||
"top_cause_3": causes[2][0], "top_cause_3_count": causes[2][1],
|
||
"pause_day": cfg.notify.escalation_stop_after_days,
|
||
}
|
||
from dmf_crawler.notify import messages
|
||
rendered = messages.render("CONSECUTIVE_FAILURES", ctx)
|
||
if raise_alert(
|
||
conn, run_id=None, severity=Severity.CRITICAL,
|
||
code="CONSECUTIVE_FAILURES",
|
||
what=rendered.what, why=rendered.why, how=rendered.how,
|
||
next_actions=rendered.next_actions, context=ctx,
|
||
log_dir=Path(hb.log_dir) if hb.log_dir else None,
|
||
cooldown_minutes=cfg.notify.modal_repeat_minutes,
|
||
date_scoped=False, source="watchdog",
|
||
):
|
||
fired.append("CONSECUTIVE_FAILURES")
|
||
|
||
# ---- 3) 자동 실행 일시중지 (7일차) ----------------------------------
|
||
if failed_days >= cfg.notify.escalation_stop_after_days:
|
||
if not paths.PAUSE_FLAG.exists():
|
||
paths.STATE_DIR.mkdir(parents=True, exist_ok=True)
|
||
paths.PAUSE_FLAG.write_text(
|
||
json.dumps({"paused_at": now.isoformat(timespec="seconds"),
|
||
"reason": f"{failed_days}일 연속 실패",
|
||
"failed_days": failed_days},
|
||
ensure_ascii=False), encoding="utf-8")
|
||
ctx = {"failed_days": failed_days,
|
||
"pause_at": now.strftime("%Y-%m-%d %H:%M"),
|
||
"pause_flag_path": str(paths.PAUSE_FLAG)}
|
||
from dmf_crawler.notify import messages
|
||
rendered = messages.render("RUN_PAUSED", ctx)
|
||
if raise_alert(
|
||
conn, run_id=None, severity=Severity.CRITICAL, code="RUN_PAUSED",
|
||
what=rendered.what, why=rendered.why, how=rendered.how,
|
||
next_actions=rendered.next_actions, context=ctx,
|
||
log_dir=Path(hb.log_dir) if hb.log_dir else None,
|
||
cooldown_minutes=cfg.notify.modal_repeat_minutes,
|
||
date_scoped=False, source="watchdog",
|
||
):
|
||
fired.append("RUN_PAUSED")
|
||
|
||
return fired
|
||
```
|
||
|
||
---
|
||
|
||
### 6.6 `src/dmf_crawler/notify/pump.py` 전문
|
||
|
||
```python
|
||
# src/dmf_crawler/notify/pump.py
|
||
"""대화형 알림 에이전트 — UI 를 띄울 권한을 가진 유일한 프로세스.
|
||
|
||
실행 주체:
|
||
Task Scheduler 작업 DMF_Crawler_Agent
|
||
(LogonType Interactive, 15분 반복 + AtLogOn)
|
||
|
||
원칙:
|
||
- 어떤 예외도 사용자에게 보이지 않게 삼킨다. 다만 이벤트 로그에는 반드시 남긴다.
|
||
"알리미가 죽어서 조용해지는 것"이 이 시스템의 최악의 실패다.
|
||
- 매 주기 pump.stamp 를 갱신한다. 이것이 없으면 notify.ps1 폴백이 발동한다.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import sys
|
||
import traceback
|
||
from datetime import datetime
|
||
|
||
from dmf_crawler import checks, paths, watchdog
|
||
from dmf_crawler.alerts import AlertRow, Severity
|
||
from dmf_crawler.alerts import (mark_show_failed, mark_shown, mirror_to_file,
|
||
pending, snooze)
|
||
from dmf_crawler.config import Config, load_config
|
||
from dmf_crawler.gui import steps
|
||
from dmf_crawler.notify import eventlog, toast, webhook
|
||
from dmf_crawler.runlock import file_lock
|
||
from dmf_crawler.storage import db
|
||
|
||
_PUMP_LOCK = "pump.lock"
|
||
|
||
|
||
def pump_once(cfg: Config) -> int:
|
||
"""1회 주기. 종료 코드를 반환한다(0=정상, 1=표시 실패 있음, 2=치명)."""
|
||
# 자체 락. 이전 주기의 모달이 아직 떠 있으면 새로 띄우지 않는다.
|
||
try:
|
||
with file_lock(paths.STATE_DIR / _PUMP_LOCK, timeout_s=0):
|
||
return _pump_body(cfg)
|
||
except TimeoutError:
|
||
# 이미 다른 pump 가 돌고 있다. 정상적인 상황이다.
|
||
watchdog.touch_pump_stamp()
|
||
return 0
|
||
|
||
|
||
def _pump_body(cfg: Config) -> int:
|
||
watchdog.touch_pump_stamp()
|
||
toast.reset_stack()
|
||
|
||
conn = db.connect(cfg, readonly=False)
|
||
try:
|
||
# ---- 1) 워치독 판정 -> 필요하면 alerts 에 기록 -------------------
|
||
try:
|
||
watchdog.evaluate(conn, cfg)
|
||
except Exception:
|
||
eventlog.write(source=cfg.notify.eventlog_source,
|
||
severity=Severity.ERROR, event_id=900,
|
||
message="워치독 판정 중 오류: " + traceback.format_exc(limit=3))
|
||
|
||
# ---- 2) 자동 해소 판정 (R-D2) ------------------------------------
|
||
_auto_resolve(conn, cfg)
|
||
|
||
# ---- 3) 표시 대상 조회 -------------------------------------------
|
||
rows = pending(conn, modal_repeat_minutes=cfg.notify.modal_repeat_minutes)
|
||
mirror_to_file(conn, paths.ALERTS_MIRROR)
|
||
if not rows:
|
||
return 0
|
||
|
||
# ---- 4) 일일 상한(L3) 판정 — CRITICAL 은 면제 ---------------------
|
||
from dmf_crawler.alerts import shown_today
|
||
used = shown_today(conn)
|
||
criticals = [r for r in rows if r.severity is Severity.CRITICAL]
|
||
others = [r for r in rows if r.severity is not Severity.CRITICAL]
|
||
if used >= cfg.notify.daily_alert_cap and others:
|
||
eventlog.write(source=cfg.notify.eventlog_source,
|
||
severity=Severity.INFO, event_id=520,
|
||
message=f"일일 알림 표시 상한 {cfg.notify.daily_alert_cap}건 도달. "
|
||
f"남은 {len(others)}건은 표시하지 않음.")
|
||
others = []
|
||
|
||
# ---- 5) 표시 ------------------------------------------------------
|
||
failures = 0
|
||
# 5-a) CRITICAL / ERROR -> 강제 모달. 한 주기에 하나만 띄운다.
|
||
modal_targets = criticals + [r for r in others if r.severity is Severity.ERROR]
|
||
if modal_targets:
|
||
top = modal_targets[0]
|
||
if not _show_modal(conn, cfg, top):
|
||
failures += 1
|
||
# 모달은 사용자가 응답할 때까지 블록한다. 나머지는 다음 주기로 미룬다.
|
||
return 1 if failures else 0
|
||
|
||
# 5-b) WARN/INFO -> 토스트. 2건 이상이면 병합(L2).
|
||
toast_targets = [r for r in others
|
||
if r.severity is Severity.WARN
|
||
or (r.severity is Severity.INFO and cfg.notify.show_info_toast)]
|
||
if not toast_targets:
|
||
return 0
|
||
|
||
if toast.is_presentation_mode():
|
||
eventlog.write(source=cfg.notify.eventlog_source,
|
||
severity=Severity.INFO, event_id=510,
|
||
message="전체 화면/프레젠테이션 모드 감지. "
|
||
"토스트 표시를 다음 주기로 미룸.")
|
||
return 0
|
||
|
||
if len(toast_targets) >= cfg.notify.merge_threshold:
|
||
if not _show_merged(conn, cfg, toast_targets):
|
||
failures += 1
|
||
else:
|
||
for row in toast_targets[: cfg.notify.max_toasts_per_hour]:
|
||
if not _show_toast(conn, cfg, row):
|
||
failures += 1
|
||
|
||
mirror_to_file(conn, paths.ALERTS_MIRROR)
|
||
return 1 if failures else 0
|
||
finally:
|
||
conn.close()
|
||
|
||
|
||
# ------------------------------------------------------------------ 표시부
|
||
def _show_toast(conn, cfg: Config, row: AlertRow) -> bool:
|
||
buttons = tuple(
|
||
toast.ToastButton(key, steps.label_of(key))
|
||
for key in row.next_actions[:3] # 토스트에는 최대 3개
|
||
)
|
||
|
||
def _on_click(key: str) -> None:
|
||
if key == "snooze":
|
||
snooze(conn, row.alert_id, minutes=cfg.notify.snooze_minutes)
|
||
return
|
||
steps.invoke(key, cfg, row.as_context())
|
||
|
||
result = toast.show(
|
||
title=row.title, body=row.body, severity=row.severity.value,
|
||
buttons=buttons, seconds=cfg.notify.toast_seconds, on_click=_on_click,
|
||
)
|
||
|
||
if result.shown:
|
||
mark_shown(conn, row.alert_id, channel="toast")
|
||
eventlog.write_alert(cfg, row.code, row.severity, row.title, row.what,
|
||
event_id=eventlog.EVENT_IDS["NOTIFY_SHOWN"])
|
||
return True
|
||
|
||
attempts = mark_show_failed(conn, row.alert_id)
|
||
eventlog.write(source=cfg.notify.eventlog_source, severity=Severity.WARN,
|
||
event_id=510,
|
||
message=f"[{row.code}] 토스트 표시 실패 ({attempts}회째).")
|
||
if attempts >= 3:
|
||
# R-P3: 3회 실패하면 모달 경로로 승격한다.
|
||
return _show_modal(conn, cfg, row)
|
||
return False
|
||
|
||
|
||
def _show_merged(conn, cfg: Config, rows: list[AlertRow]) -> bool:
|
||
lines = [f"[{'주의' if r.severity is Severity.WARN else '정보'}] {r.title}"
|
||
for r in rows[:5]]
|
||
if len(rows) > 5:
|
||
lines.append(f"그 외 {len(rows) - 5}건")
|
||
|
||
def _on_click(key: str) -> None:
|
||
steps.invoke(key, cfg, rows[0].as_context())
|
||
|
||
result = toast.show_merged(
|
||
count=len(rows), lines=lines, severity="WARN",
|
||
seconds=cfg.notify.toast_seconds, on_click=_on_click,
|
||
)
|
||
if not result.shown:
|
||
for row in rows:
|
||
mark_show_failed(conn, row.alert_id)
|
||
return False
|
||
for row in rows:
|
||
mark_shown(conn, row.alert_id, channel="toast")
|
||
eventlog.write_alert(cfg, row.code, row.severity, row.title, row.what,
|
||
event_id=eventlog.EVENT_IDS["NOTIFY_SHOWN"])
|
||
return True
|
||
|
||
|
||
def _show_modal(conn, cfg: Config, row: AlertRow) -> bool:
|
||
# CRITICAL 은 화면 표시 성공 여부와 무관하게 웹훅을 병렬 발사한다.
|
||
if row.severity is Severity.CRITICAL:
|
||
webhook.send_alert(cfg, row)
|
||
|
||
eventlog.write_alert(cfg, row.code, row.severity, row.title, row.body,
|
||
event_id=_eventlog_id_for(row))
|
||
|
||
focus_key = _CHECK_KEY_BY_CODE.get(row.code)
|
||
try:
|
||
from dmf_crawler.gui import app as gui_app
|
||
gui_app.launch(mode="recover", focus_key=focus_key, alert_id=row.alert_id)
|
||
except Exception:
|
||
eventlog.write(source=cfg.notify.eventlog_source, severity=Severity.ERROR,
|
||
event_id=510,
|
||
message=f"[{row.code}] 복구 창을 띄우지 못했습니다. "
|
||
f"MessageBox 폴백으로 강등합니다. "
|
||
+ traceback.format_exc(limit=2))
|
||
return _show_messagebox_fallback(conn, cfg, row)
|
||
|
||
mark_shown(conn, row.alert_id, channel="modal")
|
||
return True
|
||
|
||
|
||
def _show_messagebox_fallback(conn, cfg: Config, row: AlertRow) -> bool:
|
||
"""tkinter 가 완전히 불가능할 때 PowerShell 로 MessageBox 를 띄운다."""
|
||
import subprocess
|
||
script = paths.SCRIPTS_DIR / "notify.ps1"
|
||
try:
|
||
subprocess.run(
|
||
["powershell.exe", "-NoProfile", "-NonInteractive",
|
||
"-WindowStyle", "Hidden", "-ExecutionPolicy", "Bypass",
|
||
"-File", str(script), "-Mode", "Force"],
|
||
timeout=180, creationflags=steps.CREATE_NO_WINDOW,
|
||
)
|
||
except Exception:
|
||
mark_show_failed(conn, row.alert_id)
|
||
return False
|
||
mark_shown(conn, row.alert_id, channel="messagebox")
|
||
return True
|
||
|
||
|
||
def _eventlog_id_for(row: AlertRow) -> int:
|
||
from dmf_crawler.notify import messages
|
||
tpl = messages.TEMPLATES.get(row.code)
|
||
if tpl and tpl.eventlog_id:
|
||
return tpl.eventlog_id
|
||
return {"WARN": 200, "ERROR": 300, "CRITICAL": 400}.get(row.severity.value, 200)
|
||
|
||
|
||
# 알림 코드 -> 복구 GUI 에서 포커스할 체크 항목 키
|
||
_CHECK_KEY_BY_CODE: dict[str, str] = {
|
||
"API_KEY_MISSING": "api_key",
|
||
"API_KEY_INVALID": "api_key_live",
|
||
"AGY_MISSING": "agy_binary",
|
||
"AGY_AUTH": "agy_auth",
|
||
"DB_CORRUPT": "db_integrity",
|
||
"MIGRATION_FAILED": "db_schema",
|
||
"TASK_MISSING": "tasks",
|
||
"DISK_LOW": "disk_free",
|
||
"REPORT_FAILED": "report_writable",
|
||
"REPORT_LOCKED": "report_writable",
|
||
"WATCHDOG_STALE": "last_run",
|
||
"CONSECUTIVE_FAILURES": "last_run",
|
||
"RUN_PAUSED": "last_run",
|
||
}
|
||
|
||
|
||
def _auto_resolve(conn, cfg: Config) -> None:
|
||
"""상태성 CRITICAL 은 해당 체크가 통과하면 자동 해소한다(R-D2)."""
|
||
from dmf_crawler.alerts import resolve
|
||
open_codes = {r.code for r in pending(conn, modal_repeat_minutes=10 ** 6)}
|
||
for code, check_key in _CHECK_KEY_BY_CODE.items():
|
||
if code not in open_codes:
|
||
continue
|
||
try:
|
||
outcome = checks.run_one(check_key, cfg)
|
||
except Exception:
|
||
continue
|
||
if outcome.ok:
|
||
n = resolve(conn, code, note=f"진단 '{check_key}' 통과로 자동 해소")
|
||
if n:
|
||
eventlog.write(
|
||
source=cfg.notify.eventlog_source, severity=Severity.INFO,
|
||
event_id=500,
|
||
message=f"[{code}] 문제가 해결돼 알림을 지웠습니다.")
|
||
|
||
|
||
def main(argv: list[str] | None = None) -> int:
|
||
"""cli.py 의 notify-pump 서브커맨드 본체."""
|
||
try:
|
||
cfg = load_config()
|
||
except Exception:
|
||
# 설정조차 못 읽으면 이벤트 로그가 유일한 통로다.
|
||
eventlog.write(source="DMF Crawler", severity=Severity.CRITICAL,
|
||
event_id=900,
|
||
message="설정을 읽지 못해 알림 에이전트를 시작하지 못했습니다: "
|
||
+ traceback.format_exc(limit=3))
|
||
return 2
|
||
try:
|
||
return pump_once(cfg)
|
||
except Exception:
|
||
eventlog.write(source=cfg.notify.eventlog_source, severity=Severity.CRITICAL,
|
||
event_id=900,
|
||
message="알림 에이전트가 예외로 종료됐습니다: "
|
||
+ traceback.format_exc(limit=5))
|
||
return 2
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main(sys.argv[1:]))
|
||
```
|
||
|
||
---
|
||
|
||
### 6.7 `src/dmf_crawler/notify/webhook.py` 전문
|
||
|
||
```python
|
||
# src/dmf_crawler/notify/webhook.py
|
||
"""웹훅 알림과 dead-man switch. 선택 기능(기본 off).
|
||
|
||
원칙:
|
||
- 절대 예외를 밖으로 던지지 않는다.
|
||
- 재시도하지 않는다(1회만). 웹훅 실패는 화면 알림을 대체하지 않는다.
|
||
- URL 은 DPAPI 로 암호화 저장된다. config.toml 에 평문으로 두지 않는다.
|
||
- 본문에 인증키·토큰을 절대 포함하지 않는다(mask 적용).
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import re
|
||
from typing import Any
|
||
|
||
import httpx
|
||
|
||
from dmf_crawler import secrets_dpapi
|
||
from dmf_crawler.alerts import AlertRow, Severity
|
||
from dmf_crawler.config import Config
|
||
|
||
_SECRET_NAME = "webhook_url"
|
||
_DEADMAN_NAME = "deadman_url"
|
||
|
||
# 로그·웹훅 본문에서 지워야 하는 패턴
|
||
_MASK_PATTERNS = (
|
||
re.compile(r"(serviceKey=)[^&\s]+", re.I),
|
||
re.compile(r"(access_token[\"'\s:=]+)[A-Za-z0-9._\-]+", re.I),
|
||
re.compile(r"\bya29\.[A-Za-z0-9._\-]+"),
|
||
)
|
||
|
||
|
||
def _mask(text: str) -> str:
|
||
out = text
|
||
for pat in _MASK_PATTERNS:
|
||
out = pat.sub(lambda m: (m.group(1) if m.lastindex else "") + "***", out)
|
||
return out
|
||
|
||
|
||
def _url() -> str | None:
|
||
try:
|
||
value = secrets_dpapi.load(_SECRET_NAME)
|
||
except Exception:
|
||
return None
|
||
return value.strip() if value else None
|
||
|
||
|
||
def _payload(cfg: Config, title: str, body: str, severity: Severity) -> dict[str, Any]:
|
||
kind = cfg.notify.webhook_kind
|
||
icon = {"INFO": "ℹ️", "WARN": "⚠️", "ERROR": "⛔", "CRITICAL": "🚨"}.get(
|
||
severity.value, "⚠️")
|
||
text = f"{icon} **{title}**\n```\n{body}\n```"
|
||
|
||
if kind == "discord":
|
||
return {"content": text[:1900]}
|
||
if kind == "slack":
|
||
return {"text": text[:3000]}
|
||
if kind == "telegram":
|
||
# 텔레그램은 URL 에 chat_id 가 포함된 형태를 전제로 한다.
|
||
return {"text": text[:4000], "parse_mode": "Markdown"}
|
||
return {"title": title, "body": body, "severity": severity.value}
|
||
|
||
|
||
def send(cfg: Config, *, title: str, body: str, severity: Severity) -> bool:
|
||
"""웹훅 1건 발사. 성공하면 True."""
|
||
if not cfg.notify.webhook_enabled:
|
||
return False
|
||
if not severity.at_least(Severity(cfg.notify.webhook_min_severity)):
|
||
return False
|
||
url = _url()
|
||
if not url:
|
||
return False
|
||
|
||
payload = _payload(cfg, _mask(title), _mask(body), severity)
|
||
try:
|
||
with httpx.Client(timeout=cfg.notify.webhook_timeout_seconds) as client:
|
||
resp = client.post(url, json=payload)
|
||
return 200 <= resp.status_code < 300
|
||
except Exception:
|
||
return False
|
||
|
||
|
||
def send_alert(cfg: Config, row: AlertRow) -> bool:
|
||
body = (f"{row.body}\n\n"
|
||
f"발생: {row.last_seen_at} (누적 {row.occurrences}회)\n"
|
||
f"코드: {row.code}")
|
||
return send(cfg, title=row.title, body=body, severity=row.severity)
|
||
|
||
|
||
# ------------------------------------------------------- dead-man switch
|
||
def ping(cfg: Config, event: str) -> bool:
|
||
"""healthchecks.io 계열 dead-man switch.
|
||
|
||
event: "start" | "success" | "fail"
|
||
PC 가 통째로 꺼져 있어도 상대편이 알아채는 유일한 경로다.
|
||
"""
|
||
if not cfg.notify.deadman_enabled:
|
||
return False
|
||
try:
|
||
base = secrets_dpapi.load(_DEADMAN_NAME)
|
||
except Exception:
|
||
return False
|
||
if not base:
|
||
return False
|
||
base = base.rstrip("/")
|
||
suffix = {"start": "/start", "success": "", "fail": "/fail"}.get(event, "")
|
||
try:
|
||
with httpx.Client(timeout=cfg.notify.webhook_timeout_seconds) as client:
|
||
resp = client.get(base + suffix)
|
||
return 200 <= resp.status_code < 300
|
||
except Exception:
|
||
return False
|
||
|
||
|
||
def test(cfg: Config) -> tuple[bool, str]:
|
||
"""온보딩 GUI 의 '웹훅 테스트' 버튼이 부르는 함수."""
|
||
url = _url()
|
||
if not url:
|
||
return False, "웹훅 주소가 저장돼 있지 않습니다."
|
||
ok = send(cfg,
|
||
title="DMF 크롤러 — 웹훅 연결 시험",
|
||
body="이 메시지가 보이면 웹훅이 정상 연결됐습니다.\n"
|
||
"앞으로 심각한 문제가 생기면 여기로 알려드립니다.",
|
||
severity=Severity.CRITICAL)
|
||
return (True, "테스트 메시지를 보냈습니다. 채널을 확인하세요.") if ok \
|
||
else (False, "전송에 실패했습니다. 주소와 인터넷 연결을 확인하세요.")
|
||
```
|
||
|
||
---
|
||
|
||
### 6.8 설정 키 증분 (AMD-04)
|
||
|
||
아키텍처 §6.1 의 `[notify]` 섹션에 아래 키를 추가한다. 기존 5개는 그대로 유지된다.
|
||
|
||
| 키 | 타입 | 기본값 | 설명 |
|
||
|---|---|---|---|
|
||
| `notify.modal_repeat_minutes` | int | `60` | ERROR·CRITICAL 모달 재촉 주기 |
|
||
| `notify.snooze_minutes` | int | `60` | "나중에" 를 눌렀을 때 조용해지는 시간 |
|
||
| `notify.merge_threshold` | int | `2` | 이 건수 이상이면 토스트를 한 장으로 병합 |
|
||
| `notify.max_toasts_per_hour` | int | `6` | 시간당 토스트 상한 |
|
||
| `notify.daily_alert_cap` | int | `20` | 하루 알림 표시 상한(CRITICAL 면제) |
|
||
| `notify.show_info_toast` | bool | `false` | INFO 등급도 토스트로 띄울지 |
|
||
| `notify.pump_stamp_stale_minutes` | int | `45` | 이보다 오래되면 `notify.ps1` 폴백 발동 |
|
||
| `notify.escalation_days` | int | `3` | 연속 실패 이 일수부터 CRITICAL |
|
||
| `notify.escalation_webhook_days` | int | `5` | 이 일수부터 웹훅 강제 발사 |
|
||
| `notify.escalation_stop_after_days` | int | `7` | 이 일수부터 자동 실행 일시중지 |
|
||
| `notify.webhook_enabled` | bool | `false` | 웹훅 사용 |
|
||
| `notify.webhook_kind` | str | `"discord"` | `discord` \| `slack` \| `telegram` \| `generic` |
|
||
| `notify.webhook_min_severity` | str | `"CRITICAL"` | 이 등급 이상만 웹훅 발사 |
|
||
| `notify.webhook_timeout_seconds` | float | `10.0` | 웹훅 타임아웃 |
|
||
| `notify.deadman_enabled` | bool | `false` | dead-man switch 사용 |
|
||
|
||
**`config/config.toml` 의 `[notify]` 완성형**
|
||
|
||
```toml
|
||
[notify]
|
||
# --- 감지 ---------------------------------------------------------------
|
||
watchdog_stale_minutes = 120 # heartbeat 신선도 한계(06:00 + 2시간)
|
||
pump_stamp_stale_minutes = 45 # 이보다 오래되면 PowerShell 폴백 발동
|
||
consecutive_failure_critical = 3 # 같은 WARN 이 이 횟수 반복되면 CRITICAL 승격
|
||
|
||
# --- 표시 ---------------------------------------------------------------
|
||
toast_seconds = 12 # 자동 소멸 알림 표시 시간
|
||
modal_repeat_minutes = 60 # ERROR/CRITICAL 재촉 주기
|
||
snooze_minutes = 60 # "나중에" 를 눌렀을 때 조용해지는 시간
|
||
show_info_toast = false # INFO 도 토스트로 띄울지
|
||
|
||
# --- 폭주 억제 -----------------------------------------------------------
|
||
cooldown_minutes = 240 # 동일 코드 알림 억제 시간(요구 R7.8)
|
||
merge_threshold = 2 # 이 건수 이상이면 한 장으로 병합
|
||
max_toasts_per_hour = 6
|
||
daily_alert_cap = 20 # CRITICAL 은 이 상한을 적용받지 않는다
|
||
|
||
# --- 에스컬레이션 ---------------------------------------------------------
|
||
escalation_days = 3 # 연속 실패 3일 -> CRITICAL + 진단 자동 표시
|
||
escalation_webhook_days = 5 # 5일 -> 웹훅 강제 발사
|
||
escalation_stop_after_days = 7 # 7일 -> 자동 실행 일시중지
|
||
|
||
# --- 기록 ---------------------------------------------------------------
|
||
eventlog_source = "DMF Crawler"
|
||
|
||
# --- 보조 채널(선택, 기본 꺼짐) --------------------------------------------
|
||
# 주소는 config 에 넣지 않는다. 온보딩 GUI 에서 입력하면 DPAPI 로 암호화 저장된다.
|
||
webhook_enabled = false
|
||
webhook_kind = "discord" # discord | slack | telegram | generic
|
||
webhook_min_severity = "CRITICAL"
|
||
webhook_timeout_seconds = 10.0
|
||
deadman_enabled = false
|
||
```
|
||
|
||
---
|
||
|
||
## 7. 알림 테스트 절차
|
||
|
||
### 7.1 `dmf alert-test` 서브커맨드 (AMD-05)
|
||
|
||
**아키텍처 §5 의 서브커맨드 표에 `alert-test` 를 추가한다.** 근거: 알림 계층은 "실패해야만 검증되는" 코드다. 실제 장애를 기다려 검증하면 영원히 검증되지 않는다. 인위적 유발 수단이 없으면 §7.2 의 절차 절반이 실행 불가능하다.
|
||
|
||
| 커맨드 | 인자 | 동작 |
|
||
|---|---|---|
|
||
| `alert-test` | `--code <ALERT_CODE>` | 해당 코드의 알림을 더미 컨텍스트로 1건 발생시킨다(DB 에 기록) |
|
||
| | `--all` | 등록된 모든 템플릿을 순회하며 문구를 **렌더링만** 하고 콘솔에 출력(발생시키지 않음) |
|
||
| | `--show` | 발생 후 즉시 `pump_once()` 를 불러 화면 표시까지 확인 |
|
||
| | `--channel {toast,modal,messagebox,msgexe,webhook,eventlog}` | 특정 채널만 강제로 시험 |
|
||
| | `--cleanup` | `alert-test` 가 만든 알림·이벤트를 전부 해소·삭제 |
|
||
|
||
```python
|
||
# src/dmf_crawler/cli.py (발췌)
|
||
def cmd_alert_test(args) -> int:
|
||
from dmf_crawler.config import load_config
|
||
from dmf_crawler.notify import messages, eventlog, toast, webhook
|
||
from dmf_crawler.storage import db
|
||
from dmf_crawler import alerts, paths
|
||
from dmf_crawler.notify import pump
|
||
|
||
cfg = load_config()
|
||
|
||
# --all : 전 템플릿 렌더링 검사. DB 를 건드리지 않는다.
|
||
if args.all:
|
||
for code, tpl in sorted(messages.TEMPLATES.items()):
|
||
rendered = tpl.render(_DUMMY_CTX)
|
||
print("=" * 72)
|
||
print(f"[{tpl.severity.value}] {code}")
|
||
print(f"제목: {rendered.title}")
|
||
print(rendered.body)
|
||
print("버튼: " + " ".join(
|
||
f"[{steps.label_of(k)}]" for k in rendered.next_actions))
|
||
return 0
|
||
|
||
# --channel : 채널 단독 시험. 알림을 만들지 않는다.
|
||
if args.channel:
|
||
return _test_channel(cfg, args.channel)
|
||
|
||
code = args.code
|
||
if code not in messages.TEMPLATES:
|
||
print(f"알 수 없는 코드: {code}")
|
||
print("사용 가능: " + ", ".join(sorted(messages.TEMPLATES)))
|
||
return 2
|
||
|
||
tpl = messages.TEMPLATES[code]
|
||
rendered = tpl.render(_DUMMY_CTX)
|
||
conn = db.connect(cfg, readonly=False)
|
||
try:
|
||
if args.cleanup:
|
||
n = 0
|
||
for c in messages.TEMPLATES:
|
||
n += alerts.resolve(conn, c, note="alert-test --cleanup")
|
||
print(f"{n}건을 해소했습니다.")
|
||
return 0
|
||
|
||
fired = alerts.raise_alert(
|
||
conn, run_id="TEST", severity=tpl.severity, code=code,
|
||
what=rendered.what, why=rendered.why, how=rendered.how,
|
||
next_actions=rendered.next_actions,
|
||
context=dict(_DUMMY_CTX), log_dir=paths.LOGS_DIR,
|
||
cooldown_minutes=0, # 시험에서는 쿨다운을 무시한다
|
||
date_scoped=tpl.date_scoped, source="alert-test",
|
||
)
|
||
alerts.mirror_to_file(conn, paths.ALERTS_MIRROR)
|
||
print(f"{code} 알림을 기록했습니다 (표시대상={fired}).")
|
||
if args.show:
|
||
rc = pump.pump_once(cfg)
|
||
print(f"pump_once 종료 코드: {rc}")
|
||
return 0
|
||
finally:
|
||
conn.close()
|
||
|
||
|
||
_DUMMY_CTX: dict[str, object] = {
|
||
"run_date": "2026-09-02", "run_time": "06:04", "check_time": "2026-09-02 06:04",
|
||
"now_time": "08:15", "stale_date": "2026-09-01",
|
||
"pages_ok": 7, "pages_total": 12, "attempts": 4,
|
||
"last_error_short": "연결 시간 초과 (30초)",
|
||
"fail_count": 3, "cooldown_hours": 24, "resume_at": "2026-09-03 06:00",
|
||
"page_no": 3, "body_bytes": 84, "min_bytes": 200,
|
||
"total_count": 15832, "prev_count": 15840, "curr_count": 14851,
|
||
"drop_count": 989, "drop_pct": "6.2", "threshold_pct": "5.0",
|
||
"gate_name": "수집 건수 일치 검사", "gate_detail": "받은 건수가 API 가 알려준 전체 건수와 다릅니다.",
|
||
"gate_threshold": "0건 차이", "gate_observed": "12건 차이",
|
||
"missing_fields_str": "등록번호, 제조원", "null_ratio_pct": "37.4",
|
||
"baseline_null_pct": "1.0", "proposal_path": r"D:\workspace\DMF_Crawler\state\proposals\2026-09-02.json",
|
||
"result_code": "30", "result_msg": "SERVICE KEY IS NOT REGISTERED ERROR",
|
||
"key_fingerprint": "9f3aB1c2", "error_code": "LIMITED_NUMBER_OF_SERVICE_REQUESTS_EXCEEDS_ERROR",
|
||
"calls_today": 10021,
|
||
"agy_path": r"C:\Users\encep\AppData\Local\agy\bin\agy.exe",
|
||
"agy_error_short": "authentication required", "occurrences": 2,
|
||
"tokens_used": 298_500, "tokens_cap": 300_000, "timeout": "10m",
|
||
"fallback_name": "DMF_리포트_2026-09-02_060412.xlsx",
|
||
"target_name": "DMF_리포트_2026-09-02.xlsx", "retries": 3,
|
||
"report_dir": r"D:\workspace\DMF_Crawler\reports",
|
||
"report_path": r"D:\workspace\DMF_Crawler\reports\DMF_리포트_2026-09-02.xlsx",
|
||
"failure_summary": "시트 '성분별 집계' 생성 중 오류가 발생했습니다.",
|
||
"drive": "D:", "free_gb": "1.4", "min_gb": "2.0", "reclaim_mb": 830,
|
||
"busy_timeout_s": 15, "db_path": r"D:\workspace\DMF_Crawler\data\dmf.sqlite3",
|
||
"integrity_result": "*** in database main *** Page 412: btreeInitPage() returns error code 11",
|
||
"backup_count": 12, "latest_backup_date": "2026-09-01",
|
||
"backup_dir": r"D:\workspace\DMF_Crawler\backup",
|
||
"from_version": 2, "to_version": 3,
|
||
"pre_migration_backup": r"D:\workspace\DMF_Crawler\backup\dmf_premigrate_0003.sqlite3",
|
||
"last_success_at": "2026-09-01 06:03", "stale_hours": 26,
|
||
"exec_limit_min": 30, "missing_tasks_str": "DMF_Crawler_Daily",
|
||
"failed_days": 3, "first_failed_date": "2026-08-31", "last_failed_date": "2026-09-02",
|
||
"top_cause_1": "AGY_AUTH", "top_cause_1_count": 3,
|
||
"top_cause_2": "FETCH_FAILED", "top_cause_2_count": 2,
|
||
"top_cause_3": "INTEGRITY_BLOCKED", "top_cause_3_count": 1,
|
||
"pause_day": 7, "pause_at": "2026-09-06 08:15",
|
||
"pause_flag_path": r"D:\workspace\DMF_Crawler\state\paused.flag",
|
||
"from_channel": "토스트", "to_channel": "복구 창", "reason": "창을 만들지 못했습니다",
|
||
"cap": 20, "pending_n": 4, "stale_min": 47,
|
||
"last_pump_at": "2026-09-02 07:30", "project_root": r"D:\workspace\DMF_Crawler",
|
||
"new_n": 12, "chg_n": 3, "wdr_n": 1,
|
||
"log_dir": r"D:\workspace\DMF_Crawler\logs\run_20260902_060012",
|
||
"raw_dir": r"D:\workspace\DMF_Crawler\data\raw\2026-09-02",
|
||
}
|
||
|
||
|
||
def _test_channel(cfg, channel: str) -> int:
|
||
from dmf_crawler.notify import eventlog, toast, webhook
|
||
from dmf_crawler.alerts import Severity
|
||
import subprocess
|
||
from dmf_crawler import paths
|
||
|
||
if channel == "toast":
|
||
r = toast.show(
|
||
title="DMF 크롤러 — 알림 시험",
|
||
body="[무엇] 알림 채널 점검용 시험 메시지입니다.\n"
|
||
"[왜] dmf alert-test --channel toast 로 직접 실행했습니다.\n"
|
||
"[어떻게] 이 창이 보이면 토스트 채널이 정상입니다.",
|
||
severity="WARN",
|
||
buttons=(toast.ToastButton("open_doctor", "자세히 보기"),
|
||
toast.ToastButton("dismiss", "닫기")),
|
||
seconds=cfg.notify.toast_seconds,
|
||
)
|
||
print(f"표시됨={r.shown}, 눌린버튼={r.clicked}")
|
||
return 0 if r.shown else 1
|
||
|
||
if channel == "modal":
|
||
from dmf_crawler.gui import app as gui_app
|
||
return gui_app.launch(mode="inspect")
|
||
|
||
if channel in ("messagebox", "msgexe"):
|
||
rc = subprocess.run(
|
||
["powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass",
|
||
"-File", str(paths.SCRIPTS_DIR / "notify.ps1"), "-Mode", "Test"]
|
||
).returncode
|
||
return rc
|
||
|
||
if channel == "webhook":
|
||
ok, msg = webhook.test(cfg)
|
||
print(msg)
|
||
return 0 if ok else 1
|
||
|
||
if channel == "eventlog":
|
||
ok = eventlog.write(source=cfg.notify.eventlog_source,
|
||
severity=Severity.WARN, event_id=500,
|
||
message="알림 채널 점검용 시험 기록입니다.")
|
||
print("기록 성공" if ok else "기록 실패")
|
||
return 0 if ok else 1
|
||
|
||
print(f"알 수 없는 채널: {channel}")
|
||
return 2
|
||
```
|
||
|
||
### 7.2 시나리오별 인위적 유발 절차
|
||
|
||
각 행은 **"이렇게 하면 반드시 그 알림이 뜬다"** 는 재현 절차다. 검증 담당자는 이 표를 그대로 따라가면 된다.
|
||
|
||
| # | 코드 | 유발 방법 | 기대 결과 | 원복 |
|
||
|---|---|---|---|---|
|
||
| T01 | `FETCH_FAILED` | `config.local.toml` 에 `[source] base_url = "https://127.0.0.1:9/none"` 지정 후 `dmf run --force` | 4회 재시도 후 WARN 토스트. 리포트는 스테일 자료로 생성 | 해당 줄 삭제 |
|
||
| T02 | `SOURCE_CIRCUIT_OPEN` | T01 을 3회 연속 실행 | 3회차에 CRITICAL 모달 1회. 4회차에는 침묵(전환 시에만 알림) | `DELETE FROM component_health WHERE component='source_mfds'` |
|
||
| T03 | `HTTP_BLOCKED_BODY` | 로컬에 200 + 본문 `<html>점검중</html>` 을 반환하는 1줄 서버를 띄우고 `base_url` 을 그리로 | WARN 토스트. `data/raw/` 에 원문 보존 | 설정 원복 |
|
||
| T04 | `ZERO_RECORDS` | 로컬 서버가 `{"response":{"body":{"totalCount":0,"items":[]}}}` 반환 | CRITICAL 모달 + 웹훅(켜져 있으면). 스냅샷 미저장 | 설정 원복 |
|
||
| T05 | `INTEGRITY_BLOCKED` | `config.local.toml` 에 `[integrity] max_null_ratio = 0.0` | WARN 토스트. diff 미수행 | 값 원복 |
|
||
| T06 | `INTEGRITY_DROP` | `[integrity] max_drop_ratio = 0.0000001` 로 낮춤 | CRITICAL 모달 | 값 원복 |
|
||
| T07 | `SCHEMA_DRIFT` | 로컬 서버가 필드명을 바꾼 응답 반환(`dmfRegNo` → `regNo`) | CRITICAL 모달. 원문 보존 | 설정 원복 |
|
||
| T08 | `API_KEY_MISSING` | `dmf secrets delete service_key` 후 `dmf run` | 종료 코드 2, CRITICAL 강제 모달. GUI 에 키 입력 버튼 | 키 재입력 |
|
||
| T09 | `API_KEY_INVALID` | 인증키를 `INVALID_TEST_KEY` 로 저장 후 `dmf run --force` | CRITICAL 강제 모달. 지문 앞 8자만 표시되는지 확인 | 정상 키 복원 |
|
||
| T10 | `API_QUOTA_EXCEEDED` | 로컬 서버가 `LIMITED_NUMBER_OF_SERVICE_REQUESTS_EXCEEDS_ERROR` 반환 | WARN 토스트. 리포트 생성됨 | 설정 원복 |
|
||
| T11 | `AGY_MISSING` | `config.local.toml` 에 `[agy] binary_path = "C:\\nope\\agy.exe"` | CRITICAL 모달. **리포트는 정상 생성**(핵심 확인 항목) | 값 삭제 |
|
||
| T12 | `AGY_AUTH` | `~/.gemini/antigravity-cli/antigravity-oauth-token` 을 `.bak` 로 **이름 변경** | CRITICAL 모달. [로그인 창 열기] 클릭 시 **보이는 콘솔 창**이 뜨는지 확인 | 파일 이름 복원 |
|
||
| T13 | `AGY_QUOTA` | `[agy] daily_token_cap = 1` 로 설정 후 `dmf run --force` | WARN 토스트. 대시보드 시트에 "AI 요약 없음" 배지 | 값 원복 |
|
||
| T14 | `REPORT_LOCKED` | 오늘 리포트를 Excel 로 열어 둔 채 `dmf run --force` | WARN 토스트 + `_HHMMSS` 폴백 파일 생성 | Excel 닫고 `dmf report-only` |
|
||
| T15 | `REPORT_FAILED` | `reports/` 디렉터리를 읽기 전용으로 만들거나 이름을 바꿔 둠 | ERROR 모달. [리포트 다시 만들기] 버튼 존재 | 권한 원복 |
|
||
| T16 | `DISK_LOW` | `[backup] min_free_gb = 99999` | WARN 토스트 + [정리하기] 버튼. 백업만 스킵 | 값 원복 |
|
||
| T17 | `DB_LOCKED` | DB Browser for SQLite 로 `dmf.sqlite3` 를 열고 쓰기 트랜잭션 시작 후 `dmf run --force` | ERROR 모달 | 도구 닫기 |
|
||
| T18 | `DB_CORRUPT` | DB 사본을 만들어 헥스 편집기로 중간 바이트를 훼손하고 `[storage] sqlite_path` 를 그리로 | CRITICAL 강제 모달 + [백업으로 복원] | 설정 원복 |
|
||
| T19 | `WATCHDOG_STALE` | `state/heartbeat.json` 의 `last_success_at` 을 3일 전으로 수정 후 `dmf notify-pump --once` | CRITICAL 강제 모달 | 파일 삭제 후 정상 실행 |
|
||
| T20 | `TASK_MISSING` | `Disable-ScheduledTask -TaskName DMF_Crawler_Daily` | CRITICAL 모달 + [자동 실행 다시 등록] | `Enable-ScheduledTask` |
|
||
| T21 | `CONSECUTIVE_FAILURES` | `runs` 테이블에 최근 3일치 `status='FAILED'` 행을 직접 INSERT 후 pump 실행 | CRITICAL 모달 + 원인 상위 3개 표시 | 해당 행 DELETE |
|
||
| T22 | `RUN_PAUSED` | 위와 같이 7일치 FAILED INSERT 후 pump 실행 | `state/paused.flag` 생성 + CRITICAL 모달. **다음 `dmf run` 이 즉시 종료되는지 확인** | 플래그 삭제 |
|
||
| T23 | `PYTHON_BROKEN` | `.venv` 폴더를 `.venv_bak` 로 이름 변경 후 Agent 작업 수동 실행 | pump 액션 실패 → `notify.ps1 -Mode Guard` 가 MessageBox 표시 | 이름 복원 |
|
||
| T24 | 병합(L2) | `dmf alert-test --code FETCH_FAILED`, `--code REPORT_LOCKED`, `--code AGY_QUOTA` 를 연속 실행 후 `dmf notify-pump --once` | **토스트 1장**에 3건 요약 | `--cleanup` |
|
||
| T25 | 상한(L3) | `[notify] daily_alert_cap = 1` 로 낮추고 알림 2건 발생 | 2번째는 표시되지 않고 이벤트 ID 520 기록 | 값 원복 |
|
||
| T26 | 쿨다운(L1) | 같은 코드로 `alert-test` 를 2회 연속 실행(쿨다운 240분 기본값 사용) | 2번째는 `표시대상=False`, `occurrences=2` | `--cleanup` |
|
||
| T27 | 로그오프 축적(B1) | 알림 발생 후 로그오프 → 재로그온 | 로그온 직후 `AtLogOn` 트리거로 밀린 알림 표시 | — |
|
||
| T28 | 전체 화면(B4) | 게임/PPT 를 전체 화면으로 띄운 상태에서 WARN 발생 | 토스트가 미뤄지고 이벤트 ID 510 기록. 창을 내리면 다음 주기에 표시 | — |
|
||
|
||
### 7.3 채널별 단독 점검 (설치 직후 필수)
|
||
|
||
```powershell
|
||
# 1) 토스트 — 우하단에 12초짜리 창이 떠야 한다
|
||
D:\workspace\DMF_Crawler\.venv\Scripts\python.exe -m dmf_crawler alert-test --channel toast
|
||
|
||
# 2) 복구 GUI 모달 — 진단 체크리스트 창이 떠야 한다
|
||
D:\workspace\DMF_Crawler\.venv\Scripts\pythonw.exe -m dmf_crawler alert-test --channel modal
|
||
|
||
# 3) MessageBox 폴백 — 항상 맨 앞에 뜨는지 확인
|
||
powershell -NoProfile -ExecutionPolicy Bypass `
|
||
-File D:\workspace\DMF_Crawler\scripts\notify.ps1 -Mode Test
|
||
|
||
# 4) 이벤트 로그
|
||
D:\workspace\DMF_Crawler\.venv\Scripts\python.exe -m dmf_crawler alert-test --channel eventlog
|
||
Get-WinEvent -FilterHashtable @{LogName='Application'; ProviderName='DMF Crawler'} -MaxEvents 3 |
|
||
Format-List TimeCreated, Id, LevelDisplayName, Message
|
||
|
||
# 5) 웹훅(켜 두었을 때만)
|
||
D:\workspace\DMF_Crawler\.venv\Scripts\python.exe -m dmf_crawler alert-test --channel webhook
|
||
|
||
# 6) 전체 문구 렌더링 검사 — 치환 누락, 깨진 줄바꿈, 과도한 길이 확인
|
||
D:\workspace\DMF_Crawler\.venv\Scripts\python.exe -m dmf_crawler alert-test --all
|
||
```
|
||
|
||
### 7.4 자동 회귀 테스트
|
||
|
||
`tests/test_alerts.py` 로 CI 없이도 `pytest` 한 번에 검증되는 항목들이다.
|
||
|
||
```python
|
||
# tests/test_alerts.py
|
||
import json
|
||
import pytest
|
||
|
||
from dmf_crawler.alerts import Severity, raise_alert, pending, resolve, snooze
|
||
from dmf_crawler.notify import messages
|
||
|
||
|
||
def test_모든_템플릿이_4요소를_갖는다():
|
||
"""messages 모듈 임포트만으로 계약이 검증된다(_validate_registry)."""
|
||
assert messages.TEMPLATES # 임포트가 성공했다는 것이 곧 통과
|
||
for code, tpl in messages.TEMPLATES.items():
|
||
assert tpl.what and tpl.why and tpl.how, code
|
||
assert tpl.next_actions, code
|
||
|
||
|
||
def test_실패_알림에_실행가능한_액션이_있다():
|
||
for code, tpl in messages.TEMPLATES.items():
|
||
if tpl.severity.at_least(Severity.WARN):
|
||
actionable = set(tpl.next_actions) - {"snooze", "dismiss"}
|
||
assert actionable, f"{code}: 막다른 골목 알림(R7.7 위반)"
|
||
|
||
|
||
def test_모든_액션키가_레지스트리에_존재한다():
|
||
from dmf_crawler.gui import steps
|
||
for code, tpl in messages.TEMPLATES.items():
|
||
for key in tpl.next_actions:
|
||
assert key in steps.ACTIONS, f"{code}: 미등록 액션 {key}"
|
||
|
||
|
||
def test_이벤트로그_ID가_1에서_1000_범위다():
|
||
"""eventcreate.exe 의 하드 제약."""
|
||
from dmf_crawler.notify import eventlog
|
||
for name, eid in eventlog.EVENT_IDS.items():
|
||
assert 1 <= eid <= 1000, f"{name}={eid} 는 eventcreate 범위를 벗어난다"
|
||
for code, tpl in messages.TEMPLATES.items():
|
||
if tpl.eventlog_id:
|
||
assert 1 <= tpl.eventlog_id <= 1000, code
|
||
|
||
|
||
def test_4요소_누락시_ValueError(tmp_conn):
|
||
with pytest.raises(ValueError, match="why"):
|
||
raise_alert(tmp_conn, run_id="T", severity=Severity.WARN, code="X",
|
||
what="무엇", why=" ", how="어떻게",
|
||
next_actions=("run_now",), cooldown_minutes=0)
|
||
|
||
|
||
def test_막다른골목_알림은_거부된다(tmp_conn):
|
||
with pytest.raises(ValueError, match="R7.7"):
|
||
raise_alert(tmp_conn, run_id="T", severity=Severity.CRITICAL, code="X",
|
||
what="a", why="b", how="c",
|
||
next_actions=("dismiss",), cooldown_minutes=0)
|
||
|
||
|
||
def test_쿨다운_안에서는_표시대상이_아니다(tmp_conn):
|
||
kw = dict(run_id="T", severity=Severity.WARN, code="FETCH_FAILED",
|
||
what="a", why="b", how="c", next_actions=("run_now",),
|
||
cooldown_minutes=240)
|
||
assert raise_alert(tmp_conn, **kw) is True
|
||
assert raise_alert(tmp_conn, **kw) is False # 쿨다운 안
|
||
rows = pending(tmp_conn)
|
||
assert len(rows) == 1
|
||
assert rows[0].occurrences == 2 # 카운터는 올라간다
|
||
|
||
|
||
def test_alert_events는_매번_쌓인다(tmp_conn):
|
||
kw = dict(run_id="T", severity=Severity.WARN, code="FETCH_FAILED",
|
||
what="a", why="b", how="c", next_actions=("run_now",),
|
||
cooldown_minutes=240)
|
||
raise_alert(tmp_conn, **kw)
|
||
raise_alert(tmp_conn, **kw)
|
||
n = tmp_conn.execute("SELECT COUNT(*) c FROM alert_events").fetchone()["c"]
|
||
assert n == 2 # append-only 불변식
|
||
|
||
|
||
def test_해소된_알림은_pending에_없다(tmp_conn):
|
||
raise_alert(tmp_conn, run_id="T", severity=Severity.CRITICAL, code="AGY_AUTH",
|
||
what="a", why="b", how="c", next_actions=("agy_relogin",),
|
||
cooldown_minutes=0, date_scoped=False)
|
||
assert len(pending(tmp_conn)) == 1
|
||
assert resolve(tmp_conn, "AGY_AUTH", note="테스트") == 1
|
||
assert pending(tmp_conn) == []
|
||
|
||
|
||
def test_스누즈_동안은_표시되지_않는다(tmp_conn):
|
||
raise_alert(tmp_conn, run_id="T", severity=Severity.CRITICAL, code="AGY_AUTH",
|
||
what="a", why="b", how="c", next_actions=("agy_relogin",),
|
||
cooldown_minutes=0, date_scoped=False)
|
||
row = pending(tmp_conn)[0]
|
||
snooze(tmp_conn, row.alert_id, minutes=60)
|
||
assert pending(tmp_conn) == []
|
||
|
||
|
||
def test_문구에_비밀값_패턴이_없다():
|
||
"""문구 템플릿이 실수로 키·토큰을 노출하지 않는지."""
|
||
banned = ("serviceKey=", "access_token", "ya29.")
|
||
for code, tpl in messages.TEMPLATES.items():
|
||
blob = " ".join((tpl.title, tpl.what, tpl.why, tpl.how))
|
||
for word in banned:
|
||
assert word not in blob, f"{code}: 비밀값 패턴 '{word}' 노출"
|
||
|
||
|
||
def test_제목이_60자를_넘지_않는다():
|
||
for code, tpl in messages.TEMPLATES.items():
|
||
assert len(tpl.title) <= 60, f"{code}: {len(tpl.title)}자"
|
||
```
|
||
|
||
### 7.5 설치 직후 승인 체크리스트
|
||
|
||
운영 담당자가 최초 설치 후 한 번 통과시키는 목록이다. 하나라도 실패하면 배치를 신뢰할 수 없다.
|
||
|
||
- [ ] `alert-test --all` 이 전 템플릿을 오류 없이 렌더링하고, `(정보 없음)` 이 하나도 안 보인다
|
||
- [ ] `alert-test --channel toast` — 우하단에 창이 뜨고 12초 뒤 사라진다
|
||
- [ ] 토스트에 마우스를 올리면 사라지지 않고, 떼면 3초 뒤 사라진다
|
||
- [ ] `alert-test --channel modal` — 복구 창이 뜨고 진단 12항목이 보인다
|
||
- [ ] `notify.ps1 -Mode Test` — MessageBox 가 **다른 창들보다 앞에** 뜬다
|
||
- [ ] `alert-test --channel eventlog` 후 이벤트 뷰어에 "DMF Crawler" 원본이 보인다
|
||
- [ ] `alert-test --code AGY_AUTH --show` — 모달이 뜨고 [로그인 창 열기]가 있다
|
||
- [ ] 그 버튼을 누르면 **보이는 검은 콘솔 창**이 뜨고 한국어 안내가 나온다
|
||
- [ ] `alert-test --code REPORT_LOCKED --show` — 토스트 [리포트 다시 만들기]가 실제로 동작한다
|
||
- [ ] T24(병합)를 수행하면 창이 3장이 아니라 **1장** 뜬다
|
||
- [ ] T19(워치독)를 수행하면 CRITICAL 모달이 뜬다
|
||
- [ ] T23(`.venv` 제거)을 수행하면 MessageBox 폴백이 뜬다 — **가장 중요한 항목**
|
||
- [ ] 위 시험 후 `alert-test --cleanup` 으로 시험 알림이 전부 사라진다
|
||
- [ ] `state/alerts.json` 의 `pending` 이 빈 배열이 된다
|
||
|
||
---
|
||
|
||
## 8. 에스컬레이션
|
||
|
||
### 8.1 3단 에스컬레이션
|
||
|
||
| 일차 | 트리거 | 자동 조치 | 사용자에게 보이는 것 |
|
||
|---|---|---|---|
|
||
| **1~2일** | `runs.status='FAILED'` | 없음. 다음 실행을 기다린다 | 매일 ERROR 모달 1회(60분마다 재촉) |
|
||
| **3일** | `consecutive_failed_days >= notify.escalation_days` | `CONSECUTIVE_FAILURES` CRITICAL 발생. **복구 GUI 가 `inspect` 모드로 자동 기동**되어 진단 12종을 즉시 보여준다 | 원인 상위 3개가 적힌 CRITICAL 모달 + 진단 화면 |
|
||
| **5일** | `>= notify.escalation_webhook_days` | **웹훅 강제 발사.** `webhook_min_severity` 설정과 무관하게, 그리고 `webhook_enabled=false` 여도 URL 이 저장돼 있으면 보낸다 | 디스코드/슬랙/텔레그램 메시지 |
|
||
| **7일** | `>= notify.escalation_stop_after_days` | **`state/paused.flag` 생성 → 다음 `dmf run` 이 즉시 종료 0.** API 호출을 멈춘다 | `RUN_PAUSED` CRITICAL 모달. [자동 실행 재개] 버튼은 **진단 전부 통과해야 활성화** |
|
||
|
||
### 8.2 왜 7일에 멈추는가
|
||
|
||
| 근거 | 설명 |
|
||
|---|---|
|
||
| **API 예의** | 고장난 상태로 매일 수십~수백 회 호출하면 공공데이터포털 쪽에 무의미한 부하를 준다. 요구 N1(정중한 접근)의 직접 귀결이다 |
|
||
| **쿼터 보호** | 개발계정 일일 10,000회 한도를 실패 재시도로 태우면, 정작 고친 날 쓸 수 없다 |
|
||
| **알림 피로 차단** | 7일째면 사용자는 이미 알림을 무시하고 있다. 조용해지되 **멈췄다는 사실 자체를 알리는 것**이 더 강한 신호다 |
|
||
| **되돌리기 쉬움** | 작업 스케줄러를 지우지 않고 플래그 파일 하나로 멈춘다. 재개는 파일 삭제 한 번이다 |
|
||
|
||
### 8.3 일시중지의 구현
|
||
|
||
```python
|
||
# src/dmf_crawler/pipeline.py 의 stage_preflight 안 (발췌)
|
||
def _check_paused(ctx: RunContext) -> None:
|
||
"""일시중지 플래그가 있으면 즉시 SKIPPED 로 끝낸다.
|
||
|
||
--force 로도 뚫리지 않는다. 사람이 명시적으로 재개해야 한다.
|
||
(--force 는 '오늘 이미 성공했지만 다시 돌린다'는 뜻이지
|
||
'고장난 채로 계속 두드린다'는 뜻이 아니다.)
|
||
"""
|
||
if not paths.PAUSE_FLAG.exists():
|
||
return
|
||
try:
|
||
info = json.loads(paths.PAUSE_FLAG.read_text(encoding="utf-8"))
|
||
except (OSError, json.JSONDecodeError):
|
||
info = {}
|
||
raise PausedError(
|
||
f"연속 실패로 자동 실행이 멈춰 있습니다 "
|
||
f"(멈춘 시각: {info.get('paused_at', '알 수 없음')}, "
|
||
f"사유: {info.get('reason', '알 수 없음')}). "
|
||
f"복구 창의 [자동 실행 재개]를 눌러 다시 켜세요."
|
||
)
|
||
```
|
||
|
||
`PausedError` 는 `run_pipeline` 최상위에서 잡혀 **`status='SKIPPED'`, 종료 코드 0** 으로 처리된다. 종료 코드 1 을 쓰면 Task Scheduler 가 `RestartCount` 재시도를 3번 더 돌려 "멈췄는데 계속 도는" 모순이 생긴다.
|
||
|
||
### 8.4 재개 경로
|
||
|
||
| 경로 | 조건 | 방법 |
|
||
|---|---|---|
|
||
| GUI | 진단 12종 전부 통과 | 복구 창 → [자동 실행 재개] |
|
||
| CLI | 없음(강제) | `dmf run --resume` 또는 `del state\paused.flag` |
|
||
| 자동 | **없다** | 자동 재개는 하지 않는다. 자동으로 멈춘 것을 자동으로 풀면 멈춘 의미가 사라진다 |
|
||
|
||
### 8.5 에스컬레이션 흐름도
|
||
|
||
```
|
||
매일 06:00 실행
|
||
|
|
||
+- SUCCESS -> heartbeat 갱신, 일자성 WARN 자동 해소(R-D1), 카운터 0 으로
|
||
|
|
||
+- FAILED
|
||
|
|
||
v
|
||
Agent(15분 주기) 워치독이 consecutive_failed_days 계산
|
||
|
|
||
+----+-----------------------------------------+
|
||
| 1~2일 : ERROR/CRITICAL 모달 (60분 재촉) |
|
||
| 3일 : CONSECUTIVE_FAILURES + 진단 자동 표시 |
|
||
| 5일 : 웹훅 강제 발사 |
|
||
| 7일 : paused.flag 생성 + RUN_PAUSED |
|
||
+----+-----------------------------------------+
|
||
|
|
||
v
|
||
사람이 원인 해결 -> 진단 통과 -> [자동 실행 재개]
|
||
|
|
||
v
|
||
plag 삭제, 다음 06:00 정상 복귀
|
||
(다음 SUCCESS 에서 R-D1/R-D2 가 모든 알림을 해소)
|
||
```
|
||
|
||
---
|
||
|
||
## 9. 이 문서가 상위 정본에 요구하는 개정 목록
|
||
|
||
문서 사이의 어긋남을 방지하기 위해, 이 문서가 `docs/design/01-architecture.md` 에 대해 만든 개정을 한곳에 모은다. 아키텍처를 갱신할 때 이 표를 그대로 반영하면 된다.
|
||
|
||
| ID | 대상 | 개정 내용 | 근거 |
|
||
|---|---|---|---|
|
||
| **AMD-01** | §3.14 `alerts.py` | `Severity` 를 3값 → **4값**(`INFO/WARN/ERROR/CRITICAL`)으로 확장 | "리포트 미생성"과 "사람 개입 필수"는 채널·재촉 주기·에스컬레이션 카운터가 전부 다르다(§2.1) |
|
||
| **AMD-02** | §2 디렉터리 트리 | `src/dmf_crawler/notify/webhook.py`, `scripts/notify.ps1`, `scripts/register_protocol.ps1` **3개 파일 추가** | 실패 시나리오 #32 에서 tkinter 는 원리적으로 뜰 수 없다. PowerShell 폴백이 유일한 화면 경로다(§6.0) |
|
||
| **AMD-03** | §3.9 불변식 | `alerts` 를 `alert_events`(append-only 사실) + `alerts`(상태 머신)로 **2분할** | 기존 불변식 "alerts 를 UPDATE 하지 않는다"를 깨지 않으면서 dedup·표시·해소 상태를 관리하는 유일한 방법(§2.3) |
|
||
| **AMD-04** | §6.1 설정 키 | `[notify]` 에 15개 키 추가 | 폭주 억제 3층·에스컬레이션 3단·웹훅 채널에 필요(§6.8) |
|
||
| **AMD-05** | §5 서브커맨드 표 | `alert-test` 추가 | 알림 계층은 인위적 유발 수단 없이는 검증 불가(§7.1) |
|
||
| **AMD-06** | §3.16 `pump.py` | `pump_once` 가 매 주기 `state/pump.stamp` 를 갱신하고, Agent 작업에 **두 번째 액션**(`notify.ps1 -Mode Guard`)을 등록 | pump 가 죽었다는 사실 자체를 감지할 주체가 필요하다(§6.1) |
|
||
| **AMD-07** | §5 종료 코드 | `PausedError` → `status='SKIPPED'` + 종료 코드 **0** | 코드 1 이면 Task Scheduler 가 3회 재시도해 "멈췄는데 계속 도는" 모순이 생긴다(§8.3) |
|
||
| **AMD-08** | §7 실패 시나리오 대응표 | 시나리오 #28(배치 미실행)의 알림 코드를 `WATCHDOG_STALE` 로, #30(연속 3일)을 `CONSECUTIVE_FAILURES` 로 명시. 신규 #34 `RUN_PAUSED` 추가 | 코드 이름이 dedup_key 의 일부이므로 표기가 일치해야 한다 |
|
||
|
||
**연구 문서 08 과의 차이** (08 은 리서치, 01-architecture 가 정본이다)
|
||
|
||
| 항목 | 연구 08 | 이 문서(=아키텍처 정본) | 이유 |
|
||
|---|---|---|---|
|
||
| 작업 3종 이름 | Daily / Watchdog / Notify | **Daily / Agent / AgyUpdate** | 워치독을 별도 작업으로 두지 않고 Agent 안에서 판정한다(아키텍처 부록). 대신 주간 `agy update` 작업이 필요해졌다 |
|
||
| 토스트 구현 | BurntToast | **tkinter 자체 창** | ADR-12. 외부 모듈 의존은 순환 실패를 만든다 |
|
||
| 이벤트 ID | 1000 / 1001 | **100~910 (§2.8)** | `eventcreate.exe` 의 `/ID` 는 1~1000 만 허용한다. 1001 은 실행 자체가 실패한다 |
|
||
| dead-man switch | 필수 | **선택(기본 off)** | 외부 서비스 계정이 전제이므로 비개발자 온보딩에서 기본값으로 강제할 수 없다. 온보딩에서 권유만 한다 |
|
||
| 웹훅 | 3단 폴백의 3단 | **병렬 채널** | 화면 표시 성공 여부와 무관하게 CRITICAL 은 발사한다. "화면을 봤다"와 "PC 앞에 있었다"는 다른 사실이다(§1.3) |
|
||
|
||
---
|
||
|
||
## 부록. 미해결 / 실측 필요
|
||
|
||
### A. 실측이 필요한 항목
|
||
|
||
- [ ] **`eventcreate.exe` 의 `/ID` 상한이 정확히 1000 인가.** 문서상 1~1000 이지만 Windows 11 26xxx 빌드에서 재확인 필요. 1001 을 넣었을 때의 정확한 오류 메시지도 기록할 것. (⚠️ 미검증)
|
||
- [ ] **`/SO` 로 지정한 원본 이름이 Application 로그에 자동 등록되는가.** 그룹 정책으로 Application 로그 쓰기가 제한된 환경에서의 동작. 최초 1회 관리자 권한이 필요한지. (⚠️ 미검증)
|
||
- [ ] **`msg.exe` 가 Windows 11 Pro 26220 에 존재하는가.** Home 에디션에는 없는 것으로 알려져 있으나 Pro 실측 필요. `/TIME:` 파라미터의 실제 동작도. (⚠️ 미검증)
|
||
- [ ] **`SHQueryUserNotificationState` 의 Windows 11 반환값.** 특히 값 7(APP, 전체 화면 앱)이 실제로 반환되는지, 그리고 집중 지원(방해 금지) ON 일 때 값 6(QUIET_TIME)이 오는지. 우리 tkinter 창은 QUIET_TIME 에도 떠야 하므로 판정에서 6 을 제외한 것이 옳은지 확인. (⚠️ 미검증)
|
||
- [ ] **`overrideredirect(True)` + `-topmost` 창이 잠금 화면·UAC 프롬프트 위에 뜨는가.** 뜨면 안 된다(보안). 실제로는 뜨지 않을 것으로 보이나 확인 필요. (⚠️ 미검증)
|
||
- [ ] **Task Scheduler 다중 액션이 정말 앞 액션의 종료 코드를 무시하고 순차 실행하는가.** §6.1 폴백 설계의 전제다. `notify.ps1` 이 pump 실패와 무관하게 실행되는지 실측. (⚠️ 미검증)
|
||
- [ ] **`CREATE_NEW_CONSOLE` 로 띄운 `agy` 가 실제로 기본 브라우저 OAuth 를 여는가.** agy SSOT 는 "기본 브라우저로 OAuth 로그인"이라고 적었지만 재로그인(만료 후) 경로도 같은지 실측. 다르다면 §5.2 의 안내 배너 문구를 고쳐야 한다. (⚠️ 미검증)
|
||
- [ ] **`agy` 인증 만료 시 `error` 필드의 정확한 문자열과 종료 코드.** `classify_error` 의 `AUTH` 판정 규칙이 여기에 달려 있다. agy SSOT 부록 B 에도 같은 항목이 미결로 남아 있다. (⚠️ 미검증)
|
||
- [ ] **`agy` 쿼터 소진 시 `error` 문자열.** `QUOTA` 판정 규칙. 위와 동일. (⚠️ 미검증)
|
||
- [ ] **DPAPI 로 저장한 웹훅 URL 을 S4U 세션에서 복호화할 수 있는가.** 연구 08 은 S4U 가 "encrypted files 접근 불가"라고 경고한다. DPAPI 사용자 범위가 여기 해당하는지가 관건이다 — **해당한다면 웹훅을 배치가 아니라 Agent 만 발사할 수 있고, 설계를 바꿔야 한다.** 이 문서에서 가장 위험한 미검증 항목이다. (⚠️ 미검증, 우선순위 최상)
|
||
- [ ] **`file_lock`(msvcrt) 을 pump 와 파이프라인이 서로 다른 파일로 잡을 때 충돌이 없는가.** `run.lock` 과 `pump.lock` 분리가 의도대로 동작하는지. (⚠️ 미검증)
|
||
|
||
### B. 설계 결정을 미룬 항목
|
||
|
||
- [ ] **`GEMINI_API_KEY` 모드로 전환해 agy 로그인 자체를 없앨 것인가.** agy SSOT §4.1 에 따르면 `modelProvider: "gemini"` + 환경변수로 완전 비대화형이 된다. 그러면 `AGY_AUTH` 시나리오 자체가 사라진다. 다만 **과금 체계가 달라지는 결정**이므로 알림 설계가 임의로 정할 수 없다. 사용자 판단 필요.
|
||
- [ ] **액션 센터 잔류가 필요한가.** tkinter 토스트는 놓치면 사라진다. 요구 N3(24시간 내 인지)는 만족하지만, "자리를 비운 사이 WARN 이 지나갔다"를 사용자가 불편해하면 `win11toast` 를 선택 의존성으로 켜는 안을 재검토한다. 그때 §4.4 의 프로토콜 등록이 필요해진다.
|
||
- [ ] **이메일 채널을 정말 안 만들 것인가.** 웹훅이 없는 사용자에게는 PC 밖 채널이 dead-man switch 뿐이다. 필요해지면 `notify/webhook.py` 에 `smtp` kind 를 추가하는 형태로 확장한다(파일 신설 없이).
|
||
- [ ] **알림 이력 화면.** 지금은 `alert_events` 에 쌓기만 하고 사람이 볼 화면이 없다. 리포트 xlsx 의 `s99_meta` 시트에 최근 14일 알림 요약을 넣을지, 복구 GUI 에 탭을 하나 더 둘지 미정. 리포트 명세(`docs/design/03-xlsx-report-spec.md`) 작성 시 결정한다.
|
||
- [ ] **`INTEGRITY_BLOCKED` 의 게이트별 문구 분리.** 지금은 게이트 이름을 `{gate_name}` 으로 치환하는 단일 템플릿이다. 게이트 2/4/5 의 사용자 조치가 실제로 다르다면 코드를 `INTEGRITY_COUNT_MISMATCH` / `INTEGRITY_NULL_RATIO` / `INTEGRITY_DUPLICATE` 로 쪼개야 한다. 실운영 데이터를 보고 결정한다.
|
||
- [ ] **다국어.** `general.language` 키가 있지만 문구는 한국어 상수로 하드코딩돼 있다. 영어가 필요해지면 `TEMPLATES` 를 언어별 딕셔너리로 감싼다. 지금은 필요 없다.
|
||
|
||
### C. 운영 중 재확인할 임계값
|
||
|
||
- [ ] `notify.cooldown_minutes = 240` 이 적절한가. 하루 1회 배치이므로 240분이면 사실상 "하루 1회"다. 너무 조용하면 120 으로 내린다.
|
||
- [ ] `notify.daily_alert_cap = 20` 이 실제로 도달하는 날이 있는가. 도달한다면 병합(L2) 임계값을 낮추는 편이 낫다.
|
||
- [ ] `notify.watchdog_stale_minutes = 120` 이 06:00 + 2시간이라는 전제와 맞는가. `schedule.daily_time` 을 바꾸면 이 값도 함께 조정해야 한다 — **두 값을 연동시킬지, 독립으로 둘지** 정할 것.
|
||
- [ ] `notify.escalation_stop_after_days = 7` 이 너무 늦은가. 실운영에서 3일이면 이미 방치 상태라면 5일로 당긴다.
|
||
- [ ] `notify.toast_seconds = 12` 로 4요소 본문(약 6줄)을 다 읽을 수 있는가. 못 읽으면 15~20 으로 올린다.
|