docs: architecture.md 전면 개편 — 애니 파이프라인 전체 실행 흐름 + 스킵 조건 문서화

This commit is contained in:
2026-03-19 07:10:17 +09:00
parent bb59e7caca
commit 4a35c43517

View File

@@ -2,79 +2,242 @@
> Variet Agent — Hybrid Skill-Based AI Agent (v3)
## 프로젝트 개요
사용자가 디스코드에서 자연어 명령 → Orchestrator NLU 분류 → 도구 실행 또는 Agent 통합 실행.
Gemini CLI를 subprocess(`asyncio.create_subprocess_exec`)로 호출. **SDK/API 전환 금지.**
## 디렉토리 구조
```
variet-agent/
├── main.py # 진입점 (FastAPI + Discord Bot)
├── config.py # .env 기반 설정 관리
├── api/
│ ├── server.py # FastAPI REST 서버
│ ├── discord_bot.py # Discord Bot (이벤트 핸들러 + 라우팅, ~310줄)
│ └── models.py # 요청/응답 모델
├── core/
│ ├── orchestrator.py # NLU 분류 + 도구 라우팅
│ ├── task_pipeline.py # ★ execute() 1회 호출 + 선택적 review()
│ ├── gemini_caller.py # Gemini CLI 래퍼 (text/agent 모드)
│ ├── context_manager.py # 관련 파일 선별 + 토큰 예산
│ ├── project_indexer.py # 프로젝트 구조 스캔
│ ├── workspace.py # 워크스페이스 관리
│ ├── file_applier.py # 코드 변경 적용
│ └── docs_manager.py # 문서/세션 기록
├── handlers/ # Discord 핸들러
│ ├── anime_handler.py # 애니 NLU + /anime 슬래시
│ ├── task_handler.py # ★ Agent 1회 실행 + 결과 임베드 (~110줄)
│ ├── commands.py # /workspace, /task 슬래시
│ └── renderer.py # ToolResult → Discord Embed
├── tools/ # 자동화 도구 (Plugin 패턴)
│ ├── base.py # BaseTool 추상 기반
│ ├── registry.py # ToolRegistry 자동 발견
│ ├── anime_tool.py # AnimeTool(BaseTool)
│ ├── anime_pipeline.py # 통합 파이프라인
│ └── ... # 개별 클라이언트들
├── .gemini/
│ └── skills/ # ★ Gemini CLI Skill v2
│ └── anime/
│ └── SKILL.md # 도구 설명 + 사용법
├── prompts/
│ ├── unified.md # NLU 분류
│ ├── agent.md # ★ 통합 에이전트 (plan+code+verify)
│ └── reviewer.md # 독립 리뷰 (선택적)
└── logs/
└── variet.log
```
## 데이터 흐름
## 전체 메시지 흐름
```
Discord 메시지
Orchestrator.classify() — NLU 분류
├── chat → 즉답
├── clarify → 질문
├── anime → AnimeTool + renderer
└── task → TaskPipeline.execute() ← Agent 1회
→ Gemini agent 모드 (plan+code+verify 통합)
→ JSON 보고서 → Discord Embed
discord_bot.py:_classify_and_route()
→ GeminiCaller("unified") — timeout 120s
→ _parse_unified_response() — JSON 추출
→ mode 분기:
├── "chat" → 즉답 Embed
├── "clarify" → 질문 Embed
├── "nextcloud" → nc_handler.handle_nc_message()
├── "anime" → anime_handler.handle_anime_message()
└── "task" → _agent_call() → Gemini agent 모드
```
## vs 이전 버전 (PCRS)
---
```
v2: NLU → Planner → Coder×N → PlannerVerify → Reviewer → Summarizer (5~7 호출)
v3: NLU → Agent 1회 (plan+code+verify 통합) → 선택적 Review (1~2 호출)
## 애니메이션 파이프라인 — 전체 실행 순서
### 1단계: NLU 분류 (`prompts/unified.md`)
Gemini가 반환하는 JSON:
```json
{
"mode": "anime",
"action": "search|download|sub_only|video_only|status|schedule|list",
"title": "한글 제목 (또는 빈 문자열)",
"episode": null,
"filter": "quarter:current sub:yes",
"download_mode": "auto"
}
```
## 새 도구 추가
> ⚠️ **주의**: `title`에 "이번분기" 같은 범위 한정자가 들어올 수 있음. 코드에서 반드시 방어 필요.
1. `tools/``BaseTool` 상속 클래스 생성 → 자동 등록
2. `.gemini/skills/`에 SKILL.md 생성 → Gemini CLI가 자동 발견
### 2단계: Handler 라우팅 (`handlers/anime_handler.py`)
```python
pipeline = AnimePipeline() # 매 호출 새 인스턴스 (캐시 리셋됨)
action = parsed["action"]
title = parsed["title"]
```
| action | title | 실행 경로 | 반환 타입 |
|--------|-------|-----------|-----------|
| `list/scan/schedule` | any | NasScanner 직접 호출 → Embed + return | (없음, 직접 전송) |
| `search` | 있음 | `pipeline.search(title)` | `DownloadResult` |
| `search` | 없음 | 에러 Embed + return | (없음) |
| `download` | 있음 | `pipeline.download(title)` → 실패 시 **batch fallback** | `DownloadResult``list[DownloadResult]` |
| `download` | 없음 | `pipeline.batch_download(mode="auto")` | `list[DownloadResult]` |
| `sub_only` | 있음 | `pipeline.download(title, mode="sub_only")` | `DownloadResult` |
| `sub_only` | 없음 | `pipeline.batch_download(mode="sub_only")` | `list[DownloadResult]` |
| `video_only` | 있음 | `pipeline.download(title, mode="video_only")` | `DownloadResult` |
| `video_only` | 없음 | `pipeline.batch_download(mode="video_only")` | `list[DownloadResult]` |
| `status` | - | `pipeline.get_status()` → Embed + return | (없음) |
> ⚠️ **반환 타입 주의**: `batch_download`는 **`list[DownloadResult]`**, `download`는 **`DownloadResult`**. 렌더링 시 `isinstance(result, list)` 체크 필수.
### 3단계: resolve() — 4요소 세트 완성 (`anime_pipeline.py`)
```
resolve(title) → AnimeWorkUnit | None
Step 1: NAS 기존 폴더 검색
└── _find_existing_nas_folder(title)
└── _nas_folder_cache or nas.list_anime_folders() ← 동기 I/O ⚠️
└── title substring 매칭 + year/quarter 필터
└── 매칭 시: nas_folder, existing_videos, existing_subs, existing_eps, release_group 세팅
Step 2: Anissia 매칭 (fallback 체인)
└── _resolve_anissia(title, unit)
├── 1차: anissia.search_anime(title) — 직접 substring + fuzzy
├── 2차: web_search_anime_title(title) — DuckDuckGo → 후보 → Anissia 재검색
└── 3차: fetch_title_via_jikan(title) — Jikan API → 일본어 제목 → Anissia 재검색
└── 실패 시 return None
Step 3: NAS 폴더 재검증
└── anime.start_date로 정확한 시즌 폴더 재매칭
└── 없으면 make_nas_folder_name()으로 새 폴더명 생성
Step 4: 자막 정보 조회
└── anissia.get_captions(anime_no) → unit.captions
Step 5: Nyaa 토렌트 검색
└── _resolve_nyaa(unit)
└── 영어 제목 → Nyaa RSS 검색 → title 매칭 필터 → unit.torrents
Step 6: 에피소드 오프셋 감지
└── _detect_episode_offset(unit)
└── NAS 파일 에피소드 vs Nyaa 에피소드 비교 → offset 계산
└── _get_anilist_offset() — AniList API (동기 httpx ⚠️)
```
### 4단계: _execute_download() — 다운로드 실행
```
_execute_download(unit, mode, episode) → DownloadResult
mode에 따른 실행:
├── mode in ("auto", "sub_only", "sub_required")
│ └── _download_subtitles(result, unit, episode)
├── mode in ("auto", "video_only")
│ └── _add_torrents(result, unit, episode)
└── mode == "sub_required"
└── 자막 있으면 → _add_torrents
└── 자막 없으면 → "보류" 에러
```
> ⚠️ **mode 결정이 핵심**: `"auto"` = 자막+영상 모두, `"sub_only"` = 자막만, `"video_only"` = 영상만. filter의 "sub"은 "자막도 포함" 의미 → `auto`여야 함. `sub_only`는 명시적 `action="sub_only"`일 때만.
---
## 자막 다운로드 상세 (`_download_subtitles`)
```
1. NAS 폴더 실시간 스캔 → existing_sub_eps (에피소드 셋), existing_sub_files (파일명 셋)
2. unit.captions 순회:
각 caption에서:
├── _discover_episode_urls(caption.website, caption.episode)
│ ├── blogspot → Atom Feed에서 전체 에피소드 URL 수집
│ └── 기타 → [(base_url, ep_num)] 단일 반환
└── 각 (url, discovered_ep):
├── 스킵 체크 1: discovered_ep in existing_sub_eps → 스킵
└── sub_downloader.find_subtitles(url) → list[SubtitleFile]
각 sub에 대해:
├── sub.episode 결정: filename → discovered_ep → None
├── 스킵 체크 2: sub.episode in existing_sub_eps → 스킵
├── 스킵 체크 3: sub.filename in existing_sub_files → 스킵
└── sub_downloader.download_file(sub, nas_folder)
├── 1차 스킵: sub.filename(가짜)으로 존재 체크
├── HTTP GET → Content-Disposition → 실제 파일명 결정
├── 2차 스킵: 실제 파일명으로 존재 체크 ← 핵심 방어선
└── 존재 안 하면 write_bytes
3. _rename_subtitles_to_match_videos() — 자막을 영상 파일명에 맞게 리네임
```
> ⚠️ **자막 스킵 5단계 체크**: (1) URL 에피소드, (2) 파일 에피소드, (3) 파일명 직접비교, (4) 가짜 파일명 존재, (5) **실제 파일명 존재** (Content-Disposition 후). Google Drive는 (1)(2)(3) 우회 가능하므로 **(5)가 최종 안전장치**.
---
## 토렌트 추가 상세 (`_add_torrents`)
```
1. unit.torrents 순회:
각 torrent에서:
├── episode 추출: _extract_episode(t.title)
├── offset 적용: raw_ep - offset → season_ep
├── 스킵: raw_ep in unit.existing_eps (NAS 기존 에피소드)
├── 스킵: VOSTFR/VOSTA (프랑스자막 제외)
├── 스킵: required_group 불일치
└── 점수 매기기: ASW +100, HEVC +50, 1080p +20, seeders log
2. ep_best[season_ep] = 최고 점수 토렌트
3. 에피소드 순서대로 qbit.add_torrent()
└── save_path = unit.nas_path, category = "anime"
```
---
## batch_download() — 일괄 실행
```
1. nas.get_current_quarter_anime() → 이번 분기 NAS 폴더 목록
2. 캐시 미리 로드:
├── _nas_folder_cache = run_in_executor(nas.list_anime_folders)
└── anissia.search_anime("_cache_warmup_") → 스케줄 캐시
3. asyncio.gather(*[resolve(folder.title) for folder in folders])
⚠️ 동시성 제한 없음 — Jikan rate limit 주의
4. 모드 결정:
└── sub_filter=True + captions 없음 → effective_mode = "video_only"
5. 순차 _execute_download(unit, effective_mode)
6. return list[DownloadResult]
```
---
## 개별 도구 클라이언트 정리
### AnissiaClient (`tools/anissia_client.py`)
- `get_schedule(week)` → 요일별 편성표 (0=일~7=기타)
- `get_all_schedule()` → 전체 요일 편성표 (세션당 1회 캐시)
- `search_anime(keyword)` → substring + romaji + fuzzy 매칭
- `get_captions(anime_no)` → 자막 제작자 URL 목록
- **캐시**: `_schedule_cache` — 세션당 1회 로드 (매 pipeline 인스턴스 리셋)
### NyaaClient (`tools/nyaa_client.py`)
- `search(query)` → RSS XML 파싱 → `list[TorrentResult]`
- **기본 suffix**: "ASW HEVC" 자동 추가
- **magnet**: `urn:btih:{hash}` — tracker URL 없음 (DHT 의존)
### NasScanner (`tools/nas_scanner.py`)
- `list_anime_folders(year, quarter)``rglob("*")` + `stat()` **동기 I/O** ⚠️
- `_scan_folder()` → video_files, subtitle_files 수집 (rglob 재귀)
- `get_current_quarter_anime()` → 현재 분기 필터
- **폴더 형식**: `[YY_Q분기]제목``_parse_folder_name()`으로 파싱
### SubtitleDownloader (`tools/subtitle_downloader.py`)
- `find_subtitles(url)` → HTML 파싱:
- Google Drive (`drive.google.com/file/d/``/uc?id=&export=download`)
- Tistory (`blog.kakaocdn.net`)
- Naver Blog (`download.blog.naver.com`)
- 범용 (href에서 .ass/.srt/.zip 등)
- `download_file(sub, save_dir)`:
1. 1차 스킵: `sub.filename` (Google Drive: `subtitle_{fileId}`)
2. HTTP GET + Content-Disposition → **실제 파일명 결정**
3. **2차 스킵: 실제 파일명으로 존재 체크** (핵심)
4. write_bytes → ZIP이면 자동 해제
### QBitClient (`tools/qbit_client.py`)
- `login()` → SID 쿠키 획득
- `add_torrent(magnet, save_path, category, tags)` → torrents/add
- `list_torrents(category)` → 상태 조회
- `delete_torrent(hash, delete_files)` → 완료 후 정리
### TitleMatcher (`tools/title_matcher.py`)
- `web_search_anime_title(query)` → DuckDuckGo HTML → 한글 제목 후보
- `fetch_title_via_jikan(query)` → Jikan API → 영어/일본어/로마자 제목
- `japanese_to_romaji(text)` → pykakasi 또는 카나 테이블 fallback
- `normalize_title()` → 소문자 + 특수문자 제거 + 시즌 정규화
- `title_similarity()` → SequenceMatcher 유사도
- `match_titles()` → Anissia↔Nyaa 제목 매칭
- `make_nas_folder_name(title, start_date)``[YY_Q분기]제목`
- `get_quarter(date_str)` → (year, quarter) 추출
---
## 아키텍처 결정 (변경 불가)
- **Gemini CLI subprocess 영구 유지** (SDK/API 금지)
- 상세: `.agent/references/conventions.md` 참조
## 새 도구 추가
1. `tools/``BaseTool` 상속 클래스 생성 → 자동 등록
2. `.gemini/skills/`에 SKILL.md 생성 → Gemini CLI가 자동 발견