- 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 문서 지도 갱신
1384 lines
110 KiB
Markdown
1384 lines
110 KiB
Markdown
# DMF Crawler 아키텍처 확정안
|
|
|
|
> **이 문서의 역할**: 후보 3안과 3개 렌즈 심사를 종합해 **구현이 그대로 시작될 수 있는 단일 설계 정본**을 확정한다. 이 문서의 디렉터리 트리를 그대로 만들고 모듈 계약대로 채우면 프로젝트가 완성된다.
|
|
>
|
|
> **상위 문서**: `docs/00-REQUIREMENTS.md`(요구 SSOT), `docs/design/00-DATA-SOURCE-DECISION.md`(데이터 소스 SSOT), `docs/research/05a-agy-cli-ssot.md`(agy SSOT), `docs/research/06-xlsx-linking-and-formatting.md`(xlsx SSOT), `docs/research/07-pharma-excel-dashboard-design.md`(대시보드 SSOT). **충돌 시 이 다섯 문서가 우선한다.**
|
|
|
|
**확정일**: 2026-09-02
|
|
**대상 환경**: Windows 11 Pro 10.0.26220, Python 3.14.6 (실측), PowerShell 7
|
|
**상태**: ✅ 확정 — M0 착수 가능
|
|
|
|
**2026-09-03 정정**: 사용자 요구에 따라 공식 API 단일 소스 전제를 수정한다. 수집 경로는 ① 인증키가 있으면 공식 Open API 우선 ② 인증키가 없거나 `source.mode=browser` 이면 **AGY headful/visible Chrome 으로 의약품안전나라 CCBAC03 xlsx 다운로드**다. 수동 smoke `run_20260903_130332` 에서 visible Chrome + `https://nedrug.mfds.go.kr/pbp/CCBAC03/getExcel` 다운로드, 9,816건 정규화·저장·리포트 생성까지 성공했다. AGY 권한은 `mcp(chrome-devtools/*)`, `execute_url(nedrug.mfds.go.kr)`만 좁게 허용한다. AI 요약용 headless `agy` 호출과 수집용 headful `agy` 호출은 별개다. 아래 ADR 중 ADR-01/02/06/15 의 원문은 이 정정이 우선한다.
|
|
|
|
---
|
|
|
|
## 0. 확정 요약
|
|
|
|
1. **뼈대는 후보 B(SQLite 이벤트 소싱 + 계층 분리)** 다. 3개 렌즈 중 2개(실패 내성 7.5, 진화 7.0)에서 1위이며, 운영 렌즈가 A를 뽑으면서도 "A 채택의 조건"으로 요구한 다섯 개 이식 항목이 전부 B의 핵심이었다.
|
|
2. **A(단일 파이프라인)에서 이식**: idempotency 가드, 서브커맨드 체계(`run`/`backfill`/`report-only`/`doctor`), 설정 파일 **1개**(TOML, stdlib `tomllib`), 의존성 최소주의, `agy` 조건부 호출(변화 없으면 미호출).
|
|
3. **C(플러그인+오케스트레이터)에서 이식**: `Retry-After` 절대 우선 + full jitter 백오프 + 조건부 요청, 3상태 서킷 브레이커(CLOSED/OPEN/HALF_OPEN), 스테이지 체크포인트 재개, Event Log 병행 기록, AI 제안 자동 적용 금지 원칙.
|
|
4. **C에서 이식하지 않은 것**: DAG 위상정렬, 문자열 동적 import 레지스트리, 소스 플러그인 3종 스텁, 설정 5분할. 요구사항이 **단일 공식 API 소스**를 확정했고 해외 소스는 명시적 비목표이므로 소스 추상화는 근거를 잃었다.
|
|
5. **세 후보 모두가 뚫려 있던 구멍을 신규로 메웠다**: HTTP 200 + 빈/오염 응답 판정, diff 안전장치 5종(요구 R2.2), xlsx 원자적 교체, 자동 백업(`VACUUM INTO`), 디스크 여유 점검, 마이그레이션 롤백 경로.
|
|
6. **세 후보 모두가 몰랐던 요구를 반영했다**: 요구 SSOT의 R6(비개발자용 GUI 온보딩 마법사)·R7(강제 모달 복구 창). 배치는 **UI를 절대 띄우지 않고** 알림 의도만 기록하며, 대화형 세션에서 도는 별도 작업이 그것을 화면에 띄운다 — 이것이 S4U 세션에 데스크톱이 없다는 Windows 제약(3개 렌즈가 모두 지적)의 유일한 구조적 해법이다.
|
|
7. **온보딩과 복구는 같은 컴포넌트**다(요구 4절). `checks.py`의 진단 엔진 하나를 CLI(`doctor`)와 GUI(`onboard`)가 각각 렌더링할 뿐이다.
|
|
8. **런타임 의존성은 3개**(`httpx`, `XlsxWriter`, `jsonschema`). `pandas`·`openpyxl`·LibreOffice 재계산·PyYAML은 전부 기각했다(부록 참조).
|
|
9. **`agy`는 두 용도다.** 수집용 `agy` 는 `source.mode=browser` 또는 auto+키 없음에서 headful Chrome 으로 xlsx 를 받는다. 요약용 `agy` 는 diff 이후 부가 계층이며 죽어도 xlsx는 나온다. 스키마 레벨에서 `enrichment` 테이블을 `events`와 분리해 이를 강제한다.
|
|
10. 과잉 설계 억제: 소스 추상화·DAG 엔진·웹 대시보드·해외 소스는 **"향후 확장 지점"으로만 표시**하고 코드를 만들지 않는다.
|
|
|
|
---
|
|
|
|
## 1. 설계 결정 기록(ADR)
|
|
|
|
각 행은 하나의 되돌리기 어려운 결정이다. "대안"은 실제로 검토한 것만 적었다.
|
|
|
|
| # | 결정 | 대안 | 선택 이유 | 트레이드오프 |
|
|
|---|---|---|---|---|
|
|
| **ADR-01** | **데이터 소스는 공식 API 우선 + AGY headful browser 폴백/강제 모드** | 공식 API 단일, nedrug HTML 파싱, 다중 플러그인 | 인증키가 있으면 공식 API가 가장 단순하고 안정적이다. 단, 사용자가 여러 차례 **AGY가 headful Chrome 으로 사람처럼 수집해야 한다**고 확정했으므로, `source.mode=auto`+키 없음 또는 `source.mode=browser` 에서는 AGY가 visible Chrome 으로 `CCBAC03/getExcel` xlsx 를 다운로드한다 | 공식 API 7필드와 nedrug xlsx 필드가 다르므로 sheet XML 검증·헤더 alias 매핑이 필요하다. CCBAC03 xlsx 의 파일명은 generic 하게 `의약품등심사결과공개.xlsx` 일 수 있어 파일명만으로 wrong export 라고 판단하지 않는다. 봇 차단 가능성은 0으로 보장하지 않으며 스텔스/우회 기법은 쓰지 않는다 |
|
|
| **ADR-02** | **무거운 플러그인 registry 는 만들지 않지만 수집 분기는 둔다.** `source_mfds.py`(API) + `agy/browser_collect.py`(headful xlsx) | 후보 B의 `SourceAdapter`, 후보 C의 `sources.yaml` + 문자열 동적 import | 소스는 2개가 됐지만 동적 import registry 는 여전히 과하다. 파이프라인 `stage_fetch` 가 `source.mode` 와 키 존재 여부로 명시 분기한다 | 세 번째 소스가 생기면 그때 `fetch_all` 프로토콜 승격을 검토한다 |
|
|
| **ADR-03** | **저장소는 SQLite 단일 파일 + 이벤트 소싱.** 일자별 전량 스냅샷과 이벤트를 **append-only**로 영구 축적 | CSV/Parquet 파일, 최신 상태만 덮어쓰는 단순 테이블, PostgreSQL | diff의 정확성은 "직전 상태와의 완전한 비교"를 요구하고, RA 실무는 몇 달 전 상태 소급 조회를 요구한다. 트랜잭션·인덱스·부분쓰기 방어가 파일 하나로 해결된다 | 파일 하나가 단일 장애점이다 → ADR-22(자동 백업)로 상쇄. DB가 연 단위로 커진다 → 원문 아카이브에 보존 기간을 둔다 |
|
|
| **ADR-04** | **스키마 진화는 번호순 SQL 마이그레이션 러너 + `schema_version` 테이블.** 적용 직전 `VACUUM INTO`로 백업본을 뜬다 | alembic, 수작업 `ALTER`(후보 A·C), 마이그레이션 없음 | 앞으로 올 변경 다수가 스키마 변경이다(진화 렌즈). alembic은 ORM 없는 프로젝트에 과하다. **백업본이 곧 롤백 경로**여서 세 후보가 공통으로 빠뜨린 "롤백 없음" 결함이 수십 줄로 해소된다 | 다운 마이그레이션 SQL은 쓰지 않는다. 되돌리기 = 백업본 복원 + 코드 되돌리기. 1인 운영에는 충분하다 |
|
|
| **ADR-05** | **HTTP 클라이언트는 `httpx`** (동기 `Client`) | `requests`, stdlib `urllib.request`, `aiohttp`, Scrapy | 데이터 소스 SSOT §9가 `params=` 자동 인코딩을 전제로 **Decoding 키 사용**을 확정했다(인코딩 키 혼동은 이 API의 1위 실패 원인). `httpx`는 타임아웃을 연결/읽기로 분리해 지정할 수 있어 배치에 유리하다 | 의존성 1개 추가. `requests`와 실질 차이는 작지만 SSOT가 이미 이 전제로 쓰여 있어 일관성을 택했다 |
|
|
| **ADR-06** | **Playwright/Selenium 은 쓰지 않고, 브라우저가 필요할 때는 AGY + chrome-devtools MCP 로 headful Chrome 만 쓴다.** | Playwright 스텔스, undetected-chromedriver, headless 브라우저 | 사용자가 headful Chrome 수집을 요구했다. 파이썬 의존성은 늘리지 않고 AGY가 이미 가진 chrome-devtools MCP 를 이용한다. curl/headless/스텔스 우회는 금지한다 | AGY/Chrome 상태와 권한(`mcp(chrome-devtools/*)`, `execute_url(nedrug.mfds.go.kr)`)에 의존하므로 실패 시 마지막 성공 자료로 리포트를 만든다 |
|
|
| **ADR-07** | **xlsx 쓰기는 `XlsxWriter` 100%, 매일 새 파일 통째 생성** | `openpyxl` 갱신, `pandas.ExcelWriter(mode='a')`, 템플릿 편집 | xlsx SSOT §3.2 결론 그대로다. 스파크라인 API는 XlsxWriter에만 있고(요구 R3.4), `openpyxl`은 `load_workbook`→`save`에서 차트·이미지·도형을 잃는다. 매일 새 파일이면 손실 문제 자체가 소멸하고 날짜별 증빙이 남는다 | 기존 파일 편집 불가. `report-only`가 항상 전체 재생성이지만, DB가 정본이므로 비용이 아니라 이점이다 |
|
|
| **ADR-08** | **리포트의 모든 숫자는 파이썬이 계산해 값으로 쓴다. LibreOffice headless 재계산 단계를 두지 않는다** | LibreOffice 재계산 파이프라인(후보 A), 수식 자유 사용 | xlsx SSOT §0·§6: XlsxWriter는 수식 결과에 `0`을 쓰고 재계산 플래그만 세운다. 재계산을 옵션으로 두면 미설치 시 **조용히 스킵**되어 수식 캐시가 빈 리포트가 몇 주 나간다(운영 렌즈가 A의 치명 결함으로 지목) | 사용자가 필터를 바꿔도 살아 움직여야 하는 셀은 `write_formula(..., value=사전계산값)`으로 캐시값을 박아 넣는다. 동적 배열 함수(FILTER/UNIQUE/XLOOKUP)는 쓰지 않는다 |
|
|
| **ADR-09** | **집계는 SQLite SQL로 한다. `pandas`를 쓰지 않는다** | `pandas` 기반 집계(후보 B·C가 전제) | 데이터가 이미 SQLite에 있고 필요한 집계는 `GROUP BY` 수준이다. 요구 N5(의존성 최소)와 부팅 시간·설치 실패 표면을 고려하면 정당화되지 않는다 | 복잡한 피벗 형태 집계는 SQL이 장황하다. 시트별 조회는 `report/data.py`에 SQL을 모아 상수로 관리한다 |
|
|
| **ADR-10** | **스케줄러는 Windows Task Scheduler, 작업 3개.** 배치는 **S4U(비대화형)**, UI를 띄우는 에이전트는 **Interactive(로그온 세션)** 로 분리 | 단일 작업(후보 A·C), Windows Service, WSL cron | `agy` OAuth 토큰이 사용자 프로필 평문 파일이므로 SYSTEM 금지·동일 사용자 계정 필수다(agy SSOT §4.2). 그런데 S4U 세션에는 데스크톱이 없어 토스트·모달이 뜨지 않는다. **알림 발생과 알림 표시를 분리**하는 것이 요구 R7.2(강제 창)를 만족하는 유일한 구조다 | 사용자가 로그오프 상태로 방치하면 화면 알림이 지연된다. Event Log·`state/alerts.json`에 흔적을 남기고 다음 로그온 시 표시한다(요구 4절 각주와 동일한 결론) |
|
|
| **ADR-11** | **배치 프로세스는 어떤 UI도 띄우지 않는다.** `alerts` 테이블 + `state/alerts.json`에 알림 **의도**만 기록한다 | 배치가 직접 BurntToast/모달 호출(후보 A) | ADR-10의 귀결. 후보 A는 `delivered=True`를 기록하면서 화면에는 아무것도 뜨지 않는 조용한 실패를 만든다(실패 내성 렌즈 최대 감점 항목) | 알림 지연이 최대 15분(에이전트 반복 주기)이다. 요구 N3(24시간 내 인지) 대비 충분한 여유다 |
|
|
| **ADR-12** | **GUI 툴킷은 stdlib `tkinter`.** 온보딩·복구·토스트를 전부 여기서 그린다 | PySide6/PyQt, WinForms(pythonnet), 웹 UI(FastAPI+브라우저), BurntToast(PowerShell 모듈) | 요구 N5(의존성 최소)와 R6.2(콘솔 창이 보이면 안 됨)를 동시에 만족한다. `pythonw.exe`로 실행하면 콘솔이 없다. BurntToast는 외부 PowerShell 모듈 설치가 전제여서 "설치가 안 되면 알림도 안 됨"이라는 순환 실패를 만든다 | 진짜 Windows 토스트(액션 센터 잔류)가 아니라 우상단 자동 소멸 창이다. 액션 센터 이력이 필요하면 `win11toast`를 선택 의존성으로 켤 수 있는 자리만 남긴다(구현하지 않음) |
|
|
| **ADR-13** | **API 키는 Windows DPAPI(`CryptProtectData`, 사용자 범위)로 암호화해 `%LOCALAPPDATA%`에 저장** | 환경변수(후보 A), 평문 파일 `secrets/*.txt`(후보 B), `.env` | 요구 R6.3의 사용자는 환경변수가 뭔지 모른다. GUI에서 입력받아 즉시 암호화 저장해야 한다. DPAPI는 `ctypes`만으로 호출 가능해 의존성이 0이고, 같은 사용자 계정에서만 복호화된다 — S4U 배치가 정확히 그 계정이다 | 다른 PC로 폴더를 복사하면 키가 복호화되지 않는다. 요구 N6(이식성)은 "폴더 복사 + 온보딩 재실행"으로 정의돼 있으므로 오히려 요구와 일치한다 |
|
|
| **ADR-14** | **설정은 `config/config.toml` 단 하나.** stdlib `tomllib`로 읽는다 | YAML 다중 파일(후보 B 4개, 후보 C 5개), JSON, INI | 운영 렌즈가 C를 5.5로 깎은 첫 번째 이유가 설정 6분할이다. 06:05에 "어느 파일을 열지"부터 정해야 하는 시스템은 5분 안에 안 고쳐진다. PyYAML 의존성도 사라진다 | 파일 하나가 길어진다(약 90줄). 섹션 주석으로 가독성을 보완한다 |
|
|
| **ADR-15** | **`agy`는 수집용(headful)과 요약용(headless)을 분리한다.** 요약용 agy 는 diff·저장 뒤 부가 계층이고, 수집용 agy 는 `stage_fetch` 의 browser 경로다 | agy를 전부 부가 계층으로만 둠, agy로 셀렉터 자가복구 | 사용자 요구상 headful 수집 경로가 필요하다. 다만 요구 R4.5("AGY/AI가 실패해도 리포트는 생성")는 유지해 browser 수집 실패도 스테일 폴백으로 처리한다. `enrichment` 테이블 분리는 요약용 산출물에만 적용된다 | agy/Chrome 문제로 최신 수집이 실패할 수 있다. 이 경우 마지막 성공 자료로 리포트를 만들고 WARN 알림을 남긴다 |
|
|
| **ADR-16** | **`agy` 호출은 하루 최대 1회, diff가 비어 있지 않을 때만.** 모든 질의(브리핑+이상해석+정규화 후보)를 한 프롬프트에 묶는다 | 역할별 다중 호출(A1~A7 각각), 매 실행 헬스체크 | agy SSOT §0·§4.3 실측: 첫 호출 오버헤드가 input 28,317 토큰 / 33.7초다. 변화 없는 날 이 비용을 태우면 쿼터 소진 시점만 앞당긴다. 인증 상태는 별도 헬스체크가 아니라 **실제 작업 호출의 결과로 판정**한다 | 프롬프트가 길어지고 한 번 실패하면 모든 AI 산출물이 함께 빠진다. graceful degradation이 이미 전제이므로 수용한다 |
|
|
| **ADR-17** | **`--json-schema`의 `structured_output`을 신뢰하지 않는다.** 자체 균형괄호 추출 + `jsonschema` 검증 + 1회 강화 재시도 | `--json-schema` 단독 신뢰 | agy SSOT §7.2 실측: `structured_output`이 아예 없었고 `response`에 JSON 조각이 4회 반복 + 한국어 산문과 혼입됐으며 토큰이 3배로 뛰었다 | 코드 약 60줄과 테스트 픽스처가 늘어난다. SSOT가 실측으로 증명한 함정을 프로덕션에 노출시키는 것보다 싸다 |
|
|
| **ADR-18** | **오케스트레이션은 `pipeline.py`의 순차 `STAGES` 리스트 + 스테이지 체크포인트.** DAG 위상정렬을 쓰지 않는다 | TaskGraph 위상정렬(후보 C), Airflow/Prefect, 오케스트레이션 없음(후보 A) | 7단계 선형 흐름에 병렬성도 동적 브랜칭도 없다. 그러나 "스테이지 추가 = 모듈 하나 + 리스트 한 줄"이라는 확장 문법과, `RestartCount` 재시도 시 성공한 스테이지를 건너뛰는 체크포인트는 실제 가치가 있다(진화·실패 내성 렌즈) | 스테이지 순서가 코드에 있어 배포 없이 못 바꾼다. 1인 운영에서 순서 변경은 코드 변경과 같은 사건이므로 문제가 아니다 |
|
|
| **ADR-19** | **로깅은 stdlib `logging`.** 실행 1회당 `logs/run_<run_id>/` 디렉터리에 `pipeline.log`(사람용) + `events.jsonl`(기계용) + `agy.stdout.json` / `agy.stderr.log` 분리 | `structlog`, 단일 롤링 파일, print | agy SSOT §5.3 규약이 stdout/stderr 혼합을 금지한다. 실행 단위 디렉터리는 "가장 최근 디렉터리를 열면 끝"이라는 조사 시작점을 만든다(운영 렌즈가 A의 강점으로 꼽은 항목) | 디렉터리 수가 늘어난다. `logging.retain_days`(기본 90)로 정리한다 |
|
|
| **ADR-20** | **패키지 관리는 `pyproject.toml` + `venv` + `pip`.** 프로젝트 로컬 `.venv` | poetry, uv, conda, 시스템 파이썬 직접 사용 | 비개발자 PC에 추가 도구를 설치시키지 않는다. `bootstrap.cmd`가 `python -m venv .venv` 후 `pip install -e .` 한 줄로 끝난다 | 잠금 파일(lock)이 없다. `pyproject.toml`에 상한 버전을 명시해 대체한다 |
|
|
| **ADR-21** | **테스트는 `pytest`. 실제 API 응답을 고정 픽스처로 박아 넣는 골든 테스트가 중심** | 테스트 없음, tox/nox 매트릭스, 라이브 API 통합 테스트 | diff 오탐(요구 R2.2)과 agy 응답 오염 파싱(ADR-17)이 이 프로젝트의 두 급소다. 둘 다 픽스처만 있으면 오프라인에서 결정론적으로 검증된다 | 커버리지 목표를 세우지 않는다. 급소 4모듈(`normalize`/`integrity`/`diff`/`agy.extract`)만 촘촘히 덮는다 |
|
|
| **ADR-22** | **파이프라인 성공 직후 `VACUUM INTO`로 날짜별 백업본을 다른 경로에 생성.** 보존 개수 제한 | README 안내에만 의존(후보 A), 설계 밖으로 미룸(후보 B) | 세 후보가 공통으로 빠뜨렸고 세 렌즈가 모두 지적했다. 1인 운영에서 문서로 미룬 절차는 6개월 안에 잊힌다. `VACUUM INTO`는 SQLite 내장이고 일관된 스냅샷을 보장한다 | 백업 경로가 같은 디스크면 디스크 장애를 못 막는다. `config`의 `backup.dir`을 다른 드라이브/OneDrive 경로로 지정하도록 온보딩에서 유도한다 |
|
|
| **ADR-23** | **중복 실행 방어는 3중**: ① 코드의 idempotency 가드(오늘 SUCCESS면 즉시 종료 0) ② 파일 락(`msvcrt.locking`) ③ Task Scheduler `MultipleInstances=IgnoreNew` | 스케줄러 설정에만 의존(후보 B·C) | `IgnoreNew`는 동시 실행만 막지 06:00 실행 후 재부팅 캐치업 같은 **순차 재실행**을 못 막는다. append-only 스키마는 하필 여기에 가장 취약하다(실패 내성 렌즈가 B의 최대 구멍으로 지목) | 의도적 재실행에는 `--force`가 필요하다. `report-only`는 가드 대상이 아니다 |
|
|
| **ADR-24** | **온보딩(R6)과 오류 복구(R7)는 `checks.py` 하나를 공유하는 같은 컴포넌트.** CLI `doctor`와 GUI `onboard`는 렌더러일 뿐 | 온보딩 마법사와 복구 창을 별도 구현 | 요구 4절이 명시한 원칙이다. 진단 로직이 한 곳에 있어야 온보딩과 복구가 어긋나지 않고, 새 전제조건이 생겨도 한 곳만 고친다 | GUI와 CLI가 같은 체크 목록에 묶여 유연성이 준다. 그것이 의도다 |
|
|
| **ADR-25** | **AI가 제안한 어떤 것도 자동 적용하지 않는다.** 스키마 변경 대응(A7)·정규화 후보(A2)는 `state/proposals/`에 저장하고 사람이 승인 | 자동 반영 | 후보 C의 유일한 성숙한 안전 원칙이자 데이터 소스 SSOT §6(A7)의 명시 규칙이다. 틀린 제안이 조용히 잘못된 데이터를 수집하는 2차 사고를 원천 차단한다 | 사람이 개입할 때까지 대응이 지연된다. 그 지연이 오탐 데이터가 영구 기록되는 것보다 싸다 |
|
|
|
|
---
|
|
|
|
## 2. 전체 디렉터리 구조
|
|
|
|
이 트리를 그대로 만들면 프로젝트가 시작된다. 런타임 생성 경로는 `(런타임)`으로 표시한다.
|
|
|
|
```
|
|
D:\workspace\DMF_Crawler\
|
|
├── bootstrap.cmd # 최초 설치 진입점. venv 생성→pip install→바탕화면 바로가기 생성→온보딩 GUI 기동
|
|
├── pyproject.toml # 패키지 메타·의존성 3개 상한 고정·엔트리포인트·pytest 설정
|
|
├── README.md # 설치·최초 인증·수동 실행·장애 대응 5분 가이드
|
|
├── .gitignore # .venv/ data/ logs/ reports/ state/ backup/ config.local.toml *oauth-token*
|
|
│
|
|
├── config\
|
|
│ ├── config.toml # 유일한 설정 정본 (6절 전체 키 스펙)
|
|
│ └── config.local.toml.example # PC별 오버라이드 예시(경로·백업 드라이브). 실제 파일은 gitignore
|
|
│
|
|
├── prompts\
|
|
│ ├── daily_briefing.md # agy 단일 호출 프롬프트 템플릿. "JSON 객체 하나만 출력" 강제 문구 포함
|
|
│ └── daily_briefing.schema.json # 자체 jsonschema 검증 스키마(--json-schema는 보조 수단)
|
|
│
|
|
├── scripts\
|
|
│ ├── install_tasks.ps1 # Task Scheduler 작업 3종 idempotent 등록(Daily/Agent/AgyUpdate)
|
|
│ ├── uninstall_tasks.ps1 # 작업 3종 제거
|
|
│ ├── make_shortcuts.ps1 # "DMF 설정.lnk" / "지금 실행.lnk" 생성(pythonw 대상, 콘솔 창 없음)
|
|
│ └── bootstrap_agy.ps1 # agy 존재 확인 → 미설치 시 install.ps1 무인 실행 → 버전 출력
|
|
│
|
|
├── docs\
|
|
│ ├── 00-REQUIREMENTS.md # 요구사항 SSOT (기존)
|
|
│ ├── README.md # 문서 지도 (기존)
|
|
│ ├── design\
|
|
│ │ ├── 00-DATA-SOURCE-DECISION.md # 데이터 소스 SSOT (기존)
|
|
│ │ ├── 01-architecture.md # 이 문서 — 아키텍처 확정안
|
|
│ │ ├── 02-data-model.md # 스키마 DDL·정규화 규칙·비교 필드 확정 (M1에서 작성)
|
|
│ │ ├── 03-xlsx-report-spec.md # 시트 8종 셀 단위 명세 (M3에서 작성)
|
|
│ │ └── 04-onboarding-wizard.md # GUI 화면 전이·문구 명세 (M4에서 작성)
|
|
│ ├── ops\
|
|
│ │ ├── 01-scheduling-and-resilience.md # 작업 등록 파라미터·재부팅 시나리오 검증 절차 (M2)
|
|
│ │ ├── 02-failure-alerting.md # 알림 등급·문구 4요소 규격·쿨다운 (M4)
|
|
│ │ └── 03-api-usage-policy.md # 호출 빈도·출처 표시·이용약관 준수 기록 (M1)
|
|
│ └── research\ # 리서치 정본 (기존, 01/03/04/05/05a/06/07)
|
|
│
|
|
├── src\
|
|
│ └── dmf_crawler\
|
|
│ ├── __init__.py # __version__ 상수만
|
|
│ ├── __main__.py # `python -m dmf_crawler` 진입. cli.main(sys.argv[1:]) 위임
|
|
│ ├── cli.py # argparse 서브커맨드 정의 → 각 커맨드 함수 매핑. 로직 없음
|
|
│ ├── paths.py # 프로젝트 루트·data·logs·reports·state·backup 경로 상수 계산
|
|
│ ├── config.py # config.toml + config.local.toml 병합 → 불변 Config 데이터클래스, 검증
|
|
│ ├── errors.py # 예외 계층: DmfError → ConfigError/FetchError/IntegrityError/StorageError/ReportError/AgyError
|
|
│ ├── logging_setup.py # run_id별 로그 디렉터리·핸들러(pipeline.log + events.jsonl) 구성
|
|
│ ├── runlock.py # msvcrt 기반 배타 파일 락. 컨텍스트 매니저
|
|
│ ├── secrets_dpapi.py # ctypes로 CryptProtectData/CryptUnprotectData 호출. 키 저장·읽기·삭제
|
|
│ ├── pipeline.py # STAGES 순차 실행기. 체크포인트 조회/기록, 스테이지 예외 흡수
|
|
│ ├── http.py # httpx 얇은 래퍼. Retry-After 우선·full jitter 백오프·조건부 요청·UA 고정
|
|
│ ├── source_mfds.py # 공식 API 전량 수집. totalCount 확인·페이지네이션·원문 아카이브 저장
|
|
│ ├── normalize.py # 원본 7필드 → DmfRecord 표준화. dmf_key·content_hash 산출
|
|
│ ├── integrity.py # 요구 R2.2 안전장치 5종 판정. 통과 못하면 diff 차단
|
|
│ ├── diff.py # 순수 함수. 전일 스냅샷 vs 금일 → NEW/CHANGED/WITHDRAWN
|
|
│ ├── health.py # 3상태 서킷 브레이커(CLOSED/OPEN/HALF_OPEN). 소스·agy 공용
|
|
│ ├── checks.py # 진단 엔진. Check 12종 정의·실행. doctor CLI와 GUI가 공유
|
|
│ ├── backup.py # VACUUM INTO 날짜별 백업·보존 개수 정리·복원 안내 생성
|
|
│ ├── watchdog.py # heartbeat 신선도 판정. 미갱신 시 alerts에 CRITICAL 기록
|
|
│ ├── alerts.py # 알림 의도 기록/조회/중복 억제(dedup_key + 쿨다운)/해소 표시
|
|
│ │
|
|
│ ├── storage\
|
|
│ │ ├── __init__.py
|
|
│ │ ├── db.py # 연결 생성(WAL·foreign_keys=ON·busy_timeout)·마이그레이션 러너
|
|
│ │ ├── repo.py # 유일한 읽기/쓰기 경로. run/snapshot/event/enrichment/agy_call/health/alert
|
|
│ │ └── migrations\
|
|
│ │ ├── 0001_init.sql # runs/stage_status/fetch_stats/snapshots/records/events 최초 스키마
|
|
│ │ ├── 0002_enrichment.sql # enrichment/enrichment_run/agy_calls (AI 계층 분리)
|
|
│ │ └── 0003_ops.sql # component_health/alerts/watchlist/schema_version 인덱스
|
|
│ │
|
|
│ ├── agy\
|
|
│ │ ├── __init__.py
|
|
│ │ ├── client.py # agy.exe subprocess 실행. 예외 대신 AgyEnvelope 값으로 반환
|
|
│ │ ├── prompt.py # diff+지표 → daily_briefing.md 템플릿 렌더링. 데이터 구분자 감싸기
|
|
│ │ ├── extract.py # response에서 균형괄호 JSON 추출 + jsonschema 검증 (SSOT §7.3 포팅)
|
|
│ │ └── budget.py # 일일 토큰 상한 강제. agy_calls 누적 조회·초과 판정
|
|
│ │
|
|
│ ├── report\
|
|
│ │ ├── __init__.py # build_report(run_id) 단일 공개 함수
|
|
│ │ ├── data.py # DB → ReportData 조회 전담. 시트별 SQL 상수 모음
|
|
│ │ ├── build.py # 워크북 조립 오케스트레이션. 시트 생성 순서·저장 호출
|
|
│ │ ├── theme.py # Okabe-Ito 팔레트·맑은 고딕·상태 매핑·Format 객체 캐시
|
|
│ │ ├── widgets.py # KPI 타일·델타 화살표 서식·목차 링크·한글 열너비 계산 유틸
|
|
│ │ ├── atomic.py # 임시파일 → os.replace 원자 교체. 잠김 시 재시도·폴백 파일명
|
|
│ │ └── sheets\
|
|
│ │ ├── __init__.py # SHEET_BUILDERS 순서 리스트
|
|
│ │ ├── s00_dashboard.py # 대시보드: KPI 6타일·차트 2종·스파크라인·각 시트 링크·상태 배너
|
|
│ │ ├── s01_changes.py # 오늘 변경분: 신규/변경/취하 통합 표. 상태 3중 코딩·행 강조
|
|
│ │ ├── s02_ledger.py # 전체 누적 원장. Excel 표·자동필터·틀고정·nedrug 검색 링크
|
|
│ │ ├── s03_ingredient.py # 성분별 집계 Top N + 추이
|
|
│ │ ├── s04_company.py # 업체·제조국별 집계 Top N
|
|
│ │ ├── s05_watchlist.py # 워치리스트 히트. 목록이 비면 시트 자체를 만들지 않음
|
|
│ │ ├── s06_trend.py # 일자별 건수 시계열(스파크라인·차트 원본)
|
|
│ │ └── s99_meta.py # 수집 시각·소스 URL·건수·해시·실행 로그 경로·AI 상태
|
|
│ │
|
|
│ ├── notify\
|
|
│ │ ├── __init__.py
|
|
│ │ ├── pump.py # 대화형 에이전트 본체. 워치독 판정 → 알림 표시 → 복구 GUI 기동
|
|
│ │ ├── toast.py # tkinter 우상단 자동소멸 알림 창(WARN/INFO용)
|
|
│ │ ├── eventlog.py # eventcreate.exe로 Windows 이벤트 로그 병행 기록
|
|
│ │ └── messages.py # 알림 문구 4요소(무엇/왜/어떻게/다음행동) 템플릿 렌더러
|
|
│ │
|
|
│ └── gui\
|
|
│ ├── __init__.py
|
|
│ ├── app.py # 온보딩=복구 단일 창. checks.py 결과를 체크리스트로 렌더
|
|
│ ├── steps.py # 단계별 액션: 키 입력·발급페이지 열기·agy 설치·재로그인·작업 등록
|
|
│ └── widgets.py # 공통 위젯: 상태 뱃지·진행률 바·스크롤 프레임·복사 가능 로그 영역
|
|
│
|
|
├── tests\
|
|
│ ├── conftest.py # 임시 DB·설정 픽스처. 네트워크 차단 픽스처
|
|
│ ├── fixtures\
|
|
│ │ ├── api_page_ok.json # 정상 API 응답(실제 응답 저장본)
|
|
│ │ ├── api_page_empty.json # resultCode 00 + items 0건 (200-빈응답 케이스)
|
|
│ │ ├── api_error_invalid_key.xml # 인증키 무효 응답 원문
|
|
│ │ ├── api_html_block.html # 200인데 본문이 HTML 안내 페이지인 케이스
|
|
│ │ ├── agy_envelope_clean.json # agy 정상 봉투
|
|
│ │ ├── agy_envelope_polluted.json # SSOT §7.2 실측 오염 사례(JSON 4회 반복+산문 혼입)
|
|
│ │ └── snapshot_prev.json # diff 비교용 전일 스냅샷
|
|
│ ├── test_normalize.py # dmf_key 안정성·중복 등록번호 처리·국가 다중값 분해
|
|
│ ├── test_integrity.py # 안전장치 5종 각각의 통과/차단 경계
|
|
│ ├── test_diff.py # NEW/CHANGED/WITHDRAWN 판정·필드별 변경 추출
|
|
│ ├── test_agy_extract.py # 오염 응답에서의 JSON 추출·스키마 검증 실패 경로
|
|
│ ├── test_http_retry.py # Retry-After 우선·지터 범위·조건부 요청 304 처리
|
|
│ ├── test_report_atomic.py # 잠긴 파일 시 폴백 파일명 생성·원자 교체
|
|
│ ├── test_repo_idempotency.py # 같은 run_id 재삽입 시 append-only 무결성
|
|
│ └── test_checks.py # 진단 12종의 통과/실패 판정
|
|
│
|
|
├── data\ # (런타임) git 미추적
|
|
│ ├── dmf.sqlite3 # 정본 저장소
|
|
│ └── raw\YYYY-MM-DD\ # API 원문 응답 페이지별 보존(회귀 픽스처·재파싱 원자료)
|
|
│
|
|
├── state\ # (런타임) 잠금 없이 읽는 가벼운 상태 파일
|
|
│ ├── heartbeat.json # 마지막 성공 실행 시각·run_id·리포트 경로 (워치독 전용)
|
|
│ ├── alerts.json # 미해소 알림 미러 (에이전트가 SQLite 잠금 없이 읽음)
|
|
│ ├── run.lock # 배타 실행 락 파일
|
|
│ └── proposals\ # AI 제안 보관소(자동 적용 금지, 사람 승인 대기)
|
|
│
|
|
├── reports\ # (런타임)
|
|
│ ├── DMF_리포트_YYYY-MM-DD.xlsx # 일자별 산출물
|
|
│ └── DMF_리포트_최신.xlsx # 최신본 고정 링크(원자적 복사)
|
|
│
|
|
├── backup\ # (런타임, 다른 드라이브 권장)
|
|
│ └── dmf_YYYY-MM-DD.sqlite3 # VACUUM INTO 백업본
|
|
│
|
|
└── logs\ # (런타임)
|
|
└── run_YYYYMMDD_HHMMSS\
|
|
├── pipeline.log # 사람이 읽는 전체 로그
|
|
├── events.jsonl # 기계가 읽는 구조화 이벤트
|
|
├── agy.stdout.json # agy JSON 봉투 원문(감사용)
|
|
├── agy.stderr.log # agy 진단 출력(stdout과 절대 섞지 않음)
|
|
└── agy_cli.log # --log-file로 지정한 agy 내부 로그
|
|
```
|
|
|
|
**트리에 없는 것과 그 이유**
|
|
|
|
| 없는 것 | 이유 |
|
|
|---|---|
|
|
| `sources/` 디렉터리와 플러그인 스텁 | ADR-02. 소스가 1개이고 확장은 비목표 |
|
|
| `config/*.yaml` 5분할, `permissions.agy.json` | ADR-14. agy 권한은 `~/.gemini/antigravity-cli/settings.json`에 온보딩이 병합 배포한다(별도 파일 두면 병합 스텝이 조용히 실패한다는 운영 렌즈 지적) |
|
|
| `orchestrator/graph.py`, `checkpoint.py` 별도 모듈 | ADR-18. `pipeline.py` 하나에 스테이지 실행과 체크포인트가 함께 있다 |
|
|
| `normalize/dictionaries/company_aliases.csv` | 수동 큐레이션은 영구 사람 부채다(운영 렌즈). 규칙 기반 정규화 + agy 제안(승인제, ADR-25)으로 대체 |
|
|
| `report/recalc.py` | ADR-08 |
|
|
| 웹 대시보드, FastAPI | 요구 3절 비목표 |
|
|
|
|
---
|
|
|
|
## 3. 모듈별 계약
|
|
|
|
모든 시그니처는 파이썬 3.11+ 타입 힌트 기준이다. 데이터클래스는 전부 `@dataclass(frozen=True, slots=True)`다.
|
|
|
|
### 3.1 `config.py`
|
|
|
|
- **책임**: `config.toml` + (있으면) `config.local.toml`을 병합해 불변 `Config`를 만들고, 실행 전에 필수 키·경로·타입을 검증한다. **비밀 값은 담지 않는다**(경로와 키 이름만).
|
|
- **의존**: `paths`, `errors`
|
|
- **공개 API**
|
|
|
|
```python
|
|
def load_config(path: Path | None = None) -> Config: ...
|
|
def validate(cfg: Config) -> list[str]: # 사람이 읽는 문제 목록. 빈 리스트면 정상
|
|
...
|
|
@dataclass(frozen=True, slots=True)
|
|
class Config:
|
|
general: GeneralCfg
|
|
schedule: ScheduleCfg
|
|
source: SourceCfg
|
|
integrity: IntegrityCfg
|
|
storage: StorageCfg
|
|
backup: BackupCfg
|
|
report: ReportCfg
|
|
agy: AgyCfg
|
|
notify: NotifyCfg
|
|
logging: LoggingCfg
|
|
```
|
|
|
|
- **입력**: TOML 파일 경로. **출력**: `Config`
|
|
- **실패 시**: 파일 없음·파싱 실패·타입 불일치 → `ConfigError`. 파이프라인은 시작하지 않고 종료 코드 2 + CRITICAL 알림 기록.
|
|
|
|
### 3.2 `secrets_dpapi.py`
|
|
|
|
- **책임**: 공공데이터포털 serviceKey를 사용자 범위 DPAPI로 암·복호화한다. **평문은 메모리에만 존재하고 로그·예외 메시지에 절대 넣지 않는다**(요구 N7).
|
|
- **의존**: `ctypes`(stdlib), `paths`
|
|
- **공개 API**
|
|
|
|
```python
|
|
def save_service_key(plaintext: str) -> Path: ... # %LOCALAPPDATA%\DMF_Crawler\service_key.bin
|
|
def load_service_key() -> str | None: ... # 없거나 복호화 실패 시 None
|
|
def delete_service_key() -> None: ...
|
|
def key_fingerprint() -> str | None: ... # sha256 앞 8자. 로그·GUI 표시용(원문 아님)
|
|
```
|
|
|
|
- **실패 시**: 복호화 실패는 예외 없이 `None`. 호출부(`checks.py`)가 "키 미설정"과 동일하게 취급해 GUI 복구 경로로 보낸다.
|
|
|
|
### 3.3 `http.py`
|
|
|
|
- **책임**: 이 프로젝트의 **유일한 네트워크 창구**. 타임아웃·재시도·정중함 정책을 강제한다.
|
|
- **의존**: `httpx`, `config`, `errors`
|
|
- **공개 API**
|
|
|
|
```python
|
|
@dataclass(frozen=True, slots=True)
|
|
class HttpResult:
|
|
status_code: int
|
|
text: str
|
|
headers: dict[str, str]
|
|
elapsed_seconds: float
|
|
from_cache: bool # 304 Not Modified 였는가
|
|
|
|
class HttpClient:
|
|
def __init__(self, cfg: SourceCfg, logger: logging.Logger) -> None: ...
|
|
def get(self, url: str, params: dict[str, str | int],
|
|
etag: str | None = None,
|
|
last_modified: str | None = None) -> HttpResult: ...
|
|
def close(self) -> None: ...
|
|
```
|
|
|
|
- **정책(고정)**: 최대 4회 시도, base 5s · factor 2 · cap 300s · **full jitter**. `429`/`503`의 `Retry-After`가 있으면 **절대 우선**(계산된 백오프를 무시). `If-None-Match`/`If-Modified-Since` 조건부 요청. UA는 `DMF-Crawler/<version> (+contact: <config의 연락처>)` 고정. 요청 간 최소 간격 `min_interval_seconds`(기본 0.7s) + 0~0.3s 지터.
|
|
- **실패 시**: 재시도 소진 → `FetchError(reason=...)`. 예외에 URL은 담되 `serviceKey`는 **마스킹**한다.
|
|
|
|
### 3.4 `source_mfds.py`
|
|
|
|
- **책임**: 공식 API에서 DMF 현황 **전량**을 받아오고, 원문을 그대로 아카이브하며, 수집 통계를 남긴다.
|
|
- **의존**: `http`, `secrets_dpapi`, `config`, `paths`, `errors`
|
|
- **공개 API**
|
|
|
|
```python
|
|
@dataclass(frozen=True, slots=True)
|
|
class RawRecord:
|
|
fields: dict[str, str] # 원본 필드명 그대로 (DMF_PERMIT_NO 등 7개)
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class FetchResult:
|
|
records: list[RawRecord]
|
|
total_count_reported: int # 응답의 totalCount
|
|
pages_fetched: int
|
|
http_calls: int
|
|
elapsed_seconds: float
|
|
archive_dir: Path
|
|
body_signature_ok: bool # 200인데 빈/차단 본문이 아닌가
|
|
|
|
def fetch_all(cfg: SourceCfg, client: HttpClient, run_id: str) -> FetchResult: ...
|
|
def parse_body(text: str, fmt: str) -> tuple[list[RawRecord], int, str]:
|
|
"""(records, totalCount, resultCode) 반환. JSON/XML 양쪽 지원."""
|
|
```
|
|
|
|
- **동작**: ① `numOfRows=1` 로 `totalCount` 취득 → ② `ceil(totalCount/page_size)` 페이지 순회 → ③ 각 페이지 원문을 `data/raw/<date>/page_%04d.json`에 그대로 저장 → ④ 페이지 간 정중 지연.
|
|
- **본문 시그니처 검사(신규)**: HTTP 200이어도 다음이면 실패로 간주한다 — 본문 길이 < `min_body_bytes`(기본 200), `resultCode != "00"`, `Content-Type`이 예상과 다름, 본문에 `<html`·`location.href`·차단 키워드 존재. 세 후보 모두가 뚫려 있던 구멍이다.
|
|
- **실패 시**: `FetchError`. 원문은 이미 아카이브돼 있으므로 사후 재현이 가능하다.
|
|
|
|
### 3.5 `normalize.py`
|
|
|
|
- **책임**: 원본 7필드를 표준 `DmfRecord`로 변환하고, diff의 키와 비교 해시를 계산한다.
|
|
- **의존**: 없음(순수 함수, `unicodedata`만)
|
|
- **공개 API**
|
|
|
|
```python
|
|
@dataclass(frozen=True, slots=True)
|
|
class DmfRecord:
|
|
dmf_key: str # 정규화된 DMF_PERMIT_NO (+중복 시 결정론적 접미사)
|
|
permit_no: str
|
|
ingredient_name: str
|
|
applicant: str # ENTP_NAME
|
|
manufacturer: str # MNFCTR_NAME
|
|
manufacture_place: str
|
|
countries: tuple[str, ...] # MANUF_COUNTRY_CODE_NM 콤마 분해 후 정렬
|
|
permit_date: str # ISO YYYY-MM-DD 정규화
|
|
accepted_date: str | None # 등록번호 앞 8자리에서 파생
|
|
raw: dict[str, str]
|
|
content_hash: str # 비교 대상 6필드의 sha256 앞 16자
|
|
|
|
COMPARE_FIELDS: tuple[str, ...] = (
|
|
"ingredient_name", "applicant", "manufacturer",
|
|
"manufacture_place", "countries", "permit_date",
|
|
)
|
|
|
|
def normalize_all(raws: list[RawRecord]) -> tuple[list[DmfRecord], NormalizeStats]: ...
|
|
def normalize_one(raw: RawRecord, dup_index: int = 0) -> DmfRecord: ...
|
|
def make_dmf_key(permit_no: str, dup_index: int) -> str: ...
|
|
def compute_content_hash(rec: DmfRecord) -> str: ...
|
|
```
|
|
|
|
- **정규화 규칙**: 유니코드 NFKC, 연속 공백 1개로 축약, 앞뒤 공백 제거, 전각→반각, `(주)`/`㈜` 통일, 국가 다중값은 콤마 분해 후 공백 제거·정렬(순서 흔들림으로 인한 오탐 방지 — 요구 R2.3).
|
|
- **중복 등록번호**: 한 스냅샷에 같은 `DMF_PERMIT_NO`가 둘 이상이면 `제조소명` 사전순 정렬 후 `#2`, `#3` 접미사를 붙인다(결정론적). `NormalizeStats.duplicate_permit_no`에 건수를 남겨 `integrity`가 임계값 판정에 쓴다.
|
|
> ⚠️ **미검증**: API가 실제로 중복 등록번호를 반환하는지는 데이터 소스 SSOT 부록 B의 미해결 항목이다. serviceKey 발급 직후 실측하고, 중복이 없으면 이 접미사 로직은 무해한 no-op으로 남는다.
|
|
- **실패 시**: 개별 레코드 파싱 실패는 예외를 던지지 않고 `NormalizeStats.rejected`에 적재한다. 널 비율 판정은 `integrity`가 한다.
|
|
|
|
### 3.6 `integrity.py`
|
|
|
|
- **책임**: 요구 R2.2의 **안전장치 5종**을 판정한다. 하나라도 실패하면 그 실행은 **diff를 수행하지 않는다**. 이 프로젝트에서 가장 위험한 실패 모드(전건 취하 오탐)를 막는 단일 관문이다.
|
|
- **의존**: `storage.repo`(전일 통계 조회), `config`
|
|
- **공개 API**
|
|
|
|
```python
|
|
@dataclass(frozen=True, slots=True)
|
|
class Gate:
|
|
name: str
|
|
passed: bool
|
|
detail: str
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class IntegrityVerdict:
|
|
ok: bool
|
|
gates: tuple[Gate, ...]
|
|
blocking_reason: str | None
|
|
|
|
def evaluate(fetch: FetchResult, records: list[DmfRecord],
|
|
stats: NormalizeStats, prev: PrevSnapshotStats | None,
|
|
cfg: IntegrityCfg) -> IntegrityVerdict: ...
|
|
```
|
|
|
|
- **게이트 5종(요구 R2.2와 1:1)**
|
|
1. 모든 페이지가 HTTP 200 이고 `resultCode == "00"` 이며 본문 시그니처 통과
|
|
2. `len(records) == total_count_reported`
|
|
3. `total_count_reported` 가 직전 성공 스냅샷 대비 `max_drop_ratio`(기본 0.05) 이상 급감하지 않음
|
|
4. 필수 필드(`permit_no`, `ingredient_name`, `applicant`) 널 비율 ≤ `max_null_ratio`(기본 0.01)
|
|
5. 중복 `DMF_PERMIT_NO` 비율 ≤ `max_duplicate_ratio`(기본 0.02)
|
|
- **실패 시**: `ok=False`. 파이프라인은 **스냅샷 INSERT도 하지 않는다**(기준선 오염 방지). 리포트는 마지막 성공 스냅샷으로 생성하되 대시보드에 "오늘 수집 실패 — 마지막 성공: YYYY-MM-DD" 배너를 넣고 WARN 알림을 기록한다. 실행 상태는 `PARTIAL`.
|
|
|
|
### 3.7 `diff.py`
|
|
|
|
- **책임**: 전일 스냅샷과 금일 레코드를 비교해 이벤트를 만든다. **DB에 접근하지 않는 순수 함수**(테스트 용이성의 핵심).
|
|
- **의존**: `normalize`
|
|
- **공개 API**
|
|
|
|
```python
|
|
@dataclass(frozen=True, slots=True)
|
|
class FieldChange:
|
|
field: str
|
|
before: str
|
|
after: str
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class DiffEvent:
|
|
dmf_key: str
|
|
event_type: Literal["NEW", "CHANGED", "WITHDRAWN"]
|
|
changes: tuple[FieldChange, ...]
|
|
before: dict[str, str] | None
|
|
after: dict[str, str] | None
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class DiffResult:
|
|
new: tuple[DiffEvent, ...]
|
|
changed: tuple[DiffEvent, ...]
|
|
withdrawn: tuple[DiffEvent, ...]
|
|
unchanged_count: int
|
|
@property
|
|
def is_empty(self) -> bool: ...
|
|
|
|
def compute_diff(previous: dict[str, DmfRecord],
|
|
current: dict[str, DmfRecord]) -> DiffResult: ...
|
|
def diff_fields(before: DmfRecord, after: DmfRecord) -> tuple[FieldChange, ...]: ...
|
|
```
|
|
|
|
- **판정 규칙**: 키는 `dmf_key`. NEW = 오늘만 존재, WITHDRAWN = 어제만 존재, CHANGED = 양쪽 존재 + `content_hash` 상이(그 후 `COMPARE_FIELDS`로 필드별 차이 추출).
|
|
- **첫 실행**: `previous`가 비어 있으면 **모든 레코드를 NEW로 만들지 않는다.** `DiffResult`를 빈 결과로 반환하고 `unchanged_count`에 전량을 넣는다(기준선 수립일). 요구 부록의 "첫 며칠은 diff가 비어 있다"와 일치한다.
|
|
- **실패 시**: 순수 함수라 실패 경로가 없다. 입력이 비정상이면 `integrity`가 이미 막았다.
|
|
|
|
### 3.8 `storage/db.py`
|
|
|
|
- **책임**: 연결 생성과 마이그레이션 적용. **이 모듈 밖에서는 `sqlite3.connect`를 직접 호출하지 않는다.**
|
|
- **의존**: `sqlite3`, `paths`, `backup`, `errors`
|
|
- **공개 API**
|
|
|
|
```python
|
|
def connect(db_path: Path, *, read_only: bool = False) -> sqlite3.Connection: ...
|
|
# PRAGMA journal_mode=WAL; foreign_keys=ON; busy_timeout=15000; synchronous=NORMAL
|
|
def current_version(conn: sqlite3.Connection) -> int: ...
|
|
def apply_migrations(conn: sqlite3.Connection, migrations_dir: Path,
|
|
backup_dir: Path) -> list[int]:
|
|
"""적용 전 VACUUM INTO 백업 → 번호순 적용 → schema_version 기록. 적용된 버전 목록 반환."""
|
|
def integrity_check(conn: sqlite3.Connection) -> tuple[bool, str]: ...
|
|
```
|
|
|
|
- **실패 시**: 마이그레이션 도중 예외 → 롤백 + `StorageError`. 안내 문구에 **백업본 절대경로**를 포함한다(ADR-04의 롤백 경로).
|
|
|
|
### 3.9 `storage/repo.py`
|
|
|
|
- **책임**: 정본 테이블에 대한 유일한 읽기·쓰기 경로. 하나의 실행이 만드는 쓰기를 트랜잭션으로 묶는다.
|
|
- **의존**: `storage.db`, `normalize`, `diff`
|
|
- **공개 API(발췌)**
|
|
|
|
```python
|
|
def start_run(conn, run_id: str, run_date: str, trigger: str) -> None: ...
|
|
def finish_run(conn, run_id: str, status: str, exit_code: int, notes: str) -> None: ...
|
|
def last_success_run_on(conn, run_date: str) -> RunRow | None: ... # idempotency 가드
|
|
def latest_success_run(conn) -> RunRow | None: ...
|
|
def save_checkpoint(conn, run_id: str, stage: str, status: str,
|
|
artifact_path: str | None, error: str | None) -> None: ...
|
|
def is_stage_done(conn, run_id: str, stage: str) -> bool: ...
|
|
def insert_snapshot(conn, run_id: str, records: list[DmfRecord]) -> int: ...
|
|
def load_snapshot(conn, run_id: str) -> dict[str, DmfRecord]: ...
|
|
def insert_events(conn, run_id: str, diff: DiffResult) -> int: ...
|
|
def upsert_current_records(conn, run_id: str, records: list[DmfRecord]) -> None: ...
|
|
def record_fetch_stats(conn, run_id: str, fetch: FetchResult) -> None: ...
|
|
def prev_snapshot_stats(conn) -> PrevSnapshotStats | None: ...
|
|
def insert_agy_call(conn, run_id: str, call: AgyCallRow) -> None: ...
|
|
def tokens_used_today(conn, day: str) -> int: ...
|
|
def insert_enrichment(conn, run_id: str, run_level: dict, per_event: list[dict]) -> None: ...
|
|
def get_health(conn, component: str) -> HealthRow: ...
|
|
def set_health(conn, row: HealthRow) -> None: ...
|
|
```
|
|
|
|
- **불변식**: `snapshots`·`events`·`agy_calls`·`alerts`는 **절대 UPDATE/DELETE 하지 않는다**(보존 정책에 따른 오래된 파티션 삭제만 예외). `records`만 현재 상태를 UPDATE한다.
|
|
- **실패 시**: `StorageError`로 승격하고 트랜잭션 롤백. `database is locked`는 `busy_timeout` 15초로 흡수하고, 그래도 실패하면 파이프라인 FAILED + CRITICAL 알림.
|
|
|
|
### 3.10 `health.py`
|
|
|
|
- **책임**: 3상태 서킷 브레이커. 소스(`source_mfds`)와 AI(`agy`) **두 컴포넌트에 같은 코드를 재사용**한다.
|
|
- **의존**: `storage.repo`, `config`
|
|
- **공개 API**
|
|
|
|
```python
|
|
class CircuitState(StrEnum):
|
|
CLOSED = "CLOSED"; OPEN = "OPEN"; HALF_OPEN = "HALF_OPEN"
|
|
|
|
def allow(conn, component: str, cfg: HealthCfg, now: datetime) -> tuple[bool, CircuitState, str]: ...
|
|
def record_success(conn, component: str, run_id: str) -> CircuitState: ...
|
|
def record_failure(conn, component: str, reason: str, cfg: HealthCfg,
|
|
now: datetime) -> tuple[CircuitState, bool]:
|
|
"""(새 상태, 상태가 방금 전환됐는가) — 전환 시에만 알림을 쏘기 위한 플래그."""
|
|
```
|
|
|
|
- **전이 규칙**: 연속 실패가 `failure_threshold`(소스 3, agy 3)에 도달하면 OPEN + `cooldown_until = now + cooldown`(소스 1일, agy 24시간). 쿨다운 경과 후 첫 호출은 HALF_OPEN으로 **1회만** 시도, 성공하면 CLOSED 복귀, 실패하면 OPEN 유지 + 쿨다운 갱신.
|
|
- **알림 정책**: **상태 전환 시 1회만** 알림을 기록한다. OPEN 상태가 유지되는 동안에는 매일 반복 알림을 내지 않는다(운영 렌즈의 "학습된 무시" 방지).
|
|
|
|
### 3.11 `agy/client.py`
|
|
|
|
- **책임**: `agy.exe`를 SSOT §16 체크리스트대로 호출한다. **어떤 실패도 예외로 던지지 않고 값으로 반환한다.**
|
|
- **의존**: `subprocess`, `config`, `paths`
|
|
- **공개 API**
|
|
|
|
```python
|
|
@dataclass(frozen=True, slots=True)
|
|
class AgyEnvelope:
|
|
status: str # SUCCESS/ERROR/CANCELED/INTERRUPTED/INVALID/WAITING/RUNNING/LAUNCH_FAILED
|
|
response: str
|
|
error: str | None
|
|
exit_code: int
|
|
duration_seconds: float
|
|
usage: dict[str, int]
|
|
conversation_id: str | None
|
|
stdout_path: Path
|
|
stderr_path: Path
|
|
|
|
class ErrorKind(StrEnum):
|
|
NONE="NONE"; AUTH="AUTH"; QUOTA="QUOTA"; TIMEOUT="TIMEOUT"; NOT_FOUND="NOT_FOUND"; OTHER="OTHER"
|
|
|
|
def build_args(cfg: AgyCfg, prompt_path: Path, log_file: Path) -> list[str]: ...
|
|
def run_agy(cfg: AgyCfg, prompt: str, run_id: str, log_dir: Path) -> AgyEnvelope: ...
|
|
def classify_error(env: AgyEnvelope) -> ErrorKind: ...
|
|
```
|
|
|
|
- **고정 호출 규약**: 절대경로 `%LOCALAPPDATA%\agy\bin\agy.exe`, `--output-format json`, `--model`/`--effort` 설정값, `--print-timeout 10m`, `--disable-slash-commands`, `--log-file <run별 경로>`. 환경변수 `AGY_CLI_DISABLE_AUTO_UPDATE=true`를 **자식 프로세스 환경에 주입**한다. `--continue`·`--dangerously-skip-permissions`는 **쓰지 않는다**.
|
|
- **스트림 분리**: stdout은 `agy.stdout.json`, stderr는 `agy.stderr.log`로 각각 리다이렉트하고 절대 합치지 않는다(SSOT §5.3).
|
|
- **프롬프트 전달**: 프롬프트가 길어 명령줄 길이 제한(약 32KB)에 걸릴 수 있으므로 `-p`에 넘기기 전 크기를 검사하고, 초과하면 diff 항목 수를 상위 N건으로 잘라 재구성한다.
|
|
- **실패 시**: 실행 자체가 안 되면 `status="LAUNCH_FAILED"`. 호출부는 이것을 정상 입력의 하나로 취급한다.
|
|
|
|
### 3.12 `agy/extract.py`
|
|
|
|
- **책임**: agy `response`에서 JSON을 견고하게 추출하고 자체 스키마로 검증한다(ADR-17).
|
|
- **의존**: `jsonschema`
|
|
- **공개 API**
|
|
|
|
```python
|
|
def extract_json_object(text: str) -> dict | None: ... # SSOT §7.3 균형괄호 스캐너
|
|
def validate(obj: dict | None, schema: dict) -> tuple[bool, str | None]: ...
|
|
def get_briefing(env: AgyEnvelope, schema: dict) -> BriefingResult: ...
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class BriefingResult:
|
|
ok: bool
|
|
headline: str | None
|
|
summary_md: str | None
|
|
anomalies: tuple[str, ...]
|
|
per_event: tuple[dict, ...] # {dmf_key, comment, importance}
|
|
failure_reason: str | None
|
|
```
|
|
|
|
- **실패 시**: `ok=False`. 리포트는 그대로 진행하고 대시보드에 "AI 요약 없음: <사유>" 배지를 표시한다.
|
|
|
|
### 3.13 `report/build.py` · `report/atomic.py`
|
|
|
|
- **책임**: DB만 읽어 xlsx를 조립하고, **사용자가 파일을 열어둔 상태에서도 실패하지 않게** 저장한다(요구 R3.5).
|
|
- **의존**: `xlsxwriter`, `report.data`, `report.theme`, `report.sheets`
|
|
- **공개 API**
|
|
|
|
```python
|
|
def build_report(conn, run_id: str, cfg: ReportCfg) -> ReportOutcome: ...
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class ReportOutcome:
|
|
path: Path
|
|
latest_link_path: Path | None
|
|
used_fallback_name: bool
|
|
sheets: tuple[str, ...]
|
|
warnings: tuple[str, ...]
|
|
|
|
# atomic.py
|
|
def atomic_write(build_fn: Callable[[Path], None], target: Path,
|
|
retries: int = 3, backoff_seconds: float = 2.0) -> tuple[Path, bool]:
|
|
"""임시 파일에 생성 → os.replace 로 원자 교체.
|
|
PermissionError(파일 잠김) 시 재시도, 끝내 실패하면
|
|
DMF_리포트_YYYY-MM-DD_HHMMSS.xlsx 폴백 파일명으로 저장하고 (경로, True) 반환."""
|
|
```
|
|
|
|
- **불변식**: `build_report`는 AI 산출물 유무와 무관하게 **항상 완주한다**. `enrichment` 조인 결과가 없으면 해당 셀을 "(AI 요약 없음)"으로 렌더링할 뿐이다.
|
|
- **시트 구성**(xlsx SSOT §4 + 대시보드 SSOT §0 통합 확정, 8시트): `00_대시보드` / `01_오늘변경분` / `02_전체현황` / `03_성분별` / `04_업체별` / `05_워치리스트`(비면 생략) / `06_추이` / `99_메타`.
|
|
- **실패 시**: `ReportError`. DB는 이미 커밋됐으므로 알림 문구에 "`report-only --run-id <id>` 로 재생성 가능"을 넣는다.
|
|
|
|
### 3.14 `alerts.py`
|
|
|
|
- **책임**: 알림 **의도**를 기록하고, 중복을 억제하고, 표시 여부를 추적한다. **표시는 하지 않는다**(ADR-11).
|
|
- **의존**: `storage.repo`, `paths`, `notify.messages`
|
|
- **공개 API**
|
|
|
|
```python
|
|
class Severity(StrEnum):
|
|
INFO="INFO"; WARN="WARN"; CRITICAL="CRITICAL"
|
|
|
|
def raise_alert(conn, *, run_id: str | None, severity: Severity, code: str,
|
|
what: str, why: str, how: str, action: str,
|
|
log_dir: Path | None, cooldown_minutes: int) -> bool:
|
|
"""dedup_key = code(+run_date). 쿨다운 안이면 기록만 갱신하고 False 반환."""
|
|
def pending(conn) -> list[AlertRow]: ...
|
|
def mark_shown(conn, alert_id: int) -> None: ...
|
|
def resolve(conn, code: str, note: str) -> int: ...
|
|
def mirror_to_file(conn, path: Path) -> None: ... # state/alerts.json 갱신
|
|
```
|
|
|
|
- **문구 규격(요구 R7.3)**: 모든 알림은 **무엇이 / 왜 / 어떻게 고치는지 / 다음 행동** 4요소를 필수로 갖는다. `raise_alert`가 하나라도 비면 `ValueError`를 던진다 — 규격을 문자열 생성기가 아니라 **계약**으로 승격시킨다.
|
|
- **실패 시**: 알림 기록 실패는 파이프라인을 막지 않는다. 최후 수단으로 `state/alerts.json`에 직접 추가 기록하고 Event Log에 남긴다.
|
|
|
|
### 3.15 `checks.py`
|
|
|
|
- **책임**: 온보딩·복구·진단이 공유하는 **단일 진단 엔진**(ADR-24).
|
|
- **의존**: 거의 모든 모듈(읽기 전용)
|
|
- **공개 API**
|
|
|
|
```python
|
|
@dataclass(frozen=True, slots=True)
|
|
class CheckResult:
|
|
key: str
|
|
title: str # "공공데이터포털 API 키"
|
|
ok: bool
|
|
detail: str # 사람이 읽는 현재 상태
|
|
fix_hint: str # 무엇을 하면 되는지
|
|
fix_action: str | None # GUI 버튼이 호출할 액션 키 ("enter_api_key" 등)
|
|
severity: Severity
|
|
|
|
def run_all(cfg: Config | None) -> list[CheckResult]: ...
|
|
def run_one(key: str, cfg: Config | None) -> CheckResult: ...
|
|
```
|
|
|
|
- **체크 12종**: ① Python·venv ② 의존성 3종 import ③ `config.toml` 파싱·검증 ④ API 키 존재(DPAPI 복호화) ⑤ API 키 실호출 검증(`numOfRows=1`) ⑥ `agy.exe` 존재 ⑦ `agy` 인증 상태(최근 `agy_calls` 기반 판정, 헬스체크 호출 안 함) ⑧ DB 존재·`integrity_check`·스키마 버전 ⑨ 작업 스케줄러 3종 등록 상태 ⑩ 디스크 여유 공간 ⑪ 리포트 출력 디렉터리 쓰기 권한 + 오늘 파일 잠김 여부 ⑫ 최근 실행 상태(heartbeat 신선도·연속 실패 횟수).
|
|
- **실패 시**: 체크 자체의 예외는 잡아 `ok=False, detail="<예외 요약>"`으로 변환한다. **진단기가 죽어서 진단이 안 되는 일이 없어야 한다.**
|
|
|
|
### 3.16 `notify/pump.py`
|
|
|
|
- **책임**: 로그온 세션에서 15분마다 도는 대화형 에이전트. **UI를 띄울 권한을 가진 유일한 프로세스**다.
|
|
- **의존**: `alerts`, `watchdog`, `notify.toast`, `notify.eventlog`, `gui.app`, `checks`
|
|
- **공개 API**
|
|
|
|
```python
|
|
def pump_once(cfg: Config) -> int:
|
|
"""1) 워치독 판정 → 필요 시 alerts 기록
|
|
2) state/alerts.json + DB에서 미표시 알림 조회
|
|
3) WARN/INFO → 자동소멸 토스트 창
|
|
4) CRITICAL → 복구 GUI를 해당 체크에 포커스해 기동(모달)
|
|
5) mark_shown 기록. 종료 코드 반환."""
|
|
```
|
|
|
|
- **동시성**: 자체 락 파일로 중복 pump를 막는다. GUI가 이미 떠 있으면 새로 띄우지 않고 기존 창을 전면으로 올린다.
|
|
- **실패 시**: 어떤 예외도 사용자에게 보이지 않게 삼키되 Event Log에 남긴다. 알리미가 죽어서 조용해지는 것이 최악이므로, `pump_once`가 예외로 끝나면 다음 주기에 다시 시도한다.
|
|
|
|
### 3.17 `gui/app.py`
|
|
|
|
- **책임**: 요구 R6·R7의 단일 창. `checks.run_all()` 결과를 체크리스트로 그리고, 각 항목의 `fix_action`을 버튼으로 노출한다.
|
|
- **의존**: `tkinter`, `checks`, `gui.steps`
|
|
- **공개 API**
|
|
|
|
```python
|
|
def launch(mode: Literal["setup", "recover", "inspect"] = "setup",
|
|
focus_key: str | None = None) -> int: ...
|
|
```
|
|
|
|
- **진입 모드**(요구 4절 표와 1:1): `setup`(전체 체크리스트) / `recover`(실패 항목 강조 + 원인 설명) / `inspect`(진단 결과만).
|
|
- **막다른 골목 금지(R7.7)**: 모든 실패 항목은 반드시 액션 버튼을 가진다. 액션이 정의되지 않은 체크는 `checks.py` 단계에서 등록을 거부한다(계약으로 강제).
|
|
- **실패 시**: GUI가 뜨지 못하면(예: tkinter 손상) `cli doctor`의 텍스트 출력을 콘솔로 폴백하고 Event Log에 기록한다.
|
|
|
|
### 3.18 `pipeline.py`
|
|
|
|
- **책임**: 스테이지 순차 실행, 체크포인트 조회·기록, 스테이지 경계에서의 예외 흡수.
|
|
- **의존**: 위 모든 것
|
|
- **공개 API**
|
|
|
|
```python
|
|
@dataclass(frozen=True, slots=True)
|
|
class Stage:
|
|
name: str
|
|
fn: Callable[[RunContext], None]
|
|
required: bool # False면 실패해도 파이프라인 계속(best-effort)
|
|
timeout_seconds: int
|
|
|
|
STAGES: tuple[Stage, ...] = (
|
|
Stage("preflight", stage_preflight, required=True, timeout_seconds=60),
|
|
Stage("fetch", stage_fetch, required=True, timeout_seconds=900),
|
|
Stage("normalize", stage_normalize, required=True, timeout_seconds=120),
|
|
Stage("integrity", stage_integrity, required=True, timeout_seconds=60),
|
|
Stage("diff", stage_diff, required=True, timeout_seconds=120),
|
|
Stage("persist", stage_persist, required=True, timeout_seconds=180),
|
|
Stage("enrich", stage_enrich, required=False, timeout_seconds=720),
|
|
Stage("report", stage_report, required=True, timeout_seconds=300),
|
|
Stage("backup", stage_backup, required=False, timeout_seconds=300),
|
|
Stage("finalize", stage_finalize, required=True, timeout_seconds=60),
|
|
)
|
|
|
|
def run_pipeline(ctx: RunContext) -> RunResult: ...
|
|
```
|
|
|
|
- **`required=False` 스테이지의 실패는 상태를 FAILED로 끌어내리지 않는다.** `enrich`(AI)와 `backup`이 여기 해당한다.
|
|
- **체크포인트**: 각 스테이지 시작·종료를 `stage_status`에 기록한다. 같은 `run_id`로 재실행하면 `status="SUCCESS"`인 스테이지를 건너뛴다 — Task Scheduler `RestartCount` 재시도가 소스를 다시 두드리지 않게 한다.
|
|
- **실패 시**: 미처리 예외는 `run_pipeline`이 최상위에서 잡아 `finish_run(status="FAILED")` + CRITICAL 알림을 기록한다.
|
|
|
|
---
|
|
|
|
## 4. 데이터 흐름
|
|
|
|
### 4.1 정상 경로
|
|
|
|
```
|
|
[06:00 ± 0~240초 지터] DMF_Crawler_Daily (S4U, 사용자 계정)
|
|
│ 또는 [부팅 후 5분] 같은 작업의 AtStartup 트리거
|
|
▼
|
|
python -m dmf_crawler run --trigger scheduled
|
|
│
|
|
├─ runlock.acquire() ── 실패 시 종료 0 (다른 인스턴스 실행 중)
|
|
├─ config.load_config() + validate() ── 실패 시 종료 2 + CRITICAL 알림
|
|
├─ repo.last_success_run_on(오늘) ── 있으면 "SKIPPED" 기록 후 종료 0 ★ idempotency 가드
|
|
└─ logging_setup: logs/run_<run_id>/ 생성
|
|
│
|
|
▼
|
|
┌─ STAGE preflight ─────────────────────────────────────────────────┐
|
|
│ db.connect(WAL) → apply_migrations(백업 후) → integrity_check │
|
|
│ secrets_dpapi.load_service_key() → 없으면 API는 건너뛰고 headful/폴백 │
|
|
│ 디스크 여유 공간 확인 → 임계 미만이면 WARN 알림 │
|
|
└──────────────────────────────────────────────────────────────────┘
|
|
▼
|
|
┌─ STAGE fetch ─────────────────────────────────────────────────────┐
|
|
│ source.mode=auto + API 키 있음 또는 source.mode=api │
|
|
│ · health.allow("source_mfds") 확인 (OPEN이면 스테일 폴백) │
|
|
│ · HttpClient.get(numOfRows=1) → totalCount 취득 │
|
|
│ · ceil(totalCount/page_size) 페이지 순회 │
|
|
│ · 각 페이지 원문 → data/raw/<date>/page_NNNN.json │
|
|
│ source.mode=auto + API 키 없음 또는 source.mode=browser │
|
|
│ · AGY + chrome-devtools MCP + visible Chrome │
|
|
│ · https://nedrug.mfds.go.kr/pbp/CCBAC03/getExcel 다운로드 │
|
|
│ 공통: 본문/파일 시그니처, total/zip/XML/행 수 검증 → FetchResult │
|
|
└──────────────────────────────────────────────────────────────────┘
|
|
▼
|
|
┌─ STAGE normalize ─────────────────────────────────────────────────┐
|
|
│ RawRecord[] → DmfRecord[] (NFKC·공백·전각·법인격·국가 다중값 정렬) │
|
|
│ dmf_key 부여(중복 시 결정론적 접미사), content_hash 산출 │
|
|
└──────────────────────────────────────────────────────────────────┘
|
|
▼
|
|
┌─ STAGE integrity ─── ★ 요구 R2.2 관문 ─────────────────────────────┐
|
|
│ 게이트 5종 판정. 하나라도 실패하면 → 4.2절 [실패 경로 C]로 분기 │
|
|
└──────────────────────────────────────────────────────────────────┘
|
|
▼
|
|
┌─ STAGE diff ─── (순수 함수, DB 미접근) ────────────────────────────┐
|
|
│ repo.load_snapshot(가장 최근 성공 run) 을 인자로 받아 비교 │
|
|
│ → DiffResult{new, changed, withdrawn} │
|
|
│ (첫 실행이면 빈 결과 + 기준선 수립 표시) │
|
|
└──────────────────────────────────────────────────────────────────┘
|
|
▼
|
|
┌─ STAGE persist ─── 단일 트랜잭션 ──────────────────────────────────┐
|
|
│ insert_snapshot(오늘 전량, append-only) │
|
|
│ insert_events(diff, append-only) │
|
|
│ upsert_current_records(records 현재 상태) │
|
|
│ record_fetch_stats │
|
|
│ ★ 이 시점에서 리포트는 이미 완결 가능하다. AI는 순수 후행 단계다. │
|
|
└──────────────────────────────────────────────────────────────────┘
|
|
▼
|
|
┌─ STAGE enrich (required=False) ───────────────────────────────────┐
|
|
│ 조건: diff.is_empty == False AND agy.enabled │
|
|
│ AND health.allow("agy") == True │
|
|
│ AND budget.tokens_used_today < daily_token_cap │
|
|
│ prompt.render(diff + 지표) → 단일 프롬프트 │
|
|
│ client.run_agy(...) → AgyEnvelope (예외 없음) │
|
|
│ extract.get_briefing → 실패 시 강화 프롬프트로 1회 재시도 │
|
|
│ repo.insert_agy_call(성공/실패 무관 usage·duration 기록) │
|
|
│ 성공 시 repo.insert_enrichment │
|
|
│ classify_error == AUTH → CRITICAL 알림 기록(창은 안 띄움) │
|
|
│ health.record_success/failure("agy") │
|
|
└──────────────────────────────────────────────────────────────────┘
|
|
▼
|
|
┌─ STAGE report ─── (DB 읽기 전용, AI 유무와 무관하게 항상 완주) ─────┐
|
|
│ data.fetch_report_data(run_id) → ReportData │
|
|
│ XlsxWriter로 8시트 조립(모든 숫자는 파이썬 사전 계산값) │
|
|
│ atomic.atomic_write: 임시파일 → os.replace │
|
|
│ · PermissionError 시 3회 재시도 → 폴백 파일명 + WARN 알림 │
|
|
│ DMF_리포트_최신.xlsx 원자적 갱신 │
|
|
└──────────────────────────────────────────────────────────────────┘
|
|
▼
|
|
┌─ STAGE backup (required=False) ───────────────────────────────────┐
|
|
│ VACUUM INTO backup/dmf_<date>.sqlite3 → 보존 개수 초과분 삭제 │
|
|
└──────────────────────────────────────────────────────────────────┘
|
|
▼
|
|
┌─ STAGE finalize ──────────────────────────────────────────────────┐
|
|
│ finish_run(status=SUCCESS|PARTIAL) │
|
|
│ state/heartbeat.json 갱신 ← ★ 성공/부분성공일 때만 갱신 │
|
|
│ alerts.mirror_to_file(state/alerts.json) │
|
|
│ eventlog.write(요약 1줄) │
|
|
│ 로그 보존 기간 초과 디렉터리 정리 │
|
|
└──────────────────────────────────────────────────────────────────┘
|
|
▼
|
|
종료 코드 0
|
|
┊
|
|
┊ (독립 트리거) [로그온 시 + 15분마다] DMF_Crawler_Agent (Interactive)
|
|
▼
|
|
pythonw -m dmf_crawler notify-pump
|
|
1) watchdog: heartbeat 나이 > stale_after_minutes → CRITICAL 알림 기록
|
|
2) alerts.pending() 조회
|
|
3) WARN/INFO → tkinter 자동소멸 토스트
|
|
4) CRITICAL → gui.launch(mode="recover", focus_key=...) 모달 창 ★ 요구 R7.2
|
|
5) mark_shown
|
|
```
|
|
|
|
### 4.2 실패 경로
|
|
|
|
**[A] 네트워크·API 실패 (fetch 단계)**
|
|
```
|
|
HTTP 오류/타임아웃 → http.py 재시도 4회(Retry-After 우선)
|
|
├─ 회복 → 정상 흐름 복귀
|
|
└─ 소진 → FetchError
|
|
→ health.record_failure("source_mfds")
|
|
├─ CLOSED 유지(1~2회차): WARN 알림, 오늘은 스테일 폴백
|
|
└─ OPEN 전환(3회차): CRITICAL 알림 1회(전환 시에만)
|
|
→ integrity 게이트 1 실패로 이어짐 → [C]로 합류
|
|
```
|
|
|
|
**[B] agy 실패 (enrich 단계)**
|
|
```
|
|
LAUNCH_FAILED / status=ERROR / 타임아웃 / JSON 검증 실패
|
|
→ 예외 없이 값으로 반환 → agy_calls에 실패 기록
|
|
→ health.record_failure("agy") (3회 연속이면 24h OPEN)
|
|
→ classify_error:
|
|
AUTH → CRITICAL 알림(code=AGY_AUTH). 에이전트가 재로그인 창을 띄운다
|
|
QUOTA → WARN 알림. 그날 AI 스킵
|
|
기타 → INFO 로그만
|
|
→ report 단계는 그대로 실행. 대시보드에 "AI 요약 없음: <사유>" 배지
|
|
→ 실행 상태는 FAILED가 아니라 SUCCESS(요구 R4.4)
|
|
```
|
|
|
|
**[C] 무결성 게이트 차단 (integrity 단계)**
|
|
```
|
|
게이트 5종 중 하나라도 실패
|
|
→ 스냅샷 INSERT 하지 않음 (기준선 오염 방지)
|
|
→ diff 수행하지 않음 (전건 취하 오탐 원천 차단)
|
|
→ report는 "마지막 성공 스냅샷"으로 생성 + 대시보드 최상단 경고 배너
|
|
→ WARN 알림(code=INTEGRITY_BLOCKED, 4요소 문구 포함)
|
|
→ 실행 상태 PARTIAL, 종료 코드 0 (리포트가 나왔으므로)
|
|
→ heartbeat는 갱신하되 status=PARTIAL을 함께 기록
|
|
```
|
|
|
|
**[D] 리포트 파일 잠김 (report 단계)**
|
|
```
|
|
os.replace → PermissionError (사용자가 어제/오늘 파일을 Excel로 열어둠)
|
|
→ 2초 백오프로 3회 재시도
|
|
→ 끝내 실패 → DMF_리포트_<date>_<HHMMSS>.xlsx 폴백 파일명으로 저장
|
|
→ WARN 알림: "리포트를 다른 이름으로 저장했습니다 / 원본이 열려 있습니다 /
|
|
Excel을 닫고 report-only 를 실행하세요 / [지금 실행] 버튼"
|
|
→ 실행 상태 SUCCESS (파일은 나왔다)
|
|
```
|
|
|
|
**[E] 프로세스가 아예 시작되지 못함 / 강제 종료**
|
|
```
|
|
ExecutionTimeLimit 30분 초과 → Task Scheduler가 강제 종료 → finally도 못 돔
|
|
→ heartbeat 미갱신
|
|
→ Task Scheduler RestartCount 3회 재시도(10분 간격)
|
|
→ 체크포인트 덕에 성공한 스테이지는 건너뛰고 재개
|
|
→ 그래도 실패하면 heartbeat가 계속 오래됨
|
|
→ 다음 pump 주기(≤15분)에서 워치독이 감지 → CRITICAL 모달
|
|
문구: "06:00 배치가 실행되지 않았습니다 / 마지막 성공 <시각> /
|
|
[지금 실행] [작업 상태 확인] [로그 폴더 열기]"
|
|
```
|
|
|
|
**[F] 알림을 표시할 세션이 없음**
|
|
```
|
|
사용자가 로그오프 상태
|
|
→ 배치는 alerts 테이블 + state/alerts.json + Windows Event Log에 기록
|
|
→ 다음 로그온 시 DMF_Crawler_Agent의 AtLogOn 트리거가 즉시 실행
|
|
→ 미표시 알림을 그때 표시 (요구 4절 각주의 "다음 로그온 시 재표시")
|
|
```
|
|
|
|
---
|
|
|
|
## 5. 실행 진입점
|
|
|
|
단일 진입점 `python -m dmf_crawler <서브커맨드>`. 배포된 콘솔 스크립트 `dmf` 도 등록한다(`pyproject.toml`).
|
|
|
|
| 커맨드 | 인자 | 동작 | 주 사용자 |
|
|
|---|---|---|---|
|
|
| `run` | `--trigger {scheduled,startup,manual}` (기본 manual), `--force`(idempotency 가드 무시), `--dry-run`(DB 쓰기·리포트 저장 없이 로그만), `--skip-agy` | 전체 파이프라인 1회 실행 | Task Scheduler, GUI "지금 실행" |
|
|
| `report-only` | `--run-id <id>` 또는 `--date YYYY-MM-DD`(기본: 마지막 성공 run) | 수집·diff 없이 DB에서 리포트만 재생성 | 사람(리포트 깨짐 복구) |
|
|
| `backfill` | `--from YYYY-MM-DD --to YYYY-MM-DD`, `--reparse`(원문 아카이브에서 재파싱), `--rediff`, `--rereport` | 과거 구간 재처리. 원문 아카이브가 있는 날짜만 대상 | 사람(파서 수정 후) |
|
|
| `doctor` | `--json`(기계 판독 출력), `--fix-tasks`(스케줄 작업만 재등록) | `checks.run_all()`을 텍스트 표로 출력. 종료 코드로 정상/이상 구분 | 사람, GUI 내부 |
|
|
| `onboard` | `--mode {setup,recover,inspect}`, `--focus <check_key>` | GUI 창 기동(요구 R6·R7). `pythonw`로 호출 | 바로가기 더블클릭 |
|
|
| `notify-pump` | `--once`(기본), `--interval <초>`(디버깅용 루프) | 워치독 판정 + 알림 표시 + 필요 시 복구 GUI 기동 | Task Scheduler(Interactive) |
|
|
| `install-task` | `--time 06:00`, `--user <계정>` | `scripts/install_tasks.ps1` 호출로 작업 3종 등록 | GUI 버튼, 최초 설치 |
|
|
| `uninstall-task` | — | 작업 3종 제거 | 사람 |
|
|
| `backup` | `--now`, `--prune` | 즉시 백업 / 보존 정책 적용 | 사람, 파이프라인 내부 |
|
|
| `db` | `migrate` \| `vacuum` \| `check` \| `version` | 마이그레이션 적용 / VACUUM / integrity_check / 스키마 버전 | 사람 |
|
|
| `version` | — | 패키지·Python·의존성·agy 버전 출력 | 사람 |
|
|
|
|
**종료 코드 규약(전 커맨드 공통)**
|
|
|
|
| 코드 | 의미 | Task Scheduler 재시작 유발 |
|
|
|---|---|---|
|
|
| `0` | SUCCESS / PARTIAL / SKIPPED — **리포트 파일이 존재한다** | 아니오 |
|
|
| `1` | FAILED — 리포트 미생성. 일시적 원인일 수 있어 재시도 가치 있음 | 예(최대 3회) |
|
|
| `2` | BLOCKED — DB 손상, 설정 오류처럼 재시도해도 같은 전제조건 미충족. API 키 없음은 선택 기능이라 BLOCKED가 아니다 | 예(무해, idempotent + 알림 dedup으로 흡수) |
|
|
| `130` | 사용자 중단(Ctrl+C) | 아니오 |
|
|
|
|
`doctor`만 예외적으로 `0`(전부 통과) / `1`(WARN 존재) / `2`(CRITICAL 존재)를 쓴다.
|
|
|
|
**바로가기(요구 R6.1·R6.2)**: `bootstrap.cmd`가 `scripts/make_shortcuts.ps1`을 호출해 프로젝트 루트와 바탕화면에 두 개의 `.lnk`를 만든다.
|
|
|
|
| 바로가기 | 대상 |
|
|
|---|---|
|
|
| `DMF 설정.lnk` | `.venv\Scripts\pythonw.exe -m dmf_crawler onboard --mode setup` |
|
|
| `지금 실행.lnk` | `.venv\Scripts\pythonw.exe -m dmf_crawler onboard --mode inspect --run-now` |
|
|
|
|
`pythonw.exe`는 콘솔을 만들지 않는다. `.bat`/`.cmd`는 반드시 콘솔 창을 띄우므로 **최초 설치용 `bootstrap.cmd` 외에는 배치 파일을 사용자에게 노출하지 않는다.**
|
|
|
|
---
|
|
|
|
## 6. 설정 파일
|
|
|
|
**형식 확정: TOML 단일 파일** `config/config.toml`. stdlib `tomllib`로 읽는다(ADR-14). PC별 차이는 `config/config.local.toml`(gitignore)이 같은 키를 덮어쓴다. **비밀 값은 절대 넣지 않는다** — API 키는 DPAPI, agy 토큰은 agy가 관리한다.
|
|
|
|
### 6.1 전체 키 스펙
|
|
|
|
| 키 | 타입 | 기본값 | 설명 |
|
|
|---|---|---|---|
|
|
| `general.timezone` | str | `"Asia/Seoul"` | 실행일자 판정 기준 시간대 |
|
|
| `general.contact_email` | str | `""` | User-Agent에 넣을 연락처. 요구 N1(출처 표시·정중한 접근) |
|
|
| `general.language` | str | `"ko"` | 리포트·알림 언어 |
|
|
| `schedule.daily_time` | str | `"06:00"` | 일일 실행 시각(요구 R5.1) |
|
|
| `schedule.jitter_seconds` | int | `240` | 0~이 값 사이 랜덤 지연. R5.1의 ±5분 안에 들도록 상한 300 |
|
|
| `schedule.startup_delay_minutes` | int | `5` | AtStartup 트리거 지연(네트워크·프로필 준비 대기) |
|
|
| `schedule.execution_time_limit_minutes` | int | `30` | 작업 강제 종료 한계 |
|
|
| `schedule.restart_count` | int | `3` | 실패 시 재시작 횟수 |
|
|
| `schedule.restart_interval_minutes` | int | `10` | 재시작 간격 |
|
|
| `schedule.agent_repeat_minutes` | int | `15` | 알림 에이전트 반복 주기 |
|
|
| `schedule.agy_update_weekday` | str | `"Sunday"` | 주간 `agy update` 요일 |
|
|
| `schedule.agy_update_time` | str | `"14:00"` | 주간 업데이트 시각(사람이 깨어 있는 시간대) |
|
|
| `source.base_url` | str | `"https://apis.data.go.kr/1471000/MdcDmfInfoService01/getMdcDmfList01"` | 정본 엔드포인트 |
|
|
| `source.response_type` | str | `"json"` | `json` \| `xml`. json 실패 시 xml 폴백 |
|
|
| `source.page_size` | int | `100` | `numOfRows`. 명세 크기가 3자리라 상한 999 추정(⚠️ 미검증) |
|
|
| `source.connect_timeout_seconds` | float | `10.0` | 연결 타임아웃 |
|
|
| `source.read_timeout_seconds` | float | `30.0` | 읽기 타임아웃 |
|
|
| `source.max_attempts` | int | `4` | 재시도 최대 횟수 |
|
|
| `source.backoff_base_seconds` | float | `5.0` | 지수 백오프 기준 |
|
|
| `source.backoff_cap_seconds` | float | `300.0` | 백오프 상한 |
|
|
| `source.min_interval_seconds` | float | `0.7` | 페이지 간 최소 간격(정중함) |
|
|
| `source.min_body_bytes` | int | `200` | 이보다 짧은 200 응답은 실패로 간주 |
|
|
| `source.archive_retain_days` | int | `180` | `data/raw/` 보존 기간 |
|
|
| `integrity.max_drop_ratio` | float | `0.05` | totalCount 급감 허용 한계(요구 R2.2 게이트 3) |
|
|
| `integrity.max_null_ratio` | float | `0.01` | 필수 필드 널 비율 상한(게이트 4) |
|
|
| `integrity.max_duplicate_ratio` | float | `0.02` | 중복 등록번호 비율 상한(게이트 5) |
|
|
| `storage.sqlite_path` | str | `"data/dmf.sqlite3"` | 정본 DB 경로 |
|
|
| `storage.busy_timeout_ms` | int | `15000` | SQLite 잠금 대기 |
|
|
| `storage.snapshot_retain_days` | int | `0` | 0 = 영구 보존(이벤트 소싱 원칙) |
|
|
| `backup.enabled` | bool | `true` | 성공 후 자동 백업 |
|
|
| `backup.dir` | str | `"backup"` | **다른 드라이브 권장.** 온보딩이 유도 |
|
|
| `backup.keep_count` | int | `30` | 보존 개수 |
|
|
| `backup.min_free_gb` | float | `2.0` | 이보다 여유가 적으면 백업 스킵 + WARN |
|
|
| `report.output_dir` | str | `"reports"` | 산출물 디렉터리 |
|
|
| `report.filename_pattern` | str | `"DMF_리포트_{date}.xlsx"` | 파일명 |
|
|
| `report.latest_link_name` | str | `"DMF_리포트_최신.xlsx"` | 고정 링크 파일명. 빈 문자열이면 생성 안 함 |
|
|
| `report.font` | str | `"맑은 고딕"` | Windows Vista+ 기본 탑재 |
|
|
| `report.palette` | str | `"okabe_ito"` | 색각이상 안전 팔레트 고정 |
|
|
| `report.top_n` | int | `10` | 성분별·업체별 시트 상위 N |
|
|
| `report.trend_days` | int | `90` | 추이 시트 표시 기간 |
|
|
| `report.retain_days` | int | `365` | 리포트 보존 기간 |
|
|
| `report.lock_retries` | int | `3` | 파일 잠김 재시도 횟수 |
|
|
| `agy.enabled` | bool | `true` | AI 계층 사용 여부 |
|
|
| `agy.binary_path` | str | `""` | 빈 값이면 `%LOCALAPPDATA%\agy\bin\agy.exe` 자동 |
|
|
| `agy.model` | str | `"gemini-3.7-flash-medium"` | agy SSOT §8.2 권장 |
|
|
| `agy.effort` | str | `"medium"` | `low` \| `medium` \| `high` |
|
|
| `agy.print_timeout` | str | `"10m"` | SSOT §16 권장. 기본 5분은 부족 |
|
|
| `agy.skip_if_diff_empty` | bool | `true` | 변화 없는 날 28k 토큰을 태우지 않는다 |
|
|
| `agy.max_json_retries` | int | `1` | 스키마 검증 실패 시 강화 프롬프트 재시도 |
|
|
| `agy.daily_token_cap` | int | `300000` | 일일 누적 토큰 상한 |
|
|
| `agy.max_events_in_prompt` | int | `200` | 명령줄 길이·토큰 절약을 위한 상위 N건 절단 |
|
|
| `agy.prompt_path` | str | `"prompts/daily_briefing.md"` | 템플릿 경로 |
|
|
| `agy.schema_path` | str | `"prompts/daily_briefing.schema.json"` | 자체 검증 스키마 |
|
|
| `health.source_failure_threshold` | int | `3` | 소스 서킷 OPEN 임계(연속 실행 횟수) |
|
|
| `health.source_cooldown_hours` | int | `24` | 소스 쿨다운 |
|
|
| `health.agy_failure_threshold` | int | `3` | agy 서킷 OPEN 임계 |
|
|
| `health.agy_cooldown_hours` | int | `24` | agy 쿨다운 |
|
|
| `notify.watchdog_stale_minutes` | int | `120` | heartbeat 신선도 한계(06:00 + 2시간) |
|
|
| `notify.toast_seconds` | int | `12` | 자동 소멸 알림 표시 시간 |
|
|
| `notify.cooldown_minutes` | int | `240` | 동일 코드 알림 억제 시간(요구 R7.8) |
|
|
| `notify.consecutive_failure_critical` | int | `3` | 연속 실패 이 횟수부터 CRITICAL 승격 |
|
|
| `notify.eventlog_source` | str | `"DMF Crawler"` | Windows 이벤트 로그 원본 이름 |
|
|
| `logging.dir` | str | `"logs"` | 로그 루트 |
|
|
| `logging.level` | str | `"INFO"` | `DEBUG` \| `INFO` \| `WARNING` \| `ERROR` |
|
|
| `logging.retain_days` | int | `90` | 실행 로그 디렉터리 보존 |
|
|
| `logging.mask_patterns` | list[str] | `["serviceKey", "access_token"]` | 로그 기록 전 마스킹 대상 키 |
|
|
|
|
### 6.2 완전한 예시 파일
|
|
|
|
```toml
|
|
# config/config.toml — DMF Crawler 설정 정본
|
|
# 비밀 값(API 키, OAuth 토큰)은 이 파일에 절대 넣지 않는다.
|
|
# API 키는 온보딩 GUI에서 입력하면 Windows DPAPI 로 암호화 저장된다.
|
|
|
|
[general]
|
|
timezone = "Asia/Seoul"
|
|
contact_email = "" # User-Agent 에 들어갈 연락처. 온보딩에서 입력받는다
|
|
language = "ko"
|
|
|
|
[schedule]
|
|
daily_time = "06:00"
|
|
jitter_seconds = 240
|
|
startup_delay_minutes = 5
|
|
execution_time_limit_minutes = 30
|
|
restart_count = 3
|
|
restart_interval_minutes = 10
|
|
agent_repeat_minutes = 15
|
|
agy_update_weekday = "Sunday"
|
|
agy_update_time = "14:00"
|
|
|
|
[source]
|
|
base_url = "https://apis.data.go.kr/1471000/MdcDmfInfoService01/getMdcDmfList01"
|
|
response_type = "json"
|
|
page_size = 100
|
|
connect_timeout_seconds = 10.0
|
|
read_timeout_seconds = 30.0
|
|
max_attempts = 4
|
|
backoff_base_seconds = 5.0
|
|
backoff_cap_seconds = 300.0
|
|
min_interval_seconds = 0.7
|
|
min_body_bytes = 200
|
|
archive_retain_days = 180
|
|
|
|
[integrity]
|
|
max_drop_ratio = 0.05
|
|
max_null_ratio = 0.01
|
|
max_duplicate_ratio = 0.02
|
|
|
|
[storage]
|
|
sqlite_path = "data/dmf.sqlite3"
|
|
busy_timeout_ms = 15000
|
|
snapshot_retain_days = 0 # 0 = 영구 보존
|
|
|
|
[backup]
|
|
enabled = true
|
|
dir = "backup" # 다른 드라이브 경로를 권장한다 (예: "E:/DMF_Backup")
|
|
keep_count = 30
|
|
min_free_gb = 2.0
|
|
|
|
[report]
|
|
output_dir = "reports"
|
|
filename_pattern = "DMF_리포트_{date}.xlsx"
|
|
latest_link_name = "DMF_리포트_최신.xlsx"
|
|
font = "맑은 고딕"
|
|
palette = "okabe_ito"
|
|
top_n = 10
|
|
trend_days = 90
|
|
retain_days = 365
|
|
lock_retries = 3
|
|
|
|
[agy]
|
|
enabled = true
|
|
binary_path = "" # 빈 값이면 %LOCALAPPDATA%\agy\bin\agy.exe
|
|
model = "gemini-3.7-flash-medium"
|
|
effort = "medium"
|
|
print_timeout = "10m"
|
|
skip_if_diff_empty = true
|
|
max_json_retries = 1
|
|
daily_token_cap = 300000
|
|
max_events_in_prompt = 200
|
|
prompt_path = "prompts/daily_briefing.md"
|
|
schema_path = "prompts/daily_briefing.schema.json"
|
|
|
|
[health]
|
|
source_failure_threshold = 3
|
|
source_cooldown_hours = 24
|
|
agy_failure_threshold = 3
|
|
agy_cooldown_hours = 24
|
|
|
|
[notify]
|
|
watchdog_stale_minutes = 120
|
|
toast_seconds = 12
|
|
cooldown_minutes = 240
|
|
consecutive_failure_critical = 3
|
|
eventlog_source = "DMF Crawler"
|
|
|
|
[logging]
|
|
dir = "logs"
|
|
level = "INFO"
|
|
retain_days = 90
|
|
mask_patterns = ["serviceKey", "access_token"]
|
|
```
|
|
|
|
---
|
|
|
|
## 7. 실패 시나리오 대응표
|
|
|
|
심사에서 제기된 모든 시나리오 + 요구 SSOT 4절의 복구 표를 하나로 합쳤다.
|
|
|
|
| # | 실패 | 감지 방법 | 자동 대응 | 사용자 알림 | 복구 절차 |
|
|
|---|---|---|---|---|---|
|
|
| 1 | API 키 미설정 | `secrets_dpapi.load_service_key() is None` | 파이프라인 시작 안 함, 종료 2 | **CRITICAL, 강제 창** | GUI에서 키 입력 → 즉시 실호출 검증 → 저장. 발급 페이지 열기 버튼 제공 |
|
|
| 2 | API 키 무효·만료 | `resultCode != "00"` 또는 HTTP 401/403 | fetch 중단, 스테일 폴백 리포트 | **CRITICAL, 강제 창** | GUI에서 키 재입력. 기존 키 지문(앞 8자)만 표시해 혼동 방지 |
|
|
| 3 | 일일 트래픽 10,000 초과 | API 오류 코드 `LIMITED_NUMBER_OF_SERVICE_REQUESTS_EXCEEDS_ERROR` | 그날 fetch 포기, 스테일 폴백 | WARN 토스트 | 내일 재시도 안내. 반복되면 운영계정 전환 안내 |
|
|
| 4 | 네트워크 단절 | 연결 예외 | 재시도 4회 → 서킷 카운터 증가 | WARN 토스트(1~2회차) | 자동 회복. 3회 연속이면 #5로 승격 |
|
|
| 5 | 소스 서킷 OPEN 전환 | `health.record_failure` 3회 연속 | 24시간 소스 호출 자체를 스킵 | **CRITICAL 1회(전환 시에만)** | GUI "지금 실행"으로 HALF_OPEN 강제 시도. 원인 조사 후 자동 복귀 |
|
|
| 6 | HTTP 429/503 + `Retry-After` | 응답 헤더 | 헤더 값을 **절대 우선**해 대기 후 재시도 | 없음(로그만) | 자동 |
|
|
| 7 | HTTP 200인데 빈/차단 본문 | `min_body_bytes` 미달, `<html` 포함, `resultCode` 이상 | 실패로 분류 → 게이트 1 차단 | WARN 토스트 | 원문이 `data/raw/`에 남아 있으므로 즉시 열람 가능 |
|
|
| 8 | 수집 건수 ≠ `totalCount` | 게이트 2 | **diff 미수행**, 스냅샷 저장 안 함 | WARN 토스트 | 다음 실행에서 자동 재시도. `run --force`로 즉시 재시도 가능 |
|
|
| 9 | `totalCount` 5% 이상 급감 | 게이트 3 | **diff 미수행**(전건 취하 오탐 차단) | **CRITICAL** | 사람이 원문 확인 후 정상이면 임계값 조정 또는 `run --force` |
|
|
| 10 | 필수 필드 널 비율 초과 | 게이트 4 | diff 미수행 | WARN | 원문 확인. API 스키마 변경 의심 시 #12로 |
|
|
| 11 | 중복 등록번호 비율 초과 | 게이트 5 | diff 미수행 | WARN | 정상 데이터일 수 있음 — 실측 후 임계값 조정 |
|
|
| 12 | API 스키마 변경 | 예상 필드 부재 → 널 비율 급증 | diff 미수행, 원문 아카이브 보존 | **CRITICAL** | agy에 A7(변경점 진단) 요청 → `state/proposals/`에 저장 → **사람 승인 후** 매핑 수정(ADR-25) |
|
|
| 13 | `agy.exe` 미설치 | `checks` ⑥ 경로 확인 실패 | AI 스테이지 스킵, 리포트는 정상 | **CRITICAL, 강제 창** | GUI "설치" 버튼 → `bootstrap_agy.ps1` 무인 실행(진행률 표시) |
|
|
| 14 | `agy` OAuth 만료 | `classify_error(env) == AUTH` | AI 스킵. 리포트 정상 | **CRITICAL, 강제 창** | GUI "로그인 창 열기" → 대화형 `agy` 콘솔 기동 → 완료 후 재검사 |
|
|
| 15 | `agy` 쿼터 소진 | `classify_error == QUOTA` | 그날 AI 스킵, 서킷 카운터 증가 | WARN 토스트 | "AI 없이 리포트 생성됨" 안내. 익일 자동 재시도 |
|
|
| 16 | `agy` 타임아웃 | `--print-timeout` 초과, 종료 코드 비0 | AI 스킵 | INFO 로그만 | 자동. 반복되면 서킷 OPEN |
|
|
| 17 | `agy` JSON 추출/검증 실패 | `extract.validate` 실패 | 강화 프롬프트 1회 재시도 → 실패 시 스킵 | INFO 로그만 | 오염 응답을 `agy.stdout.json`에 보존. 픽스처로 승격해 회귀 테스트 추가 |
|
|
| 18 | `agy` 일일 토큰 상한 초과 | `budget.tokens_used_today >= cap` | 호출 자체를 하지 않음 | INFO 로그만 | 상한 조정 또는 다음 날 대기 |
|
|
| 19 | `agy` 자동 업데이트 충돌 | 배치 중 바이너리 교체 | `AGY_CLI_DISABLE_AUTO_UPDATE=true`를 자식 환경에 주입해 **예방** | 없음 | 주간 별도 작업(일요일 14:00)에서만 `agy update` |
|
|
| 20 | xlsx 파일 잠김 | `os.replace` → `PermissionError` | 3회 재시도 → 폴백 파일명 저장 | WARN 토스트 | Excel 닫고 `report-only` 실행. 알림 문구에 명령이 포함됨 |
|
|
| 21 | 디스크 여유 부족 | `checks` ⑩, 백업 전 재확인 | 백업 스킵(리포트는 생성) | WARN 토스트 | 오래된 백업·로그·원문 아카이브 정리 안내. GUI에 "정리" 버튼 |
|
|
| 22 | `database is locked` | `sqlite3.OperationalError` | `busy_timeout` 15초 대기 → 실패 시 FAILED | **CRITICAL** | 다른 인스턴스 확인. `runlock`이 이미 대부분 예방한다 |
|
|
| 23 | SQLite 파일 손상 | `PRAGMA integrity_check` 실패 | 파이프라인 시작 안 함, 종료 2 | **CRITICAL, 강제 창** | GUI에 최신 백업본 목록 표시 → "이 백업으로 복원" 버튼 |
|
|
| 24 | 마이그레이션 실패 | 적용 중 예외 | 트랜잭션 롤백, 종료 2 | **CRITICAL** | 알림 문구에 **적용 직전 백업본 절대경로** 포함. 복원 후 코드 되돌리기 |
|
|
| 25 | 같은 날 중복 실행 | idempotency 가드 + `runlock` | `SKIPPED` 기록 후 종료 0 | 없음 | 의도적 재실행은 `run --force` |
|
|
| 26 | 06:00에 PC 꺼져 있음 | Task Scheduler | `StartWhenAvailable` + AtStartup(+5분) 트리거로 자동 캐치업 | 없음 | 자동. 중복은 #25가 흡수 |
|
|
| 27 | `ExecutionTimeLimit` 초과 강제 종료 | heartbeat 미갱신 | `RestartCount` 3회 재시도(체크포인트로 재개) | **CRITICAL(워치독)** | GUI "지금 실행". 반복되면 `page_size` 조정·타임아웃 상향 |
|
|
| 28 | 배치가 아예 실행되지 않음 | heartbeat 나이 > 120분 | 없음(감지 전용) | **CRITICAL, 강제 창** | GUI "지금 실행" / "작업 상태 확인" / "로그 폴더 열기" |
|
|
| 29 | 작업 스케줄러 등록 소실 | `checks` ⑨ `Get-ScheduledTask` 실패 | **자동 재등록하지 않는다**(사용자가 의도적으로 껐을 수 있음) | **CRITICAL, 강제 창** | GUI "작업 다시 등록" 버튼 → `install-task` |
|
|
| 30 | 연속 3일 실패 | `runs` 집계 | 없음 | **CRITICAL, 강제 창** | 진단 화면(`doctor`) 자동 표시 + 실패 원인 상위 3개 요약 |
|
|
| 31 | 알림 표시 불가(로그오프) | 표시 대상 세션 없음 | `alerts` + `state/alerts.json` + Event Log에 축적 | 다음 로그온 시 즉시 표시 | AtLogOn 트리거가 밀린 알림을 한 번에 표시 |
|
|
| 32 | Python/venv 손상 | `checks` ①② import 실패 | 파이프라인 실행 불가 | **CRITICAL** — GUI도 못 뜰 수 있음 | `bootstrap.cmd` 재실행. Event Log가 유일한 흔적이므로 반드시 기록 |
|
|
| 33 | 미처리 예외 | `run_pipeline` 최상위 `except` | `finish_run(FAILED)` + 스택트레이스 로그 | **CRITICAL** | 알림에 로그 디렉터리 절대경로 포함. 워치독이 이중 확인 |
|
|
|
|
**알림 문구 4요소 예시(요구 R7.3 규격)**
|
|
|
|
```
|
|
[무엇] 06:00 DMF 수집 배치가 어제부터 실행되지 않았습니다.
|
|
[왜] Antigravity CLI 로그인이 만료되어 AI 요약 단계에서 중단됐습니다.
|
|
[어떻게] 아래 [로그인 창 열기]를 누르고 Google 계정으로 다시 로그인하세요. 1분이면 끝납니다.
|
|
[다음] [로그인 창 열기] [지금 실행] [로그 폴더 열기] [나중에]
|
|
```
|
|
|
|
---
|
|
|
|
## 8. 의존성 목록
|
|
|
|
### 8.1 런타임 의존성 (3개)
|
|
|
|
| 패키지 | 버전 제약 | 용도 | 대안과 기각 사유 |
|
|
|---|---|---|---|
|
|
| `httpx` | `>=0.27,<1.0` | 유일한 HTTP 클라이언트. `params=` 자동 인코딩(Decoding 키 전제), 연결/읽기 타임아웃 분리 | `requests`(타임아웃 분리 불가), stdlib `urllib`(재시도·타임아웃을 직접 구현해야 함), `aiohttp`(비동기 불필요) |
|
|
| `XlsxWriter` | `>=3.2,<4.0` | xlsx 생성 **전담**. 스파크라인·수식 캐시값·`autofit`은 이 라이브러리에만 있다 | `openpyxl`(스파크라인 없음, 재저장 시 차트 손실), `pandas.to_excel`(서식 제어 불가) |
|
|
| `jsonschema` | `>=4.21,<5.0` | agy 응답 자체 검증(ADR-17). 손으로 짜면 반드시 틀리는 부분 | 수동 검증(오류 메시지 품질 저하), `pydantic`(무거움, 이 프로젝트에 다른 용도 없음) |
|
|
|
|
### 8.2 개발 의존성 (1개)
|
|
|
|
| 패키지 | 버전 제약 | 용도 |
|
|
|---|---|---|
|
|
| `pytest` | `>=8.0` | 단위·골든 테스트 |
|
|
|
|
### 8.3 표준 라이브러리로 해결한 것
|
|
|
|
| 필요 | 사용한 stdlib | 회피한 외부 패키지 |
|
|
|---|---|---|
|
|
| 설정 파싱 | `tomllib` (3.11+) | PyYAML |
|
|
| 데이터베이스 | `sqlite3` | SQLAlchemy, alembic |
|
|
| GUI·토스트 | `tkinter` | PySide6, win11toast, BurntToast |
|
|
| 비밀 암호화 | `ctypes` → `crypt32.dll` DPAPI | keyring, cryptography, python-dotenv |
|
|
| 배타 락 | `msvcrt` | portalocker, filelock |
|
|
| 재시도·백오프 | 직접 구현(약 50줄, 테스트 포함) | tenacity, backoff |
|
|
| 집계 | SQLite `GROUP BY` | pandas, polars |
|
|
| 이벤트 로그 | `subprocess` → `eventcreate.exe` | pywin32 |
|
|
| CLI 파싱 | `argparse` | click, typer |
|
|
| XML 파싱(폴백) | `xml.etree.ElementTree` | lxml, BeautifulSoup |
|
|
|
|
### 8.4 외부 프로그램 전제
|
|
|
|
| 프로그램 | 필수 여부 | 확인 방법 |
|
|
|---|---|---|
|
|
| Python 3.11+ | **필수** (실측 3.14.6) | `checks` ① |
|
|
| `agy.exe` v1.1.22+ | 선택 — 없으면 AI 요약만 빠진다 | `checks` ⑥. 없으면 GUI가 설치 제안 |
|
|
| PowerShell 7 | 작업 등록·agy 설치 시에만 | `scripts/*.ps1` |
|
|
| LibreOffice | **불필요**(ADR-08로 제거) | — |
|
|
| Microsoft Excel | **불필요**. 리포트 열람용일 뿐 | — |
|
|
|
|
### 8.5 최소로 유지한 근거
|
|
|
|
요구 N5(의존성 최소)와 N8(6개월 뒤 본인이 읽고 고칠 수 있는 구조)이 직접 요구한다. 실무적으로는 이 프로젝트가 **비개발자 PC에서 무인으로 도는 것**이 목표이므로, 의존성 하나는 곧 "6개월 뒤 `pip install`이 실패할 확률 하나"다. 세 후보의 의존성은 A 3개 / B 6개(+pandas·pydantic·win11toast) / C 7개였고, 확정안은 **3개**로 A의 최소주의를 유지하면서 B의 구조를 얻었다.
|
|
|
|
---
|
|
|
|
## 9. 구현 순서
|
|
|
|
각 마일스톤 끝에서 **실제로 무엇이 동작하는지**를 기준으로 나눴다. 앞 단계가 동작하지 않으면 다음으로 넘어가지 않는다.
|
|
|
|
### M0 — 뼈대와 진실 (예상 1~2일)
|
|
|
|
**산출물**
|
|
- 디렉터리 트리 전체 생성(빈 모듈 포함), `pyproject.toml`, `bootstrap.cmd`, `.gitignore`
|
|
- `paths.py`, `config.py`, `errors.py`, `logging_setup.py`, `runlock.py`
|
|
- `secrets_dpapi.py` + 단위 테스트
|
|
- `storage/db.py` + `migrations/0001_init.sql`, `repo.py`의 run/checkpoint 계열
|
|
- `cli.py`의 `version`, `db`, `doctor`(체크 ①②③④ 만)
|
|
|
|
**이 시점에 동작하는 것**
|
|
`bootstrap.cmd` 더블클릭 → venv 생성 → `dmf version` / `dmf db migrate` / `dmf doctor` 가 실행되고, API 키를 DPAPI로 저장·복호화할 수 있다. **DB 파일과 설정이 진실로 존재한다.**
|
|
|
|
### M1 — 수집에서 diff까지 (예상 2~3일)
|
|
|
|
**산출물**
|
|
- `http.py`(재시도·Retry-After·지터·조건부 요청) + `test_http_retry.py`
|
|
- `source_mfds.py`(전량 수집·원문 아카이브·본문 시그니처 검사)
|
|
- `normalize.py` + `test_normalize.py`
|
|
- `integrity.py` + `test_integrity.py` (게이트 5종 경계 테스트)
|
|
- `diff.py` + `test_diff.py`
|
|
- `health.py`(3상태 서킷)
|
|
- `migrations/0002_enrichment.sql`은 미적용, `0001`에 snapshots/records/events 포함
|
|
- `pipeline.py`의 preflight~persist 스테이지
|
|
- `docs/design/02-data-model.md`, `docs/ops/03-api-usage-policy.md` 작성
|
|
|
|
**이 시점에 동작하는 것**
|
|
`dmf run --skip-agy` 가 실제 API에서 전량을 수집해 SQLite에 스냅샷·이벤트를 적재한다. 이틀 연속 돌리면 **둘째 날에 진짜 diff가 나온다.** 게이트가 불완전 수집을 막는 것도 확인된다. 리포트는 아직 없다.
|
|
> **여기서 데이터 소스 SSOT 부록 B의 미검증 항목을 전부 실측해 문서에 기록한다** — `totalCount` 실제 값, `numOfRows` 최대값, 중복 등록번호 유무, `type=json` 동작 여부.
|
|
|
|
### M2 — 무인 운영 (예상 1~2일)
|
|
|
|
**산출물**
|
|
- `scripts/install_tasks.ps1` / `uninstall_tasks.ps1`
|
|
- `alerts.py`, `watchdog.py`, `notify/eventlog.py`, `notify/messages.py`
|
|
- `backup.py`(`VACUUM INTO` + 보존 정리)
|
|
- `pipeline.py`의 backup·finalize 스테이지, heartbeat 기록
|
|
- idempotency 가드와 `--force`
|
|
- `docs/ops/01-scheduling-and-resilience.md` 작성
|
|
|
|
**이 시점에 동작하는 것**
|
|
06:00에 **사람이 없어도** 배치가 돈다. 재부팅해도 다음 06:00에 실행된다. 06:00을 놓치면 부팅 후 5분에 캐치업한다. 중복 실행은 흡수된다. 성공하면 백업본이 생기고 heartbeat가 갱신된다. 실패는 `alerts` 테이블과 Event Log에 남는다 — **아직 화면에는 안 뜬다.**
|
|
|
|
### M3 — 리포트 (예상 3~4일)
|
|
|
|
**산출물**
|
|
- `report/theme.py`, `widgets.py`(한글 열너비·델타 화살표·KPI 타일), `atomic.py` + `test_report_atomic.py`
|
|
- `report/data.py`(시트별 SQL), `build.py`
|
|
- `report/sheets/` 8종 전부
|
|
- `cli report-only`, `cli backfill`
|
|
- `docs/design/03-xlsx-report-spec.md` 작성
|
|
|
|
**이 시점에 동작하는 것**
|
|
매일 06:00에 **탭별로 연동된, 디자인이 예쁜 xlsx**가 나온다(요구 R3 전부). 파일을 열어둔 상태에서도 배치가 실패하지 않는다. `report-only`로 과거 어느 날짜든 재생성된다. **이 시점에서 프로젝트는 이미 사용자에게 가치를 준다.**
|
|
|
|
### M4 — 사람과의 접점 (예상 3~4일)
|
|
|
|
**산출물**
|
|
- `agy/client.py`, `prompt.py`, `extract.py`, `budget.py` + `test_agy_extract.py`
|
|
- `prompts/daily_briefing.md`, `daily_briefing.schema.json`
|
|
- `migrations/0002_enrichment.sql`, `0003_ops.sql` 적용
|
|
- `pipeline.py`의 enrich 스테이지, 대시보드 AI 배지
|
|
- `checks.py` 12종 완성 + `test_checks.py`
|
|
- `gui/app.py`, `steps.py`, `widgets.py`
|
|
- `notify/pump.py`, `toast.py`
|
|
- `scripts/make_shortcuts.ps1`, `bootstrap_agy.ps1`
|
|
- `docs/design/04-onboarding-wizard.md`, `docs/ops/02-failure-alerting.md` 작성
|
|
|
|
**이 시점에 동작하는 것**
|
|
비개발자가 **바로가기를 더블클릭**하면 창이 뜨고, API 키가 없으면 입력받아 검증·저장하고, `agy`가 없으면 설치하고, 로그인이 풀렸으면 로그인 창을 띄우고, 06:00 작업 등록까지 마친다(요구 R6 전부). 배치가 실패하면 **15분 안에 창이 떠서** 무엇이 왜 안 됐고 어떻게 고치는지 알려주고, 그 창에서 바로 고칠 수 있다(요구 R7 전부). 리포트에는 한국어 AI 브리핑이 붙고, agy가 죽어도 리포트는 그대로 나온다.
|
|
|
|
### 마일스톤 후 (범위 밖, 기록만)
|
|
|
|
| 항목 | 조건 |
|
|
|---|---|
|
|
| 워치리스트 UI | 사용자가 관심 성분·업체 목록을 확정한 뒤(요구 부록 미확정 항목) |
|
|
| 이메일·웹훅 알림 | 리포트 수신자가 2명 이상으로 늘어난 뒤 |
|
|
| 해외 소스(FDA/EDQM/PMDA) | 명시적 비목표. 붙일 때 `source_mfds.py`를 프로토콜로 승격 |
|
|
| 웹 대시보드 | 명시적 비목표 |
|
|
|
|
---
|
|
|
|
## 10. 심사 기록
|
|
|
|
### 10.1 후보 3안 요약 비교
|
|
|
|
| 축 | A — 단일 파이프라인 | B — 이벤트 소싱 + 계층 분리 | C — 소스 플러그인 + 오케스트레이터 |
|
|
|---|---|---|---|
|
|
| 철학 | 5년 뒤에도 즉시 이해 가능한 직선 | 데이터가 자산, 리포트는 순수 함수 | 소스 경계가 유일한 확장 지점 |
|
|
| 런타임 의존성 | 3개 | 6개 | 7개 |
|
|
| 설정 파일 수 | 1 | 4 | 6 |
|
|
| 모듈 수 | 약 14 | 약 30 | 약 35 |
|
|
| 스키마 마이그레이션 | 없음 | **있음** | 없음 |
|
|
| 원문 아카이브 | 없음(DB 컬럼만) | **있음(이중)** | **있음** |
|
|
| 일자별 전량 스냅샷 | SCD2 이력 | **append-only 전량** | **run별 전량** |
|
|
| 중복 실행 방어 | **코드 가드 있음** | 스케줄러 설정만 | 스케줄러 설정만 |
|
|
| 알림 세션 분리 | 없음(전부 S4U) | **있음(AtLogOn 분리)** | 절반(세션 감지만) |
|
|
| heartbeat 정책 | 성공 시 | **성공 시** | 항상 갱신(치명) |
|
|
| xlsx 엔진 | openpyxl + LibreOffice | openpyxl | **XlsxWriter** |
|
|
| Retry-After 존중 | 없음 | 없음 | **있음** |
|
|
| 서킷 브레이커 | 소스만(2상태) | agy만(2상태) | **소스만(3상태)** |
|
|
| 진단 커맨드 | **있음(doctor)** | 없음 | 없음 |
|
|
| 200-빈응답 판정 | 없음 | 없음 | 없음 |
|
|
| 자동 백업 | 없음 | 없음 | 없음 |
|
|
|
|
### 10.2 렌즈별 점수
|
|
|
|
| 렌즈 | A | B | C | 렌즈의 판단 요지 |
|
|
|---|---|---|---|---|
|
|
| **운영 단순성(ops)** | **7.5** | 7.0 | 5.5 | 설치·아침 조사·6개월 뒤 재이해의 손비용. A가 최소지만 "이식 없이는 채택 불가", 특히 S4U 토스트 문제가 A의 대전제를 무너뜨린다 |
|
|
| **실패 내성(resilience)** | 5.5 | **7.5** | 6.5 | 알림이 실제로 화면에 도달하는 안은 B뿐. C는 heartbeat를 항상 갱신해 dead-man switch를 설계상 무력화 |
|
|
| **진화(evolution)** | 4.5 | **7.0** | 6.0 | "나중에 절대 복구 못 하는 것"(원문 아카이브·전량 스냅샷·마이그레이션)을 셋 다 갖춘 것은 B뿐 |
|
|
| **합계** | 17.5 | **21.5** | 18.0 | |
|
|
| **1위 렌즈 수** | 1 | **2** | 0 | |
|
|
|
|
### 10.3 왜 이 결론인가
|
|
|
|
**B가 뼈대인 이유는 세 가지다.**
|
|
|
|
첫째, **비가역 축을 지켰다.** 앞으로 올 변경은 두 종류다 — 나중에 해도 비용이 비슷한 것(알림 채널 추가, 워치리스트, 대시보드)과 지금 안 하면 영원히 복구 못 하는 것(원문 아카이브, 일자별 전량 스냅샷, 스키마 변경 절차). 재수집 불가능한 공고 데이터에서 후자는 시간이 갈수록 손실이 누적된다. B만 셋 다 갖췄다. A는 원문을 `raw_json` 컬럼에만 넣어 "파서를 고쳐 3개월치를 재파싱한다"는 시나리오 자체가 성립하지 않는다.
|
|
|
|
둘째, **알림이 실제로 화면에 도달하는 유일한 구조**를 갖고 있었다. 요구 R7.2("강제로 창이 뜬다")와 agy SSOT §4.2(SYSTEM 금지, 사용자 계정 필수)는 정면으로 충돌한다 — S4U 세션에는 데스크톱이 없다. B만 이 충돌을 인지하고 헤드리스 워커와 로그온 세션 알리미로 작업을 쪼갰다. A는 워커도 워치독도 전부 S4U로 등록하면서 `delivered=True`를 기록하는데 화면에는 아무것도 뜨지 않는다. 세 렌즈 중 두 렌즈가 이것을 A의 치명 결함으로 지목했고, 요구 SSOT 4절이 이미 같은 제약을 각주로 적어두고 있었다.
|
|
|
|
셋째, **AI를 스키마 레벨에서 격리**했다. `enrichment`를 `events`와 물리적으로 분리한 것은 요구 R4.4("AI가 실패해도 리포트는 생성된다")를 문서가 아니라 데이터베이스가 강제하게 만든다.
|
|
|
|
**C를 뼈대로 삼지 않은 이유.** C의 소스 플러그인 논거는 "정책이 서로 다른 국내 소스 두 개가 이미 존재한다"였다. 그러나 그 사이 데이터 소스 SSOT가 확정됐다 — nedrug HTML은 **쓰지 않는다**(요구 R1.2·R1.3). 소스가 하나로 줄었고 해외 소스는 명시적 비목표가 됐다. 즉 C의 핵심 논거가 요구 확정으로 소멸했다. 남은 것은 문자열 동적 import, 죽은 스텁 3개, 설정 6분할, 대상 없는 셀렉터 자가복구 스테이지, 그리고 선형 7단계에 얹은 DAG 엔진뿐이다. C가 옳았던 부분(Retry-After, 3상태 서킷, 체크포인트, AI 제안 자동적용 금지, Event Log)은 전부 이식했다.
|
|
|
|
**A를 뼈대로 삼지 않은 이유.** A의 최소주의는 진짜 가치이고 이 확정안은 그것을 상당 부분 계승했다 — 설정 1개, 의존성 3개, 단일 진입점, `doctor` 커맨드. 그러나 A가 절약한 것(마이그레이션 러너 수백 줄, 원문 아카이브 수십 줄)과 잃은 것(앞으로 올 변경 다수가 2~3배 비싸지고, 재분석 시나리오 하나가 아예 불가능해짐)의 교환비가 나쁘다. 이건 YAGNI가 아니라 되돌릴 수 없는 축을 잘못 자른 것이다. 여기에 openpyxl + LibreOffice 재계산이라는 xlsx SSOT 정면 위반이 겹쳐, "시각화가 핵심 가치"라는 요구가 커지는 방향으로 재작성 부채가 예약돼 있었다.
|
|
|
|
**세 후보 모두가 뚫려 있어 신규로 메운 것.** ① HTTP 200 + 빈/차단 본문 판정 — 셋 다 200이면 성공으로 처리해 곧장 diff로 넘어갔고, 결과는 예외도 알림도 없이 전 레코드가 취하로 오탐된 리포트다. 요구 R2.2가 이미 안전장치 5종을 명시하고 있었으므로 `integrity.py`를 1급 스테이지로 승격시켰다. ② xlsx 원자적 교체 — xlsx SSOT §10이 `~$` 배타 잠금과 확정적 `PermissionError`를 실측했는데 원자 교체를 설계한 후보가 없었고, 하필 A·C가 권장하는 복구 절차(같은 날 `report-only` 재실행)가 정확히 이 잠금에 막혔다. ③ 자동 백업 — 셋 다 유일 정본을 파일 하나에 두면서 백업은 README나 "설계 밖"으로 미뤘다. `VACUUM INTO` 10줄을 파이프라인 스테이지로 넣었고, 이것이 동시에 마이그레이션 롤백 경로가 됐다. ④ 디스크 여유 점검. ⑤ 비개발자용 GUI 온보딩·복구(요구 R6·R7) — 세 후보가 작성될 때 존재하지 않던 요구다.
|
|
|
|
---
|
|
|
|
## 부록. 기각된 선택지와 이유
|
|
|
|
| 기각한 것 | 어느 후보/렌즈가 제안했나 | 기각 이유 |
|
|
|---|---|---|
|
|
| **nedrug HTML 스크래핑** | A(비활성), B(비활성), C(활성) | `robots.txt`가 `Disallow: /` 전면 금지이고 요구 R1.2·R1.3가 크롤링을 금지했다. 동일 데이터가 공식 API로 개방돼 있어 우회할 이유가 없다 |
|
|
| **`selectors.yaml` + AI 셀렉터 자가복구** | C | ADR-01로 셀렉터 자체가 존재하지 않는다. 대상 없는 기능이다. 대응되는 개념은 "API 스키마 변화 대응(A7)"이며 승인제로 구현한다 |
|
|
| **Source 프로토콜 + `registry.py` 동적 import** | B, C, 진화 렌즈 | 소스 1개 + 확장 비목표. 문자열 동적 import는 오타를 런타임까지 잠복시키고 grep 추적을 끊는다. 두 번째 소스가 실제로 오면 그때 승격한다 |
|
|
| **DAG 위상정렬(TaskGraph)** | C | 병렬성도 동적 브랜칭도 없는 선형 7단계. 스택 트레이스가 오케스트레이터 프레임을 통과해 원인이 흐려진다. 선언적 스테이지 리스트만 취했다 |
|
|
| **설정 파일 다중 분할(YAML 4~6개)** | B, C | 06:05에 "어느 파일을 열지"부터 정해야 하는 시스템은 5분 안에 안 고쳐진다. PyYAML 의존성도 함께 제거했다 |
|
|
| **`permissions.agy.json` 별도 파일** | B | agy는 `~/.gemini/antigravity-cli/settings.json`만 읽는다. 별도 파일 + 병합 배포 스텝은 조용히 실패했을 때 증상과 원인의 거리가 너무 멀다. 온보딩이 표준 위치에 직접 병합한다 |
|
|
| **`company_aliases.csv` 수동 큐레이션** | B | 사람 부채는 1인 운영에서 반드시 부도난다. 규칙 기반 정규화 + agy 제안(승인제)로 대체 |
|
|
| **`openpyxl`로 리포트 쓰기** | A, B | 스파크라인 API 부재(요구 R3.4 불충족), `load_workbook`→`save` 시 차트·이미지 손실. xlsx SSOT §3.2 결론 |
|
|
| **LibreOffice headless 재계산** | A | "켠 채로 조용히 안 도는" 상태가 1인 운영 최악의 실패 모드다. 모든 숫자를 파이썬이 계산해 값으로 쓰면 재계산 자체가 불필요해진다 |
|
|
| **동적 배열 함수(FILTER/UNIQUE/XLOOKUP)** | — | xlsx SSOT §0: 값 캐시가 없어 뷰어에서 0으로 보인다. 필터링은 파이썬에서, 인터랙션은 Excel 표 + 자동필터로 |
|
|
| **`pandas`** | B, C, 07 문서 전제 | 필요한 집계가 `GROUP BY` 수준이다. 요구 N5와 설치 실패 표면을 고려하면 정당화되지 않는다 |
|
|
| **`pydantic`** | B, C | 설정은 `tomllib` + 데이터클래스, agy 응답은 `jsonschema`로 충분하다 |
|
|
| **`win11toast` / BurntToast** | B, A | 외부 모듈 설치가 전제여서 "설치가 안 되면 알림도 안 됨"이라는 순환 실패를 만든다. tkinter로 직접 그린다 |
|
|
| **환경변수로 API 키 전달** | A, C, 진화 렌즈 | 요구 R6.3의 사용자는 환경변수를 모른다. DPAPI + GUI 입력이 유일하게 요구를 만족한다 |
|
|
| **평문 파일에 서비스키 저장** | B | 요구 N7 위반. DPAPI는 의존성 0으로 같은 편의를 준다 |
|
|
| **`run_daily.ps1` 시작부의 self-healing 작업 재등록** | C, 운영 렌즈 | 논리적 자기모순이다 — 작업이 사라지면 스크립트가 안 돌고, 스크립트가 안 돌면 재등록도 안 된다. 게다가 사용자가 의도적으로 끈 작업을 되살린다. `checks` ⑨의 읽기 전용 점검 + GUI 버튼으로 옮겼다 |
|
|
| **매 배치 agy 인증 헬스체크** | — | agy SSOT §4.3: 헬스체크 한 번에 input 28k 토큰. 실제 작업 호출의 결과로만 판정한다 |
|
|
| **`--dangerously-skip-permissions`** | — | API 응답 문자열(성분명·제조소명)이 프롬프트에 들어가므로 인젝션 표면이 있다. 이 태스크는 파일 쓰기·명령 실행이 필요 없다 |
|
|
| **`--continue` / `--conversation` 재사용** | — | agy SSOT §6.5: 배치는 매 실행이 독립적이어야 한다 |
|
|
| **`--json-schema`의 `structured_output` 신뢰** | — | SSOT §7.2 실측에서 필드 자체가 없었고 응답이 4회 반복 오염됐다 |
|
|
| **agy 역할별 다중 호출(A1~A7 각각)** | — | 첫 호출 오버헤드 28k 토큰 / 33.7초. 하나로 묶는다 |
|
|
| **Windows Service로 상주** | — | "매일 정해진 시각 실행"은 정확히 크론이 하는 일이다. 상시 메모리 점유와 별도 생명주기 관리만 늘어난다 |
|
|
| **WSL cron** | — | agy 토큰이 Windows 사용자 프로필에 있다. 계층을 하나 더 넣을 이유가 없다 |
|
|
| **SYSTEM 계정으로 작업 실행** | — | agy SSOT §4.2: 사용자 프로필의 OAuth 토큰에 접근할 수 없다 |
|
|
| **워치독을 별도 3번째 작업으로 분리** | A(07:30 워치독) | 알림 에이전트가 15분마다 로그온 세션에서 도는데, 그 안에서 heartbeat 판정을 함께 하면 작업 하나가 줄고 "알림이 뜨는 세션에서 판정한다"는 성질이 공짜로 따라온다 |
|
|
| **Airflow / Prefect / Scrapy** | — | 하루 1회, 소스 1개, 5~7단계 선형. 스케줄러 데몬을 24시간 띄우는 순간 "그 데몬은 누가 감시하는가"라는 요구가 하나 더 생긴다 |
|
|
| **웹 대시보드(FastAPI)** | 진화 렌즈가 확장 시나리오로 언급 | 요구 3절 명시적 비목표 |
|
|
| **해외 소스(FDA/EDQM/PMDA)** | B, C | 요구 3절 명시적 비목표. 확장 지점으로만 기록 |
|
|
| **alembic** | 진화 렌즈 | ORM이 없는 프로젝트에 과하다. 번호순 SQL + 적용 전 백업으로 충분하고, 백업본이 곧 롤백이다 |
|
|
| **tenacity / backoff 라이브러리** | 운영 렌즈가 A의 약점으로 언급 | 재시도 규칙이 `Retry-After` 우선이라는 도메인 특수 규칙을 포함해 어차피 감싸야 한다. 50줄 + 테스트로 직접 짜는 편이 명확하다 |
|
|
| **일 2회 이상 실행** | — | 요구 R5.1이 06:00 1회다. API 갱신 주기가 실측되면(데이터 소스 SSOT §7.4) 재검토한다 |
|
|
|
|
---
|
|
|
|
## 부록 B. 이 문서가 남긴 미검증 항목
|
|
|
|
구현 중 반드시 실측하고 해당 SSOT를 갱신해야 한다.
|
|
|
|
- [ ] `numOfRows` 최대값(명세 3자리 → 999 추정). `page_size` 기본값에 직결 — **M1**
|
|
- [ ] 전체 `totalCount` 실제 값. 페이지 수·실행 시간·`ExecutionTimeLimit` 적정성 결정 — **M1**
|
|
- [ ] `type=json` 실제 동작 여부. 실패 시 XML 폴백 경로가 기본이 된다 — **M1**
|
|
- [ ] `DMF_PERMIT_NO` 중복 존재 여부. `normalize.make_dmf_key`의 접미사 로직 유효성 — **M1**
|
|
- [ ] 취하·말소 등록번호가 응답에서 사라지는가 남는가. WITHDRAWN 판정의 전제 — **M1**
|
|
- [ ] 일 10,000건 한도가 "호출 수"인가 "레코드 수"인가 — **M1**
|
|
- [ ] S4U 작업에서 tkinter 창이 정말 안 뜨는가(이 설계의 ADR-10·11 전제). **떠도 설계는 유효하지만, 안 뜨는 것이 확인되면 ADR-11의 가치가 증명된다** — **M2**
|
|
- [ ] `agy` 쿼터 소진 시 정확한 `error` 문자열. `classify_error`의 QUOTA 패턴 — **M4**
|
|
- [ ] `agy` OAuth 만료 시 정확한 `error` 문자열. `classify_error`의 AUTH 패턴 — **M4**
|
|
- [ ] 등록된 MCP 서버가 배치 실행 지연을 유발하는가(agy SSOT 부록 B) — **M4**
|
|
- [ ] `--add-dir`로 워크스페이스를 좁히면 첫 호출 28k 토큰이 줄어드는가 — **M4**
|
|
|
|
---
|
|
|
|
*이 문서는 아키텍처 SSOT 다. 구조에 관한 새 결정은 1절 ADR 표에 행을 추가하고, 기각한 선택지는 부록에 기록한다.*
|