diff --git a/.agent/references/architecture.md b/.agent/references/architecture.md index 853c176..0dd100e 100644 --- a/.agent/references/architecture.md +++ b/.agent/references/architecture.md @@ -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가 자동 발견