Files
variet-agent/.agent/references/architecture.md

10 KiB

Architecture

Variet Agent — Hybrid Skill-Based AI Agent (v3)

전체 메시지 흐름

Discord 메시지
  → 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 모드

애니메이션 파이프라인 — 전체 실행 순서

1단계: NLU 분류 (prompts/unified.md)

Gemini가 반환하는 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에 "이번분기" 같은 범위 한정자가 들어올 수 있음. 코드에서 반드시 방어 필요.

2단계: Handler 라우팅 (handlers/anime_handler.py)

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 DownloadResultlist[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_downloadlist[DownloadResult], downloadDownloadResult. 렌더링 시 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가 자동 발견