fix(anime): 파이프라인 5건 수정 — 에피소드 정규식(v2/S01E), 릴리스 그룹 필터, 자막 보호, 배치 다운로드, 타임아웃

This commit is contained in:
2026-03-15 08:27:08 +09:00
parent 63818999d9
commit 9f74812710
40 changed files with 2759 additions and 815 deletions

View File

@@ -1,75 +1,80 @@
# Architecture # Architecture
> Variet Agent — Gemini CLI 기반 AI Agent Team 시스템 > Variet Agent — Hybrid Skill-Based AI Agent (v3)
## 프로젝트 개요 ## 프로젝트 개요
사용자가 디스코드에서 자연어 명령 → AI Agent Team이 코드 분석/분해/실행 → Gitea CI로 PR/빌드/배포. 사용자가 디스코드에서 자연어 명령 → Orchestrator NLU 분류 → 도구 실행 또는 Agent 통합 실행.
Gemini CLI를 서브프로세스(`asyncio.create_subprocess_exec`)로 래핑하여 역할별 독립 컨텍스트로 호출. Gemini CLI를 subprocess(`asyncio.create_subprocess_exec`)로 호출. **SDK/API 전환 금지.**
## 디렉토리 구조 ## 디렉토리 구조
``` ```
variet-agent/ variet-agent/
├── main.py # 진입점 (FastAPI + Discord Bot 동시 실행) ├── main.py # 진입점 (FastAPI + Discord Bot)
├── config.py # .env 기반 설정 관리 ├── config.py # .env 기반 설정 관리
├── api/ ├── api/
│ ├── server.py # FastAPI REST 서버 │ ├── server.py # FastAPI REST 서버
│ ├── discord_bot.py # Discord Bot (NLU + PCRS 파이프라인 + 애니 핸들러) │ ├── discord_bot.py # Discord Bot (이벤트 핸들러 + 라우팅, ~310줄)
│ └── models.py # 요청/응답 모델 │ └── models.py # 요청/응답 모델
├── core/ ├── core/
│ ├── task_pipeline.py # PCRS: Plan → Code → Review → Summarize │ ├── orchestrator.py # NLU 분류 + 도구 라우팅
│ ├── gemini_caller.py # Gemini CLI 래퍼 (text/agent 모드) │ ├── task_pipeline.py # ★ execute() 1회 호출 + 선택적 review()
│ ├── context_manager.py # 관련 파일 선별 + 토큰 예산 제어 │ ├── gemini_caller.py # Gemini CLI 래퍼 (text/agent 모드)
│ ├── project_indexer.py # 프로젝트 구조 스캔/캐시 │ ├── context_manager.py # 관련 파일 선별 + 토큰 예산
│ ├── workspace.py # 워크스페이스 관리 (채널 ↔ 프로젝트 매핑) │ ├── project_indexer.py # 프로젝트 구조 스캔
│ ├── file_applier.py # 코드 변경 적용 │ ├── workspace.py # 워크스페이스 관리
── docs_manager.py # 문서/세션 기록 ── file_applier.py # 코드 변경 적용
├── tools/ # 자동화 도구 (애니메이션 파이프라인) │ └── docs_manager.py # 문서/세션 기록
├── anime_pipeline.py # 통합 파이프라인 (검색/다운/자막/상태) ├── handlers/ # Discord 핸들러
│ ├── anissia_client.py # Anissia 편성표 API │ ├── anime_handler.py # 애니 NLU + /anime 슬래시
│ ├── nyaa_client.py # Nyaa 토렌트 검색 │ ├── task_handler.py # ★ Agent 1회 실행 + 결과 임베드 (~110줄)
│ ├── qbit_client.py # qBittorrent 제어 │ ├── commands.py # /workspace, /task 슬래시
── nas_scanner.py # NAS 파일 스캔 ── renderer.py # ToolResult → Discord Embed
├── title_matcher.py # 제목 매칭 (로마지/퍼지) ├── tools/ # 자동화 도구 (Plugin 패턴)
── subtitle_downloader.py # 자막 다운로더 ── base.py # BaseTool 추상 기반
├── integrations/ │ ├── registry.py # ToolRegistry 자동 발견
│ ├── gitea_client.py # Gitea API (PR/이슈) │ ├── anime_tool.py # AnimeTool(BaseTool)
│ ├── vikunja_client.py # Vikunja 태스크 관리 │ ├── anime_pipeline.py # 통합 파이프라인
│ └── ci_monitor.py # CI 결과 모니터링 │ └── ... # 개별 클라이언트들
── prompts/ # AI 역할별 프롬프트 ── .gemini/
── unified.md # NLU 분류 (chat/task/anime/clarify) ── skills/ # ★ Gemini CLI Skill v2
├── planner.md # 태스크 분해 └── anime/
├── coder.md # 코드 구현 │ └── SKILL.md # 도구 설명 + 사용법
├── reviewer.md # 코드 리뷰 ├── prompts/
── summarizer.md # 총평 생성 ── unified.md # NLU 분류
│ ├── agent.md # ★ 통합 에이전트 (plan+code+verify)
│ └── reviewer.md # 독립 리뷰 (선택적)
└── logs/
└── variet.log
``` ```
## 핵심 모듈
| 모듈 | 역할 | 의존성 |
|------|------|--------|
| `discord_bot.py` | 사용자 인터페이스 + NLU 분류 | `workspace.py`, `gemini_caller.py`, `task_pipeline.py` |
| `task_pipeline.py` | PCRS 오케스트레이션 (Inner/Outer 루프) | `gemini_caller.py`, `context_manager.py`, `project_indexer.py` |
| `gemini_caller.py` | Gemini CLI 서브프로세스 호출 (text/agent) | `prompts/` |
| `context_manager.py` | 태스크 기반 파일 선별 + 토큰 예산 | `project_indexer.py` |
| `workspace.py` | 채널 ↔ 프로젝트 경로 매핑, workspaces.json 관리 | — |
| `anime_pipeline.py` | 애니 자동화 통합 | `anissia_client.py`, `nyaa_client.py`, `qbit_client.py`, `nas_scanner.py` |
## 데이터 흐름 ## 데이터 흐름
``` ```
Discord 메시지 Discord 메시지
on_message() Orchestrator.classify() — NLU 분류
→ _unified_call() — NLU 분류 (chat/task/anime/clarify) ├── chat → 즉답
├─ chat즉답 ├── clarify질문
├─ clarify → 질문 임베드 ├── anime → AnimeTool + renderer
├─ anime → _handle_anime() → AnimePipeline └── task → TaskPipeline.execute() ← Agent 1회
└─ task → _handle_task() → Gemini agent 모드 (plan+code+verify 통합)
→ TaskPipeline.plan() — Planner (태스크 분해) → JSON 보고서 → Discord Embed
→ TaskPipeline.code_parallel() — Coder (에이전트 모드, cwd=프로젝트)
→ TaskPipeline.planner_verify() — 내부 자가검증 (Inner Loop)
→ TaskPipeline.batch_review() — Reviewer (Outer Loop)
→ TaskPipeline.summarize() — 총평
→ Discord Embed 보고
``` ```
## vs 이전 버전 (PCRS)
```
v2: NLU → Planner → Coder×N → PlannerVerify → Reviewer → Summarizer (5~7 호출)
v3: NLU → Agent 1회 (plan+code+verify 통합) → 선택적 Review (1~2 호출)
```
## 새 도구 추가
1. `tools/``BaseTool` 상속 클래스 생성 → 자동 등록
2. `.gemini/skills/`에 SKILL.md 생성 → Gemini CLI가 자동 발견
## 아키텍처 결정 (변경 불가)
- **Gemini CLI subprocess 영구 유지** (SDK/API 금지)
- 상세: `.agents/references/conventions.md` 참조

View File

@@ -39,6 +39,17 @@ scope: (선택)
- TODO 주석: `// TODO: 설명` 형식 - TODO 주석: `// TODO: 설명` 형식
- 복잡한 로직에는 반드시 WHY(왜) 주석 추가 - 복잡한 로직에는 반드시 WHY(왜) 주석 추가
## 아키텍처 결정 사항 (NEVER 변경)
> [!CAUTION]
> 아래 결정은 사용자가 2026-03-14에 명시적으로 확정한 것입니다.
> 어떤 상황에서도 재논의하거나 대안을 제안하지 마세요.
- **Gemini CLI subprocess 방식 유지**: `gemini_caller.py`는 Gemini CLI를 `asyncio.create_subprocess_exec`로 호출하는 현재 방식을 영구 유지합니다.
- ❌ Gemini SDK (`google-generativeai`) 전환 금지
- ❌ Gemini REST API 직접 호출 금지
- ❌ 위 방향성에 대한 제안/언급 금지
## 테스트 ## 테스트
- 테스트 파일 위치: (예: `__tests__/` 또는 `*.test.js`) - 테스트 파일 위치: (예: `__tests__/` 또는 `*.test.js`)

View File

@@ -58,26 +58,20 @@
- **해결**: 6단계 fallback 전략 (romaji±suffix → 원제±suffix → 한글±suffix) - **해결**: 6단계 fallback 전략 (romaji±suffix → 원제±suffix → 한글±suffix)
- **주의**: 외부 API 검색 시 반드시 다중 전략 + suffix 토글 구현 - **주의**: 외부 API 검색 시 반드시 다중 전략 + suffix 토글 구현
### [2026-03-12] Gemini CLI MCP — settings.json 위치 ### [2026-03-15] _extract_episode — v2/S01E10 패턴 미인식 → 중복 다운로드
- **증상**: MCP 도구가 인식되지 않음 (프로젝트 .gemini/settings.json에 설정했으나 실패) - **증상**: NAS에 ep9, 10이 있는데 재다운로드. ASW `- 10v2` 릴리스 에피소드 추출 실패
- **원인**: Gemini CLI는 `cwd` 기준으로 `.gemini/settings.json`을 탐색. cwd가 다른 워크스페이스면 MCP 설정 못 찾음 - **원인**: 정규식 `[-]\s*(\d{1,4})(?:\s|$|\.|\[)`이 v2 접미사 미처리. `S01E10`은 하이픈 패턴이 `S01``01`을 먼저 매칭
- **해결**: `~/.gemini/settings.json` (홈 레벨)에 mcpServers 등록. `_set_thinking_budget`에서 자동 관리 - **해결**: (1) SxxExx 패턴을 최우선 체크, (2) `(?:v\d)?` 추가로 version suffix 허용, (3) `\(` 추가로 SubsPlease 포맷 지원
- **주의**: MCP 서버 설정은 반드시 홈 레벨 settings.json에 등록. 프로젝트 레벨은 불충분 - **주의**: 에피소드 추출 정규식 수정 시 반드시 v2/v3 릴리스 + SxxExx + 한글(N화) + false positive(29-sai) 테스트 포함
### [2026-03-12] MCP 역할별 접근 제어 — 모든 역할이 MCP 도구 접근 ### [2026-03-15] _add_torrents — 릴리스 그룹 불일치 다운로드
- **증상**: coder, reviewer 등 텍스트 전용 역할도 anime/infra MCP 도구에 접근 가능 - **증상**: NAS에 `[ASW] HEVC` 파일(~300MB)만 있는데 `CR WEB-DL DUAL`(1.4GB) 릴리스를 다운
- **원인**: `_set_thinking_budget()`이 역할 무관하게 모든 MCP 서버를 settings.json에 등록 - **원인**: 스코어링이 ASW에 +100을 주지만, ASW 릴리스가 없는 에피소드에서 아무 릴리스나 선택
- **해결**: `ROLE_MCP_ACCESS` dict 추가, agent만 MCP 등록, 나머지는 제거. `asyncio.Lock` 추가로 settings.json 레이스 방지 - **해결**: NAS 기존 파일의 릴리스 그룹(`[ASW]`)을 감지하여 같은 그룹만 허용. 매칭 없으면 스킵
- **주의**: settings.json은 글로벌 파일이므로, 역할 전환 시 반드시 이전 설정을 정리해야 함 - **주의**: Nyaa 토렌트 제목에 영어+일본어 제목이 모두 포함되어 키워드 필터만으로는 불충분
### [2026-03-12] Gemini CLI yolo — 에이전트 자율성 위험 ### [2026-03-15] _download_subtitles — 기존 자막 덮어쓰기 위험
- **증상**: 애니 다운로드 요청 시 에이전트가 음악/만화를 다운로드하고 엉뚱한 폴더 생성 - **증상**: 이미 수동으로 배치한 자막 파일을 Anissia 자막으로 덮어쓸 수 있음
- **원인**: `--approval-mode yolo`는 MCP 도구 + 쉘 + 파일 조작 모두 무승인 허용. 프롬프트 제한은 강제력 없음 - **원인**: 기존 자막 파일 존재 여부를 확인하지 않고 전 에피소드 자막 다운로드 시도
- **해결 (검토중)**: MCP 대신 Python 도구를 소스코드로 직접 제공하여 Gemini CLI가 읽고 사용하는 방식 검토 - **해결**: NAS 폴더의 기존 자막 파일을 에피소드별로 스캔, 이미 있으면 스킵
- **주의**: 프롬프트는 "부탁"이지 "강제"가 아님. 안전장치는 코드(도구) 레벨에 구현해야 함 - **주의**: 자막 처리 시 사용자 수동 입력 파일의 보존을 항상 고려
### [2026-03-12] Nyaa 검색 — anime 카테고리 미지정
- **증상**: 애니 검색 시 음악, 만화, 라이트노벨 등 무관한 토렌트가 다운로드됨
- **원인**: `NyaaClient.search()` 기본 category가 `0_0`(전체). Music, Manga 등 포함
- **해결 (예정)**: 기본 category를 `1_2`(Anime English) 또는 `1_0`(Anime 전체)로 변경
- **주의**: 외부 검색 API 사용 시 반드시 카테고리/필터를 명시적으로 지정

View File

@@ -1,46 +0,0 @@
---
description: Gitea API로 저장소 커밋/이슈/PR 현황을 조회하는 워크플로우
---
# Gitea 저장소 현황 조회
서비스 정보는 `.agent/workflows/services.md` 참조.
// turbo-all
## 절차
1. 최근 커밋 조회 (최신 10개):
```powershell
$h = @{Authorization="token 3a01b4b15a39921572e64c413353e870d4d2161b"}
$commits = Invoke-RestMethod -Uri "https://git.variet.net/api/v1/repos/Variet/variet-agent/commits?limit=10&sha=main" -Headers $h
$commits | ForEach-Object { Write-Host "$($_.sha.Substring(0,7)) $($_.commit.message.Split("`n")[0])" }
```
2. 열린 이슈 조회:
```powershell
$h = @{Authorization="token 3a01b4b15a39921572e64c413353e870d4d2161b"}
$issues = Invoke-RestMethod -Uri "https://git.variet.net/api/v1/repos/Variet/variet-agent/issues?state=open&type=issues" -Headers $h
$issues | ForEach-Object { Write-Host "#$($_.number) $($_.title)" }
```
3. PR 조회:
```powershell
$h = @{Authorization="token 3a01b4b15a39921572e64c413353e870d4d2161b"}
Invoke-RestMethod -Uri "https://git.variet.net/api/v1/repos/Variet/variet-agent/pulls?state=open" -Headers $h
```
4. Wiki 페이지 목록:
```powershell
C:\ProgramData\miniforge3\envs\variet-agent\python.exe .agent\workflows\helpers\wiki_helper.py list
```
5. Wiki 페이지 읽기:
```powershell
C:\ProgramData\miniforge3\envs\variet-agent\python.exe .agent\workflows\helpers\wiki_helper.py read "Architecture"
```
6. Wiki 페이지 업데이트:
```powershell
C:\ProgramData\miniforge3\envs\variet-agent\python.exe .agent\workflows\helpers\wiki_helper.py update "페이지-제목" /tmp/wiki_content.md
```

View File

@@ -1,55 +0,0 @@
---
description: Vikunja API로 프로젝트 태스크 현황을 조회하는 워크플로우
---
# Vikunja 태스크 현황 조회
서비스 정보는 `.agent/workflows/services.md` 참조.
// turbo-all
## 절차
1. 전체 목록:
```powershell
C:\ProgramData\miniforge3\envs\variet-agent\python.exe .agent\workflows\helpers\vikunja_helper.py list
```
2. TODO만:
```powershell
C:\ProgramData\miniforge3\envs\variet-agent\python.exe .agent\workflows\helpers\vikunja_helper.py list todo
```
3. DONE만:
```powershell
C:\ProgramData\miniforge3\envs\variet-agent\python.exe .agent\workflows\helpers\vikunja_helper.py list done
```
4. 태스크 완료 처리 (반드시 이 방법 사용, 직접 API 호출 금지):
```powershell
C:\ProgramData\miniforge3\envs\variet-agent\python.exe .agent\workflows\helpers\vikunja_helper.py done {TASK_ID}
```
복수 태스크 처리:
```powershell
C:\ProgramData\miniforge3\envs\variet-agent\python.exe .agent\workflows\helpers\vikunja_helper.py done 71 77 78
```
5. 태스크에 코멘트 추가:
```powershell
C:\ProgramData\miniforge3\envs\variet-agent\python.exe .agent\workflows\helpers\vikunja_helper.py comment {TASK_ID} "내용"
```
6. 새 태스크 생성:
```powershell
C:\ProgramData\miniforge3\envs\variet-agent\python.exe .agent\workflows\helpers\vikunja_helper.py create "제목" "설명" --labels Backend,Priority:High
```
7. 태스크에 라벨 추가:
```powershell
C:\ProgramData\miniforge3\envs\variet-agent\python.exe .agent\workflows\helpers\vikunja_helper.py label {TASK_ID} Backend Priority:High
```
> [!CAUTION]
> 절대로 Invoke-RestMethod로 직접 API를 호출하지 마세요.
> Vikunja API는 POST 시 body에 포함되지 않은 필드를 빈값으로 덮어씁니다.
> vikunja_helper.py는 항상 GET 후 기존 필드 보존 후 POST 패턴을 사용합니다.

View File

@@ -8,6 +8,11 @@ description: 모든 구현 작업 전 실행하는 사전 점검 체크리스트
> 코딩을 시작하기 전에 반드시 이 체크리스트를 순서대로 완료하세요. > 코딩을 시작하기 전에 반드시 이 체크리스트를 순서대로 완료하세요.
> 체크리스트를 건너뛸 경우 불필요한 시행착오가 발생합니다. > 체크리스트를 건너뛸 경우 불필요한 시행착오가 발생합니다.
> [!CAUTION]
> **Python 실행 시 반드시 절대경로 사용** (conda 환경이라 `python`이 PATH에 없음):
> `C:\ProgramData\miniforge3\envs\agent_chat\python.exe`
> 절대로 `python`, `py`, `python.exe`를 단독으로 쓰지 마세요.
## 1단계: 요구사항 정리 ## 1단계: 요구사항 정리
- [ ] 유저 요청을 구체적 작업 항목으로 분해 - [ ] 유저 요청을 구체적 작업 항목으로 분해

View File

@@ -0,0 +1,81 @@
---
name: anime_automation
description: >
애니메이션 검색, 자막/영상 다운로드, 편성표 조회, qBittorrent 관리,
NAS 파일 관리. "애니", "자막", "토렌트", "다운로드", "편성표" 등의 키워드 감지 시 활성화.
---
# Anime Automation Skill
## 개요
이 스킬은 애니메이션 관련 자동화 작업을 수행합니다.
## 핵심 워크플로우
```
1. NAS 현황 확인 → 어떤 애니가 있는지, 몇 화까지 있는지 파악
2. 편성표(Anissia) 확인 → 최신 자막 등록 현황 조회
3. 자막 수집 → 제작자 사이트에서 자막 다운로드
4. 토렌트 검색(Nyaa) → ASW + HEVC + 제목으로 magnet 검색
5. 영상 다운로드 → qBittorrent에 magnet 추가 → NAS 폴더에 저장
6. 정리 → 자막 파일명 변경 + 완료된 마그넷 즉시 삭제
```
## 제목 매칭 규칙
⚠️ 소스별로 언어가 다릅니다:
- **NAS 폴더명**: 한글 (예: `[26_1분기]장송의프리렌2기`) — 수정 불가
- **Anissia**: 한글 + 일본어 원제
- **Nyaa**: 영문/로마자 (예: `[ASW] Sousou no Frieren S2 - 07`)
`tools/title_matcher.py`에 카나→로마자 변환 + SequenceMatcher 유사도 매칭이 구현되어 있습니다.
## 도구 사용법
### NAS 현황 확인
```bash
python tools/nas_scanner.py scan # 전체 목록
python tools/nas_scanner.py scan --year 26 --quarter 1 # 분기 필터
python tools/nas_scanner.py search "프리렌" # 키워드 검색
python tools/nas_scanner.py summary # 요약 통계
```
### 편성표 + 자막 조회
```bash
python tools/anissia_client.py schedule 3 # 수요일 편성표
python tools/anissia_client.py search "프리렌" # 애니 검색
python tools/anissia_client.py captions 12345 # 자막 제작자 목록
```
### 토렌트 검색
```bash
python tools/nyaa_client.py search "Sousou no Frieren" --suffix "ASW HEVC"
python tools/nyaa_client.py search "프리렌" --no-suffix # suffix 없이
```
### qBittorrent 관리
```bash
python tools/qbit_client.py status # 다운로드 현황
python tools/qbit_client.py add "magnet:?..." --path "\\NAS\path"
python tools/qbit_client.py delete <hash> # 완료 후 정리
python tools/qbit_client.py test # 연결 테스트
```
### 통합 파이프라인
```bash
python tools/anime_pipeline.py search "프리렌" # 검색 (다운로드 안함)
python tools/anime_pipeline.py download "프리렌" # 자막+영상 자동
python tools/anime_pipeline.py download "프리렌" --episode 10 # 특정 화수
python tools/anime_pipeline.py download "프리렌" --mode sub_only # 자막만
python tools/anime_pipeline.py download "프리렌" --mode video_only # 영상만
python tools/anime_pipeline.py status # 다운로드 큐
```
## 규칙
1. 다운로드 완료 후 마그넷은 **즉시 삭제**
2. NAS 폴더 형식: `[yy_x분기]제목` (예: `[26_1분기]장송의프리렌2기`)
3. 에피소드 선택은 사용자 지시에 따름 (특정 화수/최신화/일괄)
4. 자막 제작자 우선순위 없음 — 모든 자막 수집
5. 영상은 ASW + HEVC 릴리스 우선 검색

5
.gitignore vendored
View File

@@ -5,3 +5,8 @@ sessions/
__pycache__/ __pycache__/
*.pyc *.pyc
.env .env
logs/
.agent/
tests/verify_output.txt
tests/test_output.txt

View File

@@ -100,7 +100,10 @@ class DocsManager:
if changes: if changes:
lines.append("## 변경 파일") lines.append("## 변경 파일")
for c in changes: for c in changes:
lines.append(f"- `{c.get('file', '?')}` - {c.get('description', '')}") if isinstance(c, dict):
lines.append(f"- `{c.get('file', '?')}` - {c.get('description', '')}")
else:
lines.append(f"- {c}")
lines.append("") lines.append("")
warnings = summary.get("warnings", []) warnings = summary.get("warnings", [])

80
core/orchestrator.py Normal file
View File

@@ -0,0 +1,80 @@
"""Orchestrator — 인터페이스 무관 작업 오케스트레이터.
사용자 입력을 받아 NLU 분류 → 모드 분기.
Discord, API, CLI 등 어떤 인터페이스에서든 동일한 로직을 실행합니다.
도구 실행은 Gemini CLI agent가 SKILL.md를 참조하여 직접 수행합니다.
"""
import json
import logging
import re
from core.gemini_caller import GeminiCaller, GeminiCallError
logger = logging.getLogger("variet.orchestrator")
class Orchestrator:
"""인터페이스-무관 오케스트레이터.
흐름:
1. 사용자 입력 + 히스토리 → NLU 통합 분류
2. mode에 따라 분기:
- chat → 즉답 반환
- clarify → 질문 반환
- anime → AnimePipeline 직접 호출
- task → TaskPipeline 실행 (핸들러에서 처리)
"""
def __init__(self):
pass
async def classify(
self,
user_input: str,
history: str = "",
project_path: str = "",
) -> dict:
"""통합 프롬프트로 의도 분류.
Returns:
분류 결과 dict: {mode, response?, action?, title?, ...}
"""
gemini = GeminiCaller(project_path)
from core.docs_manager import DocsManager
docs = DocsManager(project_path) if project_path else None
docs_index = docs.get_docs_index() if docs else ""
context = (
f"{history}"
f"## Workspace\nPath: {project_path}\n\n"
f"## Project Docs\n{docs_index}\n\n"
f"## User Message\n{user_input}"
)
raw = await gemini.call("unified", context, timeout=120)
# JSON 추출
try:
match = re.search(r'```json\s*\n(.*?)\n\s*```', raw, re.DOTALL)
if match:
return json.loads(match.group(1))
brace_depth = 0
start = -1
for i, ch in enumerate(raw):
if ch == '{':
if brace_depth == 0:
start = i
brace_depth += 1
elif ch == '}':
brace_depth -= 1
if brace_depth == 0 and start >= 0:
return json.loads(raw[start:i + 1])
except (json.JSONDecodeError, AttributeError):
pass
logger.warning(f"통합 프롬프트 JSON 파싱 실패: {raw[:100]}")
return {"mode": "chat", "response": raw}

View File

@@ -1,8 +1,7 @@
"""Task Pipeline -- Plan -> Code(에이전트) -> Review -> 재시도 -> 총평 -> 기록. """Task Pipeline -- Agent 1회 호출 + 선택적 Review.
Coder는 에이전트 모드로 프로젝트 디렉토리에서 실행되어 Gemini CLI agent 모드가 plan+code+verify를 한 세션에서 수행합니다.
Gemini가 직접 파일을 읽고/쓰고/명령을 실행합니다. 기존 5단계(Plan→Code→PlannerVerify→Review→Summarize) → 2단계로 단순화.
리뷰 실패 시 최대 MAX_REVIEW_RETRIES회 재시도합니다.
""" """
import asyncio import asyncio
@@ -15,12 +14,11 @@ from core.context_manager import ContextManager
from core.gemini_caller import GeminiCaller, GeminiCallError from core.gemini_caller import GeminiCaller, GeminiCallError
from core.docs_manager import DocsManager from core.docs_manager import DocsManager
MAX_REVIEW_RETRIES = 2
logger = logging.getLogger("variet.pipeline") logger = logging.getLogger("variet.pipeline")
class TaskPipeline: class TaskPipeline:
"""작업 파이프라인: Plan -> Code(에이전트) -> Review(재시도) -> 기록.""" """작업 파이프라인: Agent 1회 호출 → 선택적 Review."""
def __init__(self, project_path: str, token_budget: int = 50_000, def __init__(self, project_path: str, token_budget: int = 50_000,
docs_subpath: str = "docs/wiki"): docs_subpath: str = "docs/wiki"):
@@ -37,7 +35,7 @@ class TaskPipeline:
return self return self
# ────────────────────────────────────────── # ──────────────────────────────────────────
# Docs 컨텍스트 (모든 호출에 주입) # Docs 컨텍스트
# ────────────────────────────────────────── # ──────────────────────────────────────────
def _docs_context(self) -> str: def _docs_context(self) -> str:
@@ -50,137 +48,79 @@ class TaskPipeline:
) )
# ────────────────────────────────────────── # ──────────────────────────────────────────
# Plan # Agent 통합 실행 (핵심)
# ────────────────────────────────────────── # ──────────────────────────────────────────
async def plan(self, user_request: str) -> dict: async def execute(self, user_request: str, history: str = "",
"""Planner로 태스크 분해 (에이전트 모드 — 직접 처리 가능).""" progress_callback=None, role: str = "agent",
context = self.ctx.gather(user_request) timeout: int = None) -> dict:
docs_ctx = self._docs_context() """Agent 1회 호출 — plan+code+verify+report 통합.
prompt = ( Args:
f"## User Request\n{user_request}\n\n" role: 'agent'(코딩) 또는 'operator'(도구 실행)
f"## Project Context\n{context}\n\n" progress_callback: async callable(status_text) — 진행 상태 콜백
f"## Project Docs\n{docs_ctx}\n\n" timeout: 타임아웃(초). None이면 operator=180, agent=600
f"Analyze this request. If simple, handle it directly (direct: true). "
f"If complex, decompose into tasks (direct: false)."
)
response = await self.gemini.call_agent( Returns:
"planner", prompt, cwd=self.project_path, timeout=180, dict: {title, summary, changes, verified, warnings, next_steps}
)
self._log("plan", user_request, response)
plan = self._extract_json(response)
return plan or {"summary": response, "tasks": [], "raw": response}
# ──────────────────────────────────────────
# Code (에이전트 모드 — Gemini가 직접 파일 쓰기)
# ──────────────────────────────────────────
async def code(self, task: dict) -> str:
"""에이전트 모드로 코딩 — Gemini가 직접 파일 생성/수정."""
docs_ctx = self._docs_context()
prompt = (
f"## Task\n{json.dumps(task, ensure_ascii=False, indent=2)}\n\n"
f"## Project Docs\n{docs_ctx}\n\n"
f"위 태스크를 구현하세요. 파일을 직접 생성/수정하세요."
)
response = await self.gemini.call_agent(
"coder", prompt, cwd=self.project_path, timeout=300,
)
self._log("code", task.get("title", ""), response)
return response
# ──────────────────────────────────────────
# Code 병렬 실행
# ──────────────────────────────────────────
async def code_parallel(self, tasks: list[dict]) -> list[str]:
"""여러 태스크를 병렬로 코딩 (에이전트 모드)."""
results = await asyncio.gather(
*[self.code(task) for task in tasks],
return_exceptions=True,
)
processed = []
for i, result in enumerate(results):
if isinstance(result, Exception):
error_msg = f"[ERROR] Task {i+1} 실패: {result}"
self._log("code_error", tasks[i].get("title", ""), error_msg)
processed.append(error_msg)
else:
processed.append(result)
return processed
# ──────────────────────────────────────────
# Planner 자가 검증 (오케스트레이션)
# ──────────────────────────────────────────
async def planner_verify(
self, user_request: str, plan: dict,
code_outputs: list[str],
) -> dict:
"""Planner가 자기 계획의 달성 여부를 에이전트 모드로 검증.
프로젝트 디렉토리에서 직접 파일을 읽어서 계획 충족 여부를 판단합니다.
""" """
agent_reports = "\n".join( if timeout is None:
f"--- Agent {i+1} ---\n{output}" timeout = 600
for i, output in enumerate(code_outputs)
)
prompt = ( # operator 모드는 프로젝트 컨텍스트/문서 불필요 (도구만 실행)
f"## 원래 사용자 요청\n{user_request}\n\n" if role == "operator":
f"## 내가 세운 계획\n{json.dumps(plan, ensure_ascii=False, indent=2)}\n\n" history_section = ""
f"## 에이전트 보고\n{agent_reports}\n\n" if history:
f"## 판단 요청\n" history_section = f"## 대화 히스토리\n{history}\n\n"
f"현재 디렉토리의 프로젝트 파일을 직접 읽어서 계획이 충족되었는지 확인하세요.\n" prompt = (
f"필요한 파일만 선택적으로 읽으세요.\n\n" f"{history_section}"
f"충족되었으면 satisfied=true.\n" f"## 사용자 요청\n{user_request}\n\n"
f"미충족이면 satisfied=false + 부족한 부분을 해결할 추가 태스크를 생성하세요.\n\n" f"위 요청에 맞는 도구를 바로 실행하고 결과를 JSON으로 보고하세요."
f"반드시 아래 JSON만 출력하세요:\n" )
f"```json\n" else:
f'{{\n' context = self.ctx.gather(user_request)
f' "satisfied": true|false,\n' docs_ctx = self._docs_context()
f' "feedback": "판단 근거 (한국어)",\n' history_section = ""
f' "additional_tasks": [\n' if history:
f' {{"id": 1, "title": "추가 태스크", "description": "구현 내용", "type": "modify"}}\n' history_section = f"## 대화 히스토리\n{history}\n\n"
f' ]\n' prompt = (
f'}}\n' f"{history_section}"
f"```" f"## 사용자 요청\n{user_request}\n\n"
) f"## 프로젝트 컨텍스트\n{context}\n\n"
f"## 프로젝트 문서\n{docs_ctx}\n\n"
f"위 요청을 분석하고, 계획을 세우고, 필요한 도구를 실행하고, "
f"결과를 JSON 보고서로 출력하세요."
)
response = await self.gemini.call_agent( response = await self.gemini.call_agent(
"planner", prompt, cwd=self.project_path, timeout=180, role, prompt, cwd=self.project_path, timeout=timeout,
progress_callback=progress_callback,
) )
self._log("planner_verify", user_request, response) self._log("execute", user_request, response)
result = self._extract_json(response) result = self._extract_json(response)
return result or {"satisfied": True, "feedback": response} return result or {
"title": "작업 완료",
"summary": response[:500],
"changes": [],
"verified": False,
"warnings": ["JSON 보고서 파싱 실패 — 원본 응답 참조"],
"next_steps": [],
"raw": response,
}
# ────────────────────────────────────────── # ──────────────────────────────────────────
# Batch Review # 선택적 독립 리뷰
# ────────────────────────────────────────── # ──────────────────────────────────────────
async def batch_review(self, tasks: list[dict], code_outputs: list[str]) -> dict: async def review(self, user_request: str, agent_report: str) -> dict:
"""에이전트 모드로 프로젝트 파일을 직접 읽어 리뷰.""" """독립 리뷰어 — agent 작업 결과를 검증.
task_summaries = []
for i, task in enumerate(tasks):
title = task.get("title", task.get("description", f"Task {i+1}"))
task_summaries.append(f"### Task {i+1}: {title}")
agent_reports = []
for i, output in enumerate(code_outputs):
agent_reports.append(f"--- Agent {i+1} 보고 ---\n{output}")
agent가 자체 검증을 하지만, 중요한 작업에는 독립 리뷰를 추가할 수 있음.
"""
prompt = ( prompt = (
f"## 요청된 태스크\n{chr(10).join(task_summaries)}\n\n" f"## 원래 사용자 요청\n{user_request}\n\n"
f"## 에이전트 보고\n{chr(10).join(agent_reports)}\n\n" f"## Agent 보고\n{agent_report}\n\n"
f"현재 디렉토리의 프로젝트 파일을 직접 읽어서 리뷰하세요.\n" f"현재 디렉토리의 프로젝트 파일을 직접 읽어서 리뷰하세요.\n"
f"필요한 파일만 선택적으로 확인하세요." f"필요한 파일만 선택적으로 확인하세요."
) )
@@ -188,41 +128,33 @@ class TaskPipeline:
response = await self.gemini.call_agent( response = await self.gemini.call_agent(
"reviewer", prompt, cwd=self.project_path, timeout=300, "reviewer", prompt, cwd=self.project_path, timeout=300,
) )
self._log("batch_review", f"{len(tasks)} tasks", response) self._log("review", user_request, response)
review = self._extract_json(response) result = self._extract_json(response)
return review or {"passed": True, "summary": response, "raw": response} return result or {"passed": True, "summary": response, "raw": response}
# ────────────────────────────────────────── # ──────────────────────────────────────────
# 총평 # NLU 분류 (chat/task/anime 구분에 사용)
# ────────────────────────────────────────── # ──────────────────────────────────────────
async def summarize(self, user_request: str, plan: dict, async def classify(self, user_input: str, history: str = "") -> dict:
code_outputs: list[str], review: dict) -> dict: """통합 프롬프트로 의도 분류 (Orchestrator에서 위임).
"""전체 작업 결과 종합 총평."""
prompt = ( 이전 _unified_call의 역할을 담당합니다.
f"## 원래 요청\n{user_request}\n\n" """
f"## 태스크 수\n{len(plan.get('tasks', []))}\n\n" docs_ctx = self._docs_context()
f"## 리뷰 결과\n{review.get('summary', str(review))}\n\n"
f"## 코딩 결과 요약\n" context = (
f"{chr(10).join(code_outputs)}\n\n" f"{history}"
f"위 정보를 바탕으로 총평을 작성하세요." f"## Workspace\nPath: {self.project_path}\n\n"
f"## Project Docs\n{docs_ctx}\n\n"
f"## User Message\n{user_input}"
) )
response = await self.gemini.call_agent( raw = await self.gemini.call("unified", context, timeout=120)
"summarizer", prompt, cwd=self.project_path, timeout=120,
)
self._log("summarize", user_request, response)
summary = self._extract_json(response)
return summary or {
"title": "작업 완료",
"summary": response,
"changes": [],
"warnings": [],
"next_steps": [],
}
result = self._extract_json(raw)
return result or {"mode": "chat", "response": raw}
# ────────────────────────────────────────── # ──────────────────────────────────────────
# 유틸리티 # 유틸리티

5
docs/changelog.md Normal file
View File

@@ -0,0 +1,5 @@
# Changelog
- [2026-03-14 20:40] 이번 분기 애니메이션 영상 및 자막 업데이트
- [2026-03-14 21:20] 이번 분기 애니메이션 영상 및 자막 업데이트 완료
- [2026-03-15 07:59] 이번 분기 애니메이션 영상 및 자막 업데이트 완료

View File

@@ -0,0 +1,5 @@
# 2026-03-15 Devlog
| # | 시간 | 작업 | 커밋 | 상태 |
|---|------|------|------|------|
| 1 | 07:00~08:24 | 애니 파이프라인 중복 다운로드 버그 5건 수정 (v2 정규식, 릴리스 그룹 필터, 자막 보호, 배치 다운로드, 타임아웃) | `42d0d81` | ✅ |

View File

@@ -0,0 +1,19 @@
# 애니 파이프라인 중복 다운로드 버그 5건 수정
- **시간**: 2026-03-15 07:00~08:24
- **Commit**: `42d0d81`
- **Vikunja**: 신규 생성
## 결정 사항
- `_extract_episode()`: SxxExx 패턴을 최우선으로 체크하고 v2 suffix 허용
- `_add_torrents()`: NAS 기존 파일의 릴리스 그룹(Counter로 과반 판정) 기준 필터링
- `_download_subtitles()`: 기존 자막이 있는 에피소드는 무조건 스킵 (수동 자막 보호)
- `batch_download()`: operator.md의 수동 루프 패턴 대신 단일 CLI 명령으로 통합
- operator 타임아웃: anime/task 구분 없이 600초 통일
## 수정 파일
- `tools/anime_pipeline.py` — 정규식 + 릴리스 그룹 필터 + 자막 스킵 + batch 메서드
- `handlers/task_handler.py` — 타임아웃 180→600
- `prompts/operator.md` — batch 명령 추가
- `core/docs_manager.py` — changes 배열 str/dict 양쪽 처리
- `.agents/references/known-issues.md` — 3건 추가

1
handlers/__init__.py Normal file
View File

@@ -0,0 +1 @@
# handlers — Discord Bot 핸들러 패키지.

229
handlers/anime_handler.py Normal file
View File

@@ -0,0 +1,229 @@
"""애니메이션 핸들러 — Discord Bot에서 분리된 애니 관련 처리.
AnimePipeline을 직접 호출하여 결과를 Discord Embed로 렌더링합니다.
NLU에서 mode="anime"로 분류된 요청도 처리합니다.
"""
import logging
import discord
from discord import app_commands
from handlers.renderer import safe_send_embed
logger = logging.getLogger("variet.handlers.anime")
async def handle_anime_message(
message: discord.Message,
parsed: dict,
):
"""NLU에서 anime으로 분류된 메시지 처리.
Args:
message: Discord 메시지
parsed: NLU 분류 결과 dict {mode, action, title, episode?, ...}
"""
from tools.anime_pipeline import AnimePipeline
pipeline = AnimePipeline()
action = parsed.get("action", "search")
title = parsed.get("title", parsed.get("query", ""))
async with message.channel.typing():
try:
if action in ("list", "scan"):
# NAS 현황 조회
from tools.nas_scanner import NasScanner
scanner = NasScanner()
if not scanner.is_accessible():
await message.channel.send(embed=discord.Embed(
title="❌ NAS 접근 불가",
description=f"경로: `{scanner.base_path}`",
color=0xE74C3C,
))
return
# title이 있으면 키워드 검색, 없으면 이번 분기
if title:
folders = scanner.search(title)
label = f"'{title}' 검색 결과"
# 키워드 검색 0건이면 이번 분기로 fallback
if not folders:
folders = scanner.get_current_quarter_anime()
label = "이번 분기 애니"
else:
folders = scanner.get_current_quarter_anime()
label = "이번 분기 애니"
if not folders:
await message.channel.send(embed=discord.Embed(
title=f"📁 {label}",
description="해당하는 폴더가 없습니다.",
color=0xF39C12,
))
return
desc_lines = []
for f in folders[:15]:
sub_info = f"자막 {f.subtitle_count}" if f.subtitle_count else "자막 없음"
desc_lines.append(
f"• `{f.folder_name}`\n 영상 {f.video_count}개 | {sub_info} | {f.total_size_gb:.1f}GB"
)
embed = discord.Embed(
title=f"📁 {label} ({len(folders)}개)",
description="\n".join(desc_lines)[:2000],
color=0x3498DB,
)
await safe_send_embed(message.channel, embed)
return
elif action == "search" and title:
result = await pipeline.search(title)
elif action == "search" and not title:
await message.channel.send(embed=discord.Embed(
title="🔍 애니 검색",
description="검색할 제목을 입력해주세요.\n예: `프리렌 검색해줘`",
color=0xF39C12,
))
return
elif action == "download" and title:
mode = parsed.get("download_mode", "auto")
episode = parsed.get("episode")
result = await pipeline.download(title, mode=mode, episode=episode)
elif action == "status":
status = await pipeline.get_status()
if not status:
await message.channel.send(embed=discord.Embed(
title="🎬 다운로드 현황",
description="다운로드 중인 항목 없음",
color=0x3498DB,
))
return
desc = "\n".join(
f"{s['progress']} `{s['name'][:40]}` {s['speed']}"
for s in status
)
await message.channel.send(embed=discord.Embed(
title=f"🎬 다운로드 현황 ({len(status)}건)",
description=desc[:2000],
color=0x3498DB,
))
return
else:
# 알 수 없는 action → search로 fallback
if title:
result = await pipeline.search(title)
else:
await message.channel.send(embed=discord.Embed(
title="❓ 애니 명령",
description="무엇을 도와드릴까요?\n• 목록 조회: `NAS에 뭐있어?`\n• 검색: `프리렌 검색`\n• 다운로드: `프리렌 10화 받아줘`\n• 상태: `다운로드 현황`",
color=0xF39C12,
))
return
# 결과 임베드
embed = discord.Embed(
title=f"🎬 {result.message[:100]}" if result.message else "🎬 결과",
description=result.message[:2000] if result.message else "완료",
color=0x2ECC71 if result.success else 0xE74C3C,
)
if result.errors:
embed.add_field(
name="⚠️ 오류",
value="\n".join(f"{e}" for e in result.errors[:5])[:1000],
inline=False,
)
await safe_send_embed(message.channel, embed)
except Exception as e:
logger.error(f"애니 핸들러 오류: {e}", exc_info=True)
await message.channel.send(embed=discord.Embed(
title="❌ 애니 처리 오류",
description=f"```{str(e)[:300]}```",
color=0xE74C3C,
))
def register_anime_commands(bot, ws_manager):
"""애니메이션 슬래시 커맨드 등록."""
anime_group = app_commands.Group(name="anime", description="애니메이션 자막/영상 자동화")
@anime_group.command(name="search", description="애니 검색 (편성표 + 자막 + 토렌트)")
@app_commands.describe(title="검색할 애니 제목 (한글)")
async def anime_search(interaction: discord.Interaction, title: str):
await interaction.response.defer()
from tools.anime_pipeline import AnimePipeline
pipeline = AnimePipeline()
result = await pipeline.search(title)
embed = discord.Embed(
title=f"🔍 {result.anime.subject}" if result.anime else f"🔍 {title}",
description=result.message[:2000],
color=0x2ECC71 if result.success else 0xE74C3C,
)
await interaction.followup.send(embed=embed)
@anime_group.command(name="download", description="자막+영상 자동 다운로드")
@app_commands.describe(title="애니 제목 (한글)", episode="특정 화수 (없으면 최신)")
async def anime_download(interaction: discord.Interaction, title: str, episode: int = None):
await interaction.response.defer()
from tools.anime_pipeline import AnimePipeline
pipeline = AnimePipeline()
result = await pipeline.download(title, mode="auto", episode=episode)
embed = discord.Embed(
title=f"📥 {title}",
description=result.message[:2000],
color=0x2ECC71 if result.success else 0xE74C3C,
)
await interaction.followup.send(embed=embed)
@anime_group.command(name="sub", description="자막만 다운로드")
@app_commands.describe(title="애니 제목 (한글)", episode="특정 화수")
async def anime_sub(interaction: discord.Interaction, title: str, episode: int = None):
await interaction.response.defer()
from tools.anime_pipeline import AnimePipeline
pipeline = AnimePipeline()
result = await pipeline.download(title, mode="sub_only", episode=episode)
embed = discord.Embed(
title=f"📝 자막: {title}",
description=result.message[:2000],
color=0x2ECC71 if result.success else 0xE74C3C,
)
await interaction.followup.send(embed=embed)
@anime_group.command(name="video", description="영상만 다운로드 (자막 없어도 강제)")
@app_commands.describe(title="애니 제목 (한글)", episode="특정 화수")
async def anime_video(interaction: discord.Interaction, title: str, episode: int = None):
await interaction.response.defer()
from tools.anime_pipeline import AnimePipeline
pipeline = AnimePipeline()
result = await pipeline.download(title, mode="video_only", episode=episode)
embed = discord.Embed(
title=f"🎬 영상: {title}",
description=result.message[:2000],
color=0x2ECC71 if result.success else 0xE74C3C,
)
await interaction.followup.send(embed=embed)
@anime_group.command(name="status", description="현재 다운로드 큐 상태")
async def anime_status(interaction: discord.Interaction):
await interaction.response.defer()
from tools.anime_pipeline import AnimePipeline
pipeline = AnimePipeline()
status = await pipeline.get_status()
if not status:
desc = "다운로드 중인 항목 없음"
else:
desc = "\n".join(
f"{s['progress']} `{s['name'][:40]}` {s['speed']}"
for s in status
)
embed = discord.Embed(
title=f"🎬 다운로드 현황 ({len(status)}건)",
description=desc[:2000],
color=0x3498DB,
)
await interaction.followup.send(embed=embed)
bot.tree.add_command(anime_group)
logger.info("애니메이션 슬래시 커맨드 등록 완료")

406
handlers/commands.py Normal file
View File

@@ -0,0 +1,406 @@
"""슬래시 커맨드 — /workspace, /task 등 (discord_bot.py에서 분리).
워크스페이스 관리, 프로젝트 선택, 스레드 생성 등
Discord UI 인터랙션을 담당합니다.
"""
import logging
from datetime import datetime
from pathlib import Path
import discord
from discord import app_commands
logger = logging.getLogger("variet.handlers.commands")
def register_workspace_commands(bot, ws_manager):
"""워크스페이스 관련 슬래시 커맨드 등록."""
import config
workspace_group = app_commands.Group(name="workspace", description="워크스페이스 관리")
@workspace_group.command(name="set", description="이 채널에 워크스페이스 등록")
@app_commands.describe(name="프로젝트 이름 (미입력 시 채널 이름 사용)", path="로컬 경로 (미입력 시 VW_Proj/{name}에 자동 생성)")
async def workspace_set(interaction: discord.Interaction, name: str = "", path: str = ""):
if not name:
name = interaction.channel.name
conflicts = ws_manager.find_by_name(name)
if conflicts:
old = conflicts[0]
embed = discord.Embed(
title="⚠️ 이름 충돌",
description=(
f"**{name}** 이름의 프로젝트가 이미 존재합니다.\n\n"
f"기존 등록: 채널 <#{old.channel_id}>\n"
f"경로: `{old.path}`\n\n"
f"**선택지:**\n"
f"1⃣ 다른 이름으로 등록: `/workspace set name:새이름`\n"
f"2⃣ 기존 프로젝트를 삭제 후 재등록: 기존 채널에서 `/workspace remove`"
),
color=0xF39C12,
)
await interaction.response.send_message(embed=embed)
return
if path and not Path(path).parent.exists():
await interaction.response.send_message(
f"❌ 부모 경로가 존재하지 않습니다: `{path}`", ephemeral=True
)
return
ws = ws_manager.set_workspace(interaction.channel_id, name, path)
embed = discord.Embed(
title="✅ 워크스페이스 등록 완료",
description=(
f"**{name}** -> `{ws.path}`\n\n"
f"이 채널에서 봇과 대화할 수 있습니다.\n"
f"작업 실행을 위해 Git과 Vikunja를 설정해주세요:"
),
color=0x2ECC71,
)
embed.add_field(name="Git 설정", value="`/workspace git`", inline=True)
embed.add_field(name="Vikunja 설정", value="`/workspace vikunja`", inline=True)
await interaction.response.send_message(embed=embed)
@workspace_group.command(name="git", description="Git 연결 설정")
@app_commands.describe(url="Git 서버 URL", token="API 토큰", repo="Owner/Repo", branch="기본 브랜치")
async def workspace_git(interaction: discord.Interaction, url: str, token: str,
repo: str = "", branch: str = "main"):
ws = ws_manager.get_workspace(interaction.channel_id)
if not ws:
await interaction.response.send_message(
"❌ 이 채널에 워크스페이스가 없습니다. `/workspace set`을 먼저 실행하세요.",
ephemeral=True,
)
return
ws_manager.set_git(interaction.channel_id, url, token, repo, branch)
embed = discord.Embed(
title="✅ Git 연결 완료",
description=f"**{ws.name}** → {url}\nRepo: `{repo or '미지정'}`\nBranch: `{branch}`",
color=0x2ECC71,
)
await interaction.response.send_message(embed=embed)
@workspace_group.command(name="vikunja", description="Vikunja 프로젝트 관리 연결")
@app_commands.describe(url="Vikunja URL", token="API 토큰", project_id="프로젝트 ID")
async def workspace_vikunja(interaction: discord.Interaction, url: str, token: str,
project_id: int):
ws = ws_manager.get_workspace(interaction.channel_id)
if not ws:
await interaction.response.send_message(
"❌ 이 채널에 워크스페이스가 없습니다. `/workspace set`을 먼저 실행하세요.",
ephemeral=True,
)
return
ws_manager.set_vikunja(interaction.channel_id, url, token, project_id)
embed = discord.Embed(
title="✅ Vikunja 연결 완료",
description=f"**{ws.name}** → {url}\nProject ID: `{project_id}`",
color=0x2ECC71,
)
await interaction.response.send_message(embed=embed)
@workspace_group.command(name="info", description="현재 워크스페이스 정보 표시")
async def workspace_info(interaction: discord.Interaction):
ws = ws_manager.get_workspace(interaction.channel_id)
if not ws:
await interaction.response.send_message(
"이 채널에 등록된 워크스페이스가 없습니다.\n`/workspace set`으로 등록하세요.",
ephemeral=True,
)
return
git_status = f"{ws.git.url}" if ws.git.is_configured else "❌ 미설정"
vik_status = f"{ws.vikunja.url} (project {ws.vikunja.project_id})" if ws.vikunja.is_configured else "❌ 미설정"
embed = discord.Embed(
title=f"📂 {ws.name}",
description=f"경로: `{ws.path}`\nDocs: `{ws.docs_path}`",
color=0x3498DB if ws.is_ready else 0xF39C12,
)
embed.add_field(name="Git", value=git_status, inline=False)
embed.add_field(name="Vikunja", value=vik_status, inline=False)
embed.add_field(
name="상태",
value="✅ 모든 설정 완료 — 작업 가능" if ws.is_ready else "⚠️ 설정 미완료 — 작업 차단됨",
inline=False,
)
await interaction.response.send_message(embed=embed)
@workspace_group.command(name="remove", description="워크스페이스 등록 해제")
async def workspace_remove(interaction: discord.Interaction):
ws = ws_manager.get_workspace(interaction.channel_id)
if not ws:
await interaction.response.send_message(
"이 채널에 등록된 워크스페이스가 없습니다.", ephemeral=True
)
return
name = ws.name
ws_manager.remove_workspace(interaction.channel_id)
await interaction.response.send_message(
embed=discord.Embed(
title="🗑️ 워크스페이스 해제",
description=f"**{name}** 등록이 해제되었습니다.\n이 채널에서 봇이 더 이상 자동 응답하지 않습니다.",
color=0x95A5A6,
)
)
@workspace_group.command(name="list", description="등록된 전체 워크스페이스 목록")
async def workspace_list(interaction: discord.Interaction):
all_ws = ws_manager.list_all()
if not all_ws:
await interaction.response.send_message(
"등록된 워크스페이스가 없습니다.\n`/workspace set`으로 등록하세요.",
ephemeral=True,
)
return
embed = discord.Embed(title="📂 워크스페이스 목록", color=0x3498DB)
for ws in all_ws:
status = "" if ws.is_ready else "⚠️"
embed.add_field(
name=f"{status} {ws.name}",
value=f"채널: <#{ws.channel_id}>\n경로: `{ws.path}`",
inline=False,
)
await interaction.response.send_message(embed=embed)
bot.tree.add_command(workspace_group)
logger.info("워크스페이스 슬래시 커맨드 등록 완료")
def register_task_command(bot, ws_manager, _project_threads, _thread_workspaces, _unified_call_fn):
"""프로젝트 선택 + 스레드 생성 슬래시 커맨드 등록."""
class ProjectSelectView(discord.ui.View):
def __init__(self, request_text: str):
super().__init__(timeout=60)
self.request_text = request_text
options = []
for ws in ws_manager.list_all():
label = ws.name[:100]
desc = ws.path[:100]
options.append(discord.SelectOption(label=label, description=desc, value=str(ws.channel_id)))
if not options:
options.append(discord.SelectOption(label="(등록된 프로젝트 없음)", value="__none__"))
select = discord.ui.Select(
placeholder="프로젝트를 선택하세요...",
options=options[:25],
)
select.callback = self.on_select
self.add_item(select)
async def on_select(self, interaction: discord.Interaction):
selected_value = interaction.data["values"][0]
if selected_value == "__none__":
await interaction.response.send_message(
"❌ 등록된 프로젝트가 없습니다. `/workspace set`으로 먼저 등록하세요.",
ephemeral=True,
)
return
ws = ws_manager.get_workspace(int(selected_value))
if not ws:
await interaction.response.send_message("❌ 워크스페이스를 찾을 수 없습니다.", ephemeral=True)
return
if ws.name in _project_threads:
thread_id = _project_threads[ws.name]
try:
thread = interaction.guild.get_thread(thread_id)
if thread and not thread.archived:
await interaction.response.send_message(
f"📌 **{ws.name}** 프로젝트는 이미 열린 대화가 있습니다: <#{thread_id}>\n"
f"요청을 해당 스레드에 전달합니다.",
ephemeral=True,
)
await thread.send(
f"📨 **새 요청** ({interaction.user.display_name}):\n```{self.request_text[:500]}```"
)
return
else:
_project_threads.pop(ws.name, None)
_thread_workspaces.pop(thread_id, None)
except Exception:
_project_threads.pop(ws.name, None)
project_path = Path(ws.path)
if project_path.exists() and any(project_path.iterdir()):
view = ConflictView(ws, self.request_text, interaction)
await interaction.response.send_message(
embed=discord.Embed(
title=f"📂 {ws.name} — 기존 프로젝트 발견",
description=(
f"경로: `{ws.path}`\n\n"
f"기존 프로젝트를 이어가시겠습니까, 새로 시작하시겠습니까?"
),
color=0xF39C12,
),
view=view,
ephemeral=True,
)
return
await interaction.response.defer()
await _create_task_thread(interaction, ws, self.request_text,
_project_threads, _thread_workspaces, _unified_call_fn)
class ConflictView(discord.ui.View):
def __init__(self, ws, request_text, original_interaction):
super().__init__(timeout=60)
self.ws = ws
self.request_text = request_text
self.original_interaction = original_interaction
@discord.ui.button(label="🔄 이어가기", style=discord.ButtonStyle.primary)
async def continue_project(self, interaction, button):
await interaction.response.defer()
await _create_task_thread(interaction, self.ws, self.request_text,
_project_threads, _thread_workspaces, _unified_call_fn)
self.stop()
@discord.ui.button(label="🆕 새로 시작", style=discord.ButtonStyle.secondary)
async def new_project(self, interaction, button):
old_path = Path(self.ws.path)
suffix = f"_archived_{datetime.now().strftime('%Y%m%d')}"
new_archived_path = old_path.parent / f"{old_path.name}{suffix}"
counter = 1
while new_archived_path.exists():
new_archived_path = old_path.parent / f"{old_path.name}{suffix}_{counter}"
counter += 1
try:
old_path.rename(new_archived_path)
logger.info(f"프로젝트 아카이브: {old_path}{new_archived_path}")
except OSError as e:
logger.error(f"폴더 아카이브 실패: {e}")
await interaction.response.send_message(f"❌ 폴더 아카이브 실패: {e}", ephemeral=True)
return
archived_name = new_archived_path.name
ws_manager.set_workspace(
channel_id=-abs(hash(archived_name)) % (10**10),
name=archived_name,
path=str(new_archived_path),
)
old_path.mkdir(parents=True, exist_ok=True)
await interaction.response.defer()
await _create_task_thread(interaction, self.ws, self.request_text,
_project_threads, _thread_workspaces, _unified_call_fn)
self.stop()
@bot.tree.command(name="task", description="프로젝트를 선택하고 작업 요청")
@app_commands.describe(request="작업 요청 내용")
async def task_command(interaction: discord.Interaction, request: str = ""):
all_ws = ws_manager.list_all()
if not all_ws:
await interaction.response.send_message(
"❌ 등록된 프로젝트가 없습니다. `/workspace set`으로 먼저 등록하세요.",
ephemeral=True,
)
return
view = ProjectSelectView(request)
await interaction.response.send_message(
embed=discord.Embed(
title="📂 프로젝트 선택",
description=(
f"작업할 프로젝트를 선택하세요.\n"
+ (f"**요청:** {request[:200]}" if request else "선택 후 스레드에서 요청할 수 있습니다.")
),
color=0x3498DB,
),
view=view,
ephemeral=True,
)
logger.info("/task 슬래시 커맨드 등록 완료")
async def _create_task_thread(
interaction, ws, request_text,
_project_threads, _thread_workspaces, _unified_call_fn,
):
"""스레드를 생성하고 작업을 시작합니다."""
thread_name = f"🔧 {ws.name}"
if request_text:
short_req = request_text[:40].replace("\n", " ")
thread_name = f"🔧 {ws.name}{short_req}"
thread_name = thread_name[:100]
channel = interaction.channel
thread = await channel.create_thread(
name=thread_name,
type=discord.ChannelType.public_thread,
auto_archive_duration=1440,
)
_project_threads[ws.name] = thread.id
_thread_workspaces[thread.id] = ws
logger.info(f"작업 스레드 생성: {thread.name} (ID: {thread.id}) → {ws.name}")
start_embed = discord.Embed(
title=f"📂 {ws.name}",
description=(
f"경로: `{ws.path}`\n\n"
f"**요청:** {request_text[:500]}\n\n"
f"이 스레드에서 대화를 이어갈 수 있습니다."
),
color=0x3498DB,
)
await thread.send(embed=start_embed)
await interaction.followup.send(
f"✅ 스레드가 생성되었습니다: <#{thread.id}>",
ephemeral=True,
)
if request_text.strip():
try:
async with thread.typing():
result = await _unified_call_fn(request_text, "", ws.path)
mode = result.get("mode", "chat")
logger.info(f"[스레드] 통합 분류: {mode} - \"{request_text[:50]}\"")
if mode == "chat":
response = result.get("response", "응답을 생성하지 못했습니다.")
if len(response) <= 2000:
await thread.send(response)
else:
for i in range(0, len(response), 4000):
chunk = response[i:i + 4000]
embed = discord.Embed(description=chunk, color=0x3498DB)
await thread.send(embed=embed)
elif mode == "clarify":
question = result.get("question", "더 구체적으로 말씀해 주시겠어요?")
embed = discord.Embed(
title="🤔 확인이 필요해요",
description=question,
color=0xF39C12,
)
await thread.send(embed=embed)
else:
await thread.send(
embed=discord.Embed(
title="⚙️ 작업 모드 감지",
description="이 스레드에서 작업 요청을 다시 입력해주세요.\n스레드 내 메시지는 자동으로 처리됩니다.",
color=0xF39C12,
)
)
except Exception as e:
logger.error(f"스레드 초기 호출 오류: {e}", exc_info=True)
await thread.send(f"⚠️ 초기 호출 오류: {str(e)[:200]}")

25
handlers/renderer.py Normal file
View File

@@ -0,0 +1,25 @@
"""Discord 렌더러 — Embed 렌더링 유틸리티.
Discord Embed의 제한(4096자)을 고려한 안전한 전송 함수.
"""
import discord
EMBED_DESC_LIMIT = 4096
async def safe_send_embed(channel, embed: discord.Embed):
"""Embed가 Discord 제한을 초과하면 나눠서 전송."""
desc = embed.description or ""
if len(desc) <= EMBED_DESC_LIMIT:
await channel.send(embed=embed)
return
chunks = [desc[i:i + 4000] for i in range(0, len(desc), 4000)]
embed.description = chunks[0]
await channel.send(embed=embed)
for chunk in chunks[1:]:
cont = discord.Embed(description=chunk, color=embed.color)
await channel.send(embed=cont)

180
handlers/task_handler.py Normal file
View File

@@ -0,0 +1,180 @@
"""태스크 핸들러 — Agent 1회 실행 (discord_bot.py에서 분리).
NLU에서 mode="task"로 분류된 요청을 처리합니다.
Agent가 plan+code+verify를 한 세션에서 수행합니다.
"""
import uuid
import logging
import asyncio
import discord
from core.gemini_caller import GeminiCallError
from handlers.renderer import safe_send_embed
logger = logging.getLogger("variet.handlers.task")
async def handle_task(
message: discord.Message,
text: str,
ws,
history: str = "",
mode: str = "task",
):
"""작업 요청 — Agent 1회 통합 실행 + 결과 표시.
Args:
message: Discord 메시지
text: 사용자 입력 텍스트
ws: Workspace 객체
history: 대화 히스토리 문자열
mode: 'task'(코딩) 또는 'anime'(도구 실행)
"""
from core.task_pipeline import TaskPipeline
task_id = uuid.uuid4().hex[:8]
# ── 1. 접수 ──
embed = discord.Embed(
title="⚙️ 작업 중...",
description=f"```{text[:200]}```",
color=0xF39C12,
)
embed.set_footer(text=f"ID: {task_id} | {ws.name}")
status_msg = await message.channel.send(embed=embed)
try:
pipeline = TaskPipeline(
project_path=ws.path,
docs_subpath=ws.docs_path,
)
pipeline.setup()
# ── 2. Agent 실행 (진행 상태 실시간 업데이트) ──
import time as _time
_start_time = _time.time()
_last_update = [0.0]
_current_status = ["작업 중..."]
async def _progress(status_text: str):
now = _time.time()
if now - _last_update[0] < 2.0:
return
_last_update[0] = now
_current_status[0] = status_text
elapsed = int(now - _start_time)
try:
embed.title = f"⚙️ {status_text}"
embed.set_footer(text=f"ID: {task_id} | {ws.name} | {elapsed}초 경과")
await status_msg.edit(embed=embed)
except Exception:
pass
# heartbeat: 출력이 없어도 10초마다 경과시간 갱신
_heartbeat_running = [True]
async def _heartbeat():
while _heartbeat_running[0]:
await asyncio.sleep(10)
if not _heartbeat_running[0]:
break
elapsed = int(_time.time() - _start_time)
try:
embed.set_footer(text=f"ID: {task_id} | {ws.name} | {elapsed}초 경과")
await status_msg.edit(embed=embed)
except Exception:
pass
heartbeat_task = asyncio.create_task(_heartbeat())
# mode→role 매핑: anime='operator'(도구 실행), task='agent'(코딩)
role = "operator" if mode == "anime" else "agent"
timeout = 600 # anime 배치 작업(20+건 순차)도 충분한 시간 확보
try:
result = await pipeline.execute(
text, history=history, progress_callback=_progress,
role=role,
)
finally:
_heartbeat_running[0] = False
try:
heartbeat_task.cancel()
except Exception:
pass
# ── 3. 결과 표시 ──
title = result.get("title", "작업 완료")
summary = result.get("summary", "완료")
verified = result.get("verified", False)
color = 0x2ECC71 if verified else 0xF39C12
result_embed = discord.Embed(
title=f"{'' if verified else '📋'} {title}",
description=summary[:2000],
color=color,
)
# 변경 사항
changes = result.get("changes", [])
if changes:
if isinstance(changes[0], dict):
val = "\n".join(
f"• **{c.get('title', c.get('file', c.get('name', '?')))}** — "
f"{c.get('action', c.get('description', c.get('summary', '')))}"
for c in changes[:10]
)
else:
val = "\n".join(f"{s}" for s in changes[:10])
result_embed.add_field(name="변경 사항", value=val[:1000], inline=False)
# 주의사항
warnings = result.get("warnings", [])
if warnings:
result_embed.add_field(
name="⚠️ 주의",
value="\n".join(f"{w}" for w in warnings[:5])[:1000],
inline=False,
)
# 다음 단계
next_steps = result.get("next_steps", [])
if next_steps:
result_embed.add_field(
name="🔜 다음 단계",
value="\n".join(f"{s}" for s in next_steps[:5])[:1000],
inline=False,
)
result_embed.set_footer(text=f"ID: {task_id} | {ws.name}")
await status_msg.edit(embed=result_embed)
# 기록 (실패해도 봇 종료 방지)
try:
pipeline.docs.record_session(text, result, {})
pipeline.docs.append_changelog(title)
except Exception as doc_err:
logger.warning(f"세션 기록 실패: {doc_err}")
except GeminiCallError as e:
await status_msg.edit(
embed=discord.Embed(
title="❌ AI 호출 오류",
description=(
f"```{str(e)[:300]}```\n\n"
f"💡 요청을 더 짧게/구체적으로 다시 시도해보세요."
),
color=0xE74C3C,
)
)
except Exception as e:
logger.error(f"작업 오류: {e}", exc_info=True)
await status_msg.edit(
embed=discord.Embed(
title="❌ 예기치 않은 오류",
description=f"```{str(e)[:300]}```",
color=0xE74C3C,
)
)

View File

@@ -1 +0,0 @@
Hello World
1 Hello World

119
main.py
View File

@@ -1,24 +1,53 @@
"""Variet Agent — 진입점. """Variet Agent — 진입점.
FastAPI 서버 + Discord Bot 동시 실행합니다. FastAPI 서버 + Discord Bot + APScheduler를 동시 실행합니다.
상시 실행 안정화: 파일 로깅, graceful shutdown, 자가 헬스체크.
""" """
import asyncio import asyncio
import logging import logging
import sys import sys
import signal import signal
from logging.handlers import RotatingFileHandler
from pathlib import Path
# config를 먼저 import → .env 로드 # config를 먼저 import → .env 로드
import config import config
# ──────────────────────────────────────────────
# 로깅 설정 (파일 + 콘솔)
# ──────────────────────────────────────────────
LOG_DIR = Path(__file__).parent / "logs"
LOG_DIR.mkdir(exist_ok=True)
handlers = [
logging.StreamHandler(sys.stdout),
RotatingFileHandler(
LOG_DIR / "variet.log",
maxBytes=10 * 1024 * 1024, # 10MB
backupCount=5,
encoding="utf-8",
),
]
logging.basicConfig( logging.basicConfig(
level=logging.INFO, level=logging.INFO,
format="%(asctime)s [%(name)s] %(levelname)s: %(message)s", format="%(asctime)s [%(name)s] %(levelname)s: %(message)s",
datefmt="%H:%M:%S", datefmt="%Y-%m-%d %H:%M:%S",
handlers=[logging.StreamHandler(sys.stdout)], handlers=handlers,
) )
logger = logging.getLogger("variet") logger = logging.getLogger("variet")
# 외부 라이브러리 로그 레벨 조정
logging.getLogger("discord").setLevel(logging.WARNING)
logging.getLogger("uvicorn").setLevel(logging.WARNING)
logging.getLogger("httpcore").setLevel(logging.WARNING)
# ──────────────────────────────────────────────
# 서브시스템
# ──────────────────────────────────────────────
async def run_api_server(): async def run_api_server():
"""FastAPI 서버를 uvicorn으로 실행.""" """FastAPI 서버를 uvicorn으로 실행."""
@@ -28,7 +57,7 @@ async def run_api_server():
"api.server:app", "api.server:app",
host=config.API_HOST, host=config.API_HOST,
port=config.API_PORT, port=config.API_PORT,
log_level="info", log_level="warning",
reload=False, reload=False,
) )
server = uvicorn.Server(uvi_config) server = uvicorn.Server(uvi_config)
@@ -41,12 +70,49 @@ async def run_discord_bot():
await start_bot() await start_bot()
async def run_scheduler():
"""APScheduler — 주기적 작업 실행."""
try:
from apscheduler.schedulers.asyncio import AsyncIOScheduler
except ImportError:
logger.warning("APScheduler 미설치 — 스케줄러 비활성화 (pip install apscheduler)")
return
scheduler = AsyncIOScheduler()
# 헬스체크: 5분마다
async def health_check():
logger.debug("헬스체크 OK")
scheduler.add_job(health_check, "interval", minutes=5, id="health_check")
# TODO: 여기에 주기적 작업 추가
# 예: 편성표 체크, 완료 토렌트 정리 등
# scheduler.add_job(check_schedule, "cron", hour=18, id="anime_schedule")
scheduler.start()
logger.info(f"스케줄러 시작 — {len(scheduler.get_jobs())}개 작업 등록")
# 스케줄러가 종료되지 않도록 대기
try:
while True:
await asyncio.sleep(3600)
except asyncio.CancelledError:
scheduler.shutdown(wait=False)
logger.info("스케줄러 종료")
# ──────────────────────────────────────────────
# 메인
# ──────────────────────────────────────────────
async def main(): async def main():
"""API 서버 + Discord Bot 동시 실행.""" """API 서버 + Discord Bot + 스케줄러 동시 실행."""
logger.info("=" * 50) logger.info("=" * 50)
logger.info("Variet Agent 시작") logger.info("Variet Agent 시작")
logger.info(f" API: http://{config.API_HOST}:{config.API_PORT}") logger.info(f" API: http://{config.API_HOST}:{config.API_PORT}")
logger.info(f" Discord Bot: {'토큰 설정됨' if config.DISCORD_BOT_TOKEN else '⚠ 토큰 없음'}") logger.info(f" Discord Bot: {'토큰 설정됨' if config.DISCORD_BOT_TOKEN else '⚠ 토큰 없음'}")
logger.info(f" 로그: {LOG_DIR / 'variet.log'}")
logger.info("=" * 50) logger.info("=" * 50)
tasks = [] tasks = []
@@ -60,16 +126,47 @@ async def main():
else: else:
logger.warning("DISCORD_BOT_TOKEN이 없습니다. Bot 없이 API만 실행합니다.") logger.warning("DISCORD_BOT_TOKEN이 없습니다. Bot 없이 API만 실행합니다.")
# 스케줄러
tasks.append(asyncio.create_task(run_scheduler()))
# Graceful shutdown 핸들러
shutdown_event = asyncio.Event()
def _signal_handler():
logger.info("종료 신호 수신...")
shutdown_event.set()
loop = asyncio.get_running_loop()
for sig in (signal.SIGINT, signal.SIGTERM):
try:
loop.add_signal_handler(sig, _signal_handler)
except NotImplementedError:
# Windows에서는 add_signal_handler 미지원
pass
try: try:
await asyncio.gather(*tasks) # 어느 하나가 종료되거나 shutdown 신호가 오면 종료
done, pending = await asyncio.wait(
[*tasks, asyncio.create_task(shutdown_event.wait())],
return_when=asyncio.FIRST_COMPLETED,
)
except KeyboardInterrupt: except KeyboardInterrupt:
logger.info("종료 요청...") logger.info("KeyboardInterrupt 수신...")
except Exception as e: except Exception as e:
logger.error(f"실행 오류: {e}") logger.error(f"실행 오류: {e}", exc_info=True)
finally: finally:
# 정리 # 모든 태스크 정리
from api.discord_bot import stop_bot for t in tasks:
await stop_bot() if not t.done():
t.cancel()
# Discord Bot 정리
try:
from api.discord_bot import stop_bot
await stop_bot()
except Exception:
pass
logger.info("Variet Agent 종료") logger.info("Variet Agent 종료")

View File

@@ -1,39 +0,0 @@
You are a **Coder** — 프로젝트에서 파일을 직접 생성/수정하는 AI 에이전트입니다.
## 작업 원칙
**핵심: 태스크의 description만 보고, 완성된 결과물을 파일로 만드세요.**
소스코드뿐 아니라 **문서(.md), 설정 파일, 워크플로우, 데이터 파일** 등 모든 유형의 파일을 다룹니다.
## 작업 흐름
### 1단계: 탐색
- 프로젝트 구조를 먼저 파악하세요 (디렉토리 확인, 관련 파일 검색)
- 기존 프로젝트라면 **관련 파일을 찾아서 읽은 뒤** 수정하세요
- 빈 프로젝트라면 필요한 파일을 처음부터 만드세요
### 2단계: 구현
- 파일을 직접 생성/수정하여 저장하세요
- 코드블록으로 출력하지 말고, **파일을 직접 만드세요**
### 3단계: 자가 검증 (반드시 수행)
구현 후 직접 확인하세요:
- 생성/수정한 파일을 다시 읽어서 내용이 완전한지
- 파일 간 참조(import, 경로 등)가 올바른지
- 핵심 내용이 빠진 것은 없는지
### 4단계: 자가 수정
검증에서 문제를 발견하면 직접 수정 → 다시 3단계 → 문제 없을 때까지 반복.
### 5단계: 완료 보고
- 변경한 파일 목록
- 각 파일의 핵심 내용 한 줄 설명
- 자가 검증에서 발견하고 수정한 것이 있으면 언급
- **실행/사용 방법이 있으면 반드시 안내** (예: 서버 시작 명령, 테스트 방법, 설치 절차 등)
## 규칙
- 동작하는 완성된 결과물을 만드세요. 뼈대나 TODO를 남기지 마세요.
- 기존 프로젝트의 스타일과 구조를 유지하세요.
- 코드 주석과 문서는 **한국어**로 작성. 코드 식별자는 영어 유지.

81
prompts/operator.md Normal file
View File

@@ -0,0 +1,81 @@
# Operator — 도구 실행 에이전트
> CLI 도구를 실행하고 결과를 보고합니다. 코드 수정 금지.
## Python
```
C:\ProgramData\miniforge3\envs\agent_chat\python.exe
```
반드시 이 절대경로 사용. `python` 단독 사용 금지.
## 핵심 규칙
1. **설명하지 말고 바로 실행하세요.** "~하겠습니다" 없이 즉시 도구를 실행하세요.
2. **파일 탐색/코드 분석 금지.** `ls`, `dir`, `find`, `cat` 등으로 프로젝트를 탐색하지 마세요.
3. **아래 도구 명령만 사용하세요.** 다른 명령어 사용 금지.
## 사용 가능한 도구
```bash
# 복합 작업 (대부분의 요청에 이것만 쓰면 됨)
C:\ProgramData\miniforge3\envs\agent_chat\python.exe tools/anime_pipeline.py search "제목"
C:\ProgramData\miniforge3\envs\agent_chat\python.exe tools/anime_pipeline.py download "제목"
C:\ProgramData\miniforge3\envs\agent_chat\python.exe tools/anime_pipeline.py download "제목" --episode 10
C:\ProgramData\miniforge3\envs\agent_chat\python.exe tools/anime_pipeline.py batch
C:\ProgramData\miniforge3\envs\agent_chat\python.exe tools/anime_pipeline.py batch --no-sub-filter
C:\ProgramData\miniforge3\envs\agent_chat\python.exe tools/anime_pipeline.py status
# NAS 폴더
C:\ProgramData\miniforge3\envs\agent_chat\python.exe tools/nas_scanner.py scan
C:\ProgramData\miniforge3\envs\agent_chat\python.exe tools/nas_scanner.py search "키워드"
C:\ProgramData\miniforge3\envs\agent_chat\python.exe tools/nas_scanner.py summary
# 개별 도구 (세밀한 제어가 필요할 때)
C:\ProgramData\miniforge3\envs\agent_chat\python.exe tools/anissia_client.py search "제목"
C:\ProgramData\miniforge3\envs\agent_chat\python.exe tools/anissia_client.py captions <anime_no>
C:\ProgramData\miniforge3\envs\agent_chat\python.exe tools/nyaa_client.py search "영문제목"
C:\ProgramData\miniforge3\envs\agent_chat\python.exe tools/qbit_client.py status
C:\ProgramData\miniforge3\envs\agent_chat\python.exe tools/qbit_client.py add "magnet:..." --path "경로"
C:\ProgramData\miniforge3\envs\agent_chat\python.exe tools/qbit_client.py delete <hash>
```
## 실행 패턴 예시
### "이번 분기 애니 뭐있어?"
```bash
C:\ProgramData\miniforge3\envs\agent_chat\python.exe tools/nas_scanner.py scan
```
### "프리렌 검색해줘"
```bash
C:\ProgramData\miniforge3\envs\agent_chat\python.exe tools/anime_pipeline.py search "프리렌"
```
### "이번 분기 애니 자막 업데이트 된것들 다운받아줘"
```bash
C:\ProgramData\miniforge3\envs\agent_chat\python.exe tools/anime_pipeline.py batch
```
> `batch`는 이번 분기 NAS 폴더 → Anissia 자막 확인 → 자막 있는 것만 다운을 자동 수행합니다.
> 자막 필터 없이 전부 다운받으려면 `--no-sub-filter` 옵션 추가.
### "다운로드 현황 보여줘"
```bash
C:\ProgramData\miniforge3\envs\agent_chat\python.exe tools/anime_pipeline.py status
```
## 보고서
작업 완료 후 반드시 이 JSON을 출력하세요:
```json
{
"title": "작업 제목",
"summary": "결과 요약 1-3문장",
"changes": [],
"verified": true,
"warnings": [],
"next_steps": []
}
```

View File

@@ -1,64 +0,0 @@
You are a **Planner** — 사용자 요청을 분석하여 직접 처리하거나 태스크로 분배합니다.
## 판단 원칙
**핵심 질문: "이 작업을 내가 지금 바로 할 수 있는가?"**
- **Yes** → `direct: true` (직접 처리)
- **No** → `direct: false` + tasks 배열 (코더에게 분배)
### 직접 처리 기준
- 파일 1-2개 삭제, 이름 변경, 간단한 수정
- 프로젝트 구조 확인, 현황 파악
- 간단한 문서(.md, .txt) 생성/수정
- 에이전트 도구만으로 완료 가능한 작업
### 태스크 분배 기준
- **파일을 생성/수정/삭제해야 하는 모든 작업** (소스코드, 문서, 워크플로우, 설정 파일 등)
- 구현 복잡도가 있어서 코더의 자가 검증이 필요한 작업
- 1개로 충분하면 **반드시 1개만**. 독립적인 기능이 여러 개일 때만 분할.
### ⚠️ 절대 하지 말 것
- 하나의 기능을 "파일 생성", "스타일 추가", "로직 구현"으로 쪼개기
- 단순한 요청을 3개 이상으로 분할하기
- 작업할 게 없는데 억지로 태스크 만들기
## 이전 시도 피드백이 있는 경우
review_feedback이 주어지면, 이전 시도에서 실패한 원인을 분석하고
태스크 구조를 재설계하세요. 같은 구조를 반복하지 마세요.
## Output Format
### 직접 처리:
```json
{
"summary": "처리 결과 요약",
"direct": true,
"result": "구체적으로 무엇을 했는지"
}
```
### 태스크 분배:
```json
{
"summary": "작업 요약",
"direct": false,
"tasks": [
{
"id": 1,
"title": "태스크 제목",
"description": "구현 세부사항. 에이전트가 이것만 보고 작업합니다. 대상 파일, 내용, 형식을 구체적으로 포함하세요.",
"type": "create|modify|delete"
}
],
"risk": "low|medium|high"
}
```
## Rules
- description에 **모든 구현 세부사항**을 적으세요. 코더는 이것만 봅니다.
- 한국어로 작성하세요.
- 단순한 일을 복잡하게 만들지 마세요.
- 이전 대화 맥락이 주어지면, 그 내용을 반영하세요.

View File

@@ -1,35 +0,0 @@
# Summarizer
당신은 AI Agent Team의 **총평 작성자**입니다.
작업 파이프라인 완료 후, 사용자가 이해하기 쉽게 결과를 요약합니다.
## 입력
- 사용자의 원래 요청
- 태스크 수
- 에이전트 작업 보고
- 리뷰 결과
## 출력 형식 (JSON)
```json
{
"title": "작업 완료 한줄 제목",
"changes": [
{"file": "path/to/file", "description": "변경 내용 설명"}
],
"warnings": ["주의사항이 있으면 여기에"],
"next_steps": ["사용자가 다음에 할 수 있는 작업 제안"],
"summary": "2-3문장 전체 요약"
}
```
## 규칙
- 사용자 관점에서 서술하세요. 기술 용어는 최소화.
- 한국어로 답변.
- 주의사항이 없으면 warnings를 빈 배열로.
- next_steps는 1-2개만 구체적으로 제안.
- **실행 가능한 결과물이 있으면 next_steps 첫 번째에 실행 방법을 반드시 포함** (예: "터미널에서 `npm start` 실행", "http://localhost:3000 접속" 등).
- changes의 file은 에이전트 보고에서 언급된 파일명 사용.
- 코드 작업과 문서 작업 모두 동일한 형식으로 요약하세요.

View File

@@ -4,3 +4,5 @@ uvicorn>=0.30.0
discord.py>=2.4.0 discord.py>=2.4.0
pydantic>=2.0.0 pydantic>=2.0.0
httpx>=0.27.0 httpx>=0.27.0
apscheduler>=3.10.0

61
tests/cli_test_output.txt Normal file
View File

@@ -0,0 +1,61 @@
=== Architecture Imports ===
OK core.gemini_caller
OK core.orchestrator
OK core.task_pipeline
OK tools.anime_pipeline
OK tools.anissia_client
OK tools.nyaa_client
OK tools.qbit_client
OK tools.nas_scanner
OK tools.title_matcher
OK tools.subtitle_downloader
OK handlers.renderer
=== Deleted Files ===
OK deleted tools/base.py
OK deleted tools/registry.py
OK deleted tools/anime_tool.py
OK deleted prompts/planner.md
OK deleted prompts/coder.md
OK deleted prompts/summarizer.md
=== Required Files ===
OK prompts/agent.md
OK prompts/unified.md
OK prompts/reviewer.md
OK .gemini/skills/anime/SKILL.md
=== Title Matcher ===
pykakasi('sousouno furiiren'): sousouno furiiren
sim('sousouno furiiren', 'sousou no furiiren'): 0.971
sim('Sousou no Frieren', 'sousou no furiiren'): 0.914
=== NAS Scanner ===
accessible: True
total folders: 322
current quarter: 7 anime
[26_1분기]29세 독신 중견 모험가의 일상 vid:6 sub:6
[26_1분기]공주님고문의시간입니다2기 vid:8 sub:6
[26_1분기]귀족전생-축복받은태생으로- vid:9 sub:0
[26_1분기]너따위가마왕을이길수있다고생각하지마-용사파티추방 vid:9 sub:9
[26_1분기]용사파티에서쫒겨난다재무능 vid:10 sub:8
=== Anissia Search ===
'frieren': 2 results
'sousou': 1 results
=== Nyaa Search ===
'Sousou no Frieren ASW HEVC': 36 results
top: [ASW] [ASW] Sousou no Frieren S2 - 08 [1080p HEVC x265 1 S:717
=== Pipeline Search ===
success: True
anime: 장송의 프리렌 2기
captions: 2
torrents: 20
nas_folder: [26_1분기]장송의 프리렌 2기
=== qBittorrent ===
connected: False
=== ALL TESTS DONE ===

4
tests/run_test.bat Normal file
View File

@@ -0,0 +1,4 @@
@echo off
cd /d c:\Users\Certes\Desktop\variet-agent
python tests/test_cli_tools.py 2>&1
type tests\cli_test_output.txt

107
tests/test_architecture.py Normal file
View File

@@ -0,0 +1,107 @@
"""아키텍처 v3 검증 스크립트.
사용법: 프로젝트 루트 또는 tests/ 디렉토리 어디서든 실행 가능.
cd variet-agent && python tests/test_architecture.py
cd variet-agent/tests && python test_architecture.py
"""
import sys
import os
# 프로젝트 루트를 sys.path에 추가
_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if _root not in sys.path:
sys.path.insert(0, _root)
errors = []
# 1. core 계층
try:
from core.gemini_caller import GeminiCaller, GeminiCallError
print("✅ core.gemini_caller OK")
except Exception as e:
errors.append(f"❌ core.gemini_caller: {e}")
try:
from core.orchestrator import Orchestrator
print("✅ core.orchestrator OK")
except Exception as e:
errors.append(f"❌ core.orchestrator: {e}")
try:
from core.task_pipeline import TaskPipeline
print("✅ core.task_pipeline OK")
except Exception as e:
errors.append(f"❌ core.task_pipeline: {e}")
# 2. tools 계층 (CLI 스크립트)
try:
from tools.anime_pipeline import AnimePipeline
print("✅ tools.anime_pipeline OK")
except Exception as e:
errors.append(f"❌ tools.anime_pipeline: {e}")
try:
from tools.anissia_client import AnissiaClient, AnimeInfo
print("✅ tools.anissia_client OK")
except Exception as e:
errors.append(f"❌ tools.anissia_client: {e}")
try:
from tools.nyaa_client import NyaaClient, TorrentResult
print("✅ tools.nyaa_client OK")
except Exception as e:
errors.append(f"❌ tools.nyaa_client: {e}")
try:
from tools.qbit_client import QBitClient
print("✅ tools.qbit_client OK")
except Exception as e:
errors.append(f"❌ tools.qbit_client: {e}")
try:
from tools.nas_scanner import NasScanner
print("✅ tools.nas_scanner OK")
except Exception as e:
errors.append(f"❌ tools.nas_scanner: {e}")
try:
from tools.title_matcher import match_titles, japanese_to_romaji
print("✅ tools.title_matcher OK")
except Exception as e:
errors.append(f"❌ tools.title_matcher: {e}")
# 3. handlers 계층
try:
from handlers.renderer import safe_send_embed
print("✅ handlers.renderer OK")
except Exception as e:
errors.append(f"❌ handlers.renderer: {e}")
# 4. 삭제 확인
for should_not_exist in ["tools/base.py", "tools/registry.py", "tools/anime_tool.py",
"prompts/planner.md", "prompts/coder.md", "prompts/summarizer.md"]:
path = os.path.join(_root, should_not_exist)
if os.path.exists(path):
errors.append(f"❌ 삭제해야 할 파일 존재: {should_not_exist}")
else:
print(f"{should_not_exist} 삭제됨")
# 5. 필수 파일 확인
for should_exist in ["prompts/agent.md", "prompts/unified.md", "prompts/reviewer.md",
".gemini/skills/anime/SKILL.md"]:
path = os.path.join(_root, should_exist)
if os.path.exists(path):
print(f"{should_exist} 존재")
else:
errors.append(f"❌ 필수 파일 없음: {should_exist}")
# 결과
print(f"\n{'='*40}")
if errors:
print(f"{len(errors)}개 오류:")
for e in errors:
print(f" {e}")
sys.exit(1)
else:
print("✅ v3 아키텍처 검증 통과!")
sys.exit(0)

119
tests/test_cli_tools.py Normal file
View File

@@ -0,0 +1,119 @@
"""Full integration test — all tools + architecture + pipeline."""
import sys, os, asyncio
_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, _root)
OUT = os.path.join(_root, "tests", "cli_test_output.txt")
lines = []
# ===== 1. Architecture: imports =====
lines.append("=== Architecture Imports ===")
tests = [
("core.gemini_caller", "GeminiCaller, GeminiCallError"),
("core.orchestrator", "Orchestrator"),
("core.task_pipeline", "TaskPipeline"),
("tools.anime_pipeline", "AnimePipeline"),
("tools.anissia_client", "AnissiaClient"),
("tools.nyaa_client", "NyaaClient"),
("tools.qbit_client", "QBitClient"),
("tools.nas_scanner", "NasScanner"),
("tools.title_matcher", "match_titles, japanese_to_romaji"),
("tools.subtitle_downloader", "SubtitleDownloader"),
("handlers.renderer", "safe_send_embed"),
]
for mod, names in tests:
try:
exec(f"from {mod} import {names}")
lines.append(f" OK {mod}")
except Exception as e:
lines.append(f" FAIL {mod}: {e}")
# ===== 2. Deleted files =====
lines.append("\n=== Deleted Files ===")
for f in ["tools/base.py", "tools/registry.py", "tools/anime_tool.py",
"prompts/planner.md", "prompts/coder.md", "prompts/summarizer.md"]:
exists = os.path.exists(os.path.join(_root, f))
lines.append(f" {'FAIL exists' if exists else 'OK deleted'} {f}")
# ===== 3. Required files =====
lines.append("\n=== Required Files ===")
for f in ["prompts/agent.md", "prompts/unified.md", "prompts/reviewer.md",
".gemini/skills/anime/SKILL.md"]:
exists = os.path.exists(os.path.join(_root, f))
lines.append(f" {'OK' if exists else 'FAIL missing'} {f}")
# ===== 4. Title Matcher (pykakasi) =====
lines.append("\n=== Title Matcher ===")
from tools.title_matcher import japanese_to_romaji, title_similarity
cases = [
("sousouno furiiren", "sousou no furiiren"),
("Sousou no Frieren", "sousou no furiiren"),
]
romaji = japanese_to_romaji("sousouno furiiren")
lines.append(f" pykakasi('sousouno furiiren'): {romaji}")
for a, b in cases:
sim = title_similarity(a, b)
lines.append(f" sim('{a[:30]}', '{b[:30]}'): {sim:.3f}")
# ===== 5. NAS Scanner =====
lines.append("\n=== NAS Scanner ===")
from tools.nas_scanner import NasScanner
scanner = NasScanner()
lines.append(f" accessible: {scanner.is_accessible()}")
if scanner.is_accessible():
folders = scanner.list_anime_folders()
lines.append(f" total folders: {len(folders)}")
current = scanner.get_current_quarter_anime()
lines.append(f" current quarter: {len(current)} anime")
for f in current[:5]:
lines.append(f" {f.folder_name} vid:{f.video_count} sub:{f.subtitle_count}")
# ===== 6. Async tests =====
async def async_tests():
# 6a. Anissia search
lines.append("\n=== Anissia Search ===")
from tools.anissia_client import AnissiaClient
ac = AnissiaClient()
for kw in ["frieren", "sousou"]:
r = await ac.search_anime(kw)
lines.append(f" '{kw}': {len(r)} results")
# 6b. Nyaa search
lines.append("\n=== Nyaa Search ===")
from tools.nyaa_client import NyaaClient
nc = NyaaClient()
r = await nc.search("Sousou no Frieren", use_default_suffix=True)
lines.append(f" 'Sousou no Frieren ASW HEVC': {len(r)} results")
if r:
lines.append(f" top: [{r[0].group}] {r[0].title[:50]} S:{r[0].seeders}")
# 6c. Pipeline search
lines.append("\n=== Pipeline Search ===")
from tools.anime_pipeline import AnimePipeline
pipe = AnimePipeline()
try:
result = await pipe.search("sousou")
lines.append(f" success: {result.success}")
lines.append(f" anime: {result.anime.subject if result.anime else 'None'}")
lines.append(f" captions: {len(result.captions)}")
lines.append(f" torrents: {len(result.torrents)}")
lines.append(f" nas_folder: {result.nas_folder}")
if result.errors:
lines.append(f" errors: {'; '.join(result.errors[:3])}")
except Exception as e:
lines.append(f" ERROR: {e}")
# 6d. qBittorrent
lines.append("\n=== qBittorrent ===")
from tools.qbit_client import QBitClient
qb = QBitClient()
info = await qb.test_connection()
lines.append(f" connected: {info.get('connected')}")
if info.get('connected'):
lines.append(f" version: {info.get('version')}")
asyncio.run(async_tests())
lines.append("\n=== ALL TESTS DONE ===")
with open(OUT, "w", encoding="utf-8-sig") as f:
f.write("\n".join(lines))

View File

@@ -1,227 +0,0 @@
/**
* Tetris Game Logic
* Implements core mechanics: movement, rotation, collision, line clearing, and scoring.
*/
class Tetris {
constructor(width = 10, height = 20) {
this.width = width;
this.height = height;
this.grid = this.createGrid();
this.score = 0;
this.linesCleared = 0;
this.gameOver = false;
// Tetromino shapes definitions
this.shapes = {
'I': [[1, 1, 1, 1]],
'J': [[1, 0, 0], [1, 1, 1]],
'L': [[0, 0, 1], [1, 1, 1]],
'O': [[1, 1], [1, 1]],
'S': [[0, 1, 1], [1, 1, 0]],
'T': [[0, 1, 0], [1, 1, 1]],
'Z': [[1, 1, 0], [0, 1, 1]]
};
this.colors = {
'I': '#00f0f0',
'J': '#0000f0',
'L': '#f0a000',
'O': '#f0f000',
'S': '#00f000',
'T': '#a000f0',
'Z': '#f00000'
};
this.currentPiece = null;
this.nextPiece = null;
this.spawnPiece();
}
/**
* Creates an empty game grid
*/
createGrid() {
return Array.from({ length: this.height }, () => Array(this.width).fill(0));
}
/**
* Spawns a new random tetromino
*/
spawnPiece() {
const types = Object.keys(this.shapes);
if (!this.nextPiece) {
this.nextPiece = types[Math.floor(Math.random() * types.length)];
}
const type = this.nextPiece;
this.nextPiece = types[Math.floor(Math.random() * types.length)];
const shape = this.shapes[type];
this.currentPiece = {
type: type,
shape: shape,
x: Math.floor((this.width - shape[0].length) / 2),
y: 0
};
// Check for immediate collision (Game Over)
if (this.checkCollision(this.currentPiece.x, this.currentPiece.y, this.currentPiece.shape)) {
this.gameOver = true;
}
}
/**
* Checks if a piece collides with boundaries or other pieces
*/
checkCollision(x, y, shape) {
for (let row = 0; row < shape.length; row++) {
for (let col = 0; col < shape[row].length; col++) {
if (shape[row][col] !== 0) {
const newX = x + col;
const newY = y + row;
if (newX < 0 || newX >= this.width ||
newY >= this.height ||
(newY >= 0 && this.grid[newY][newX] !== 0)) {
return true;
}
}
}
}
return false;
}
/**
* Moves the current piece in a given direction
*/
move(dx, dy) {
if (this.gameOver) return false;
if (!this.checkCollision(this.currentPiece.x + dx, this.currentPiece.y + dy, this.currentPiece.shape)) {
this.currentPiece.x += dx;
this.currentPiece.y += dy;
return true;
}
// If moving down and collision occurs, lock the piece
if (dy > 0) {
this.lockPiece();
this.clearLines();
this.spawnPiece();
}
return false;
}
/**
* Rotates the current piece clockwise
*/
rotate() {
if (this.gameOver) return;
const originalShape = this.currentPiece.shape;
const newShape = originalShape[0].map((_, index) =>
originalShape.map(row => row[index]).reverse()
);
// Basic "Wall Kick" check
let offset = 0;
if (this.checkCollision(this.currentPiece.x, this.currentPiece.y, newShape)) {
// Try shifting left/right to see if it fits
if (!this.checkCollision(this.currentPiece.x - 1, this.currentPiece.y, newShape)) {
offset = -1;
} else if (!this.checkCollision(this.currentPiece.x + 1, this.currentPiece.y, newShape)) {
offset = 1;
} else {
return; // Can't rotate
}
}
this.currentPiece.x += offset;
this.currentPiece.shape = newShape;
}
/**
* Hard drop the current piece
*/
hardDrop() {
while (this.move(0, 1)) {
// Keep moving down
}
}
/**
* Locks the piece into the grid
*/
lockPiece() {
const { shape, x, y, type } = this.currentPiece;
shape.forEach((row, rowIndex) => {
row.forEach((value, colIndex) => {
if (value !== 0) {
const gridY = y + rowIndex;
const gridX = x + colIndex;
if (gridY >= 0) {
this.grid[gridY][gridX] = type;
}
}
});
});
}
/**
* Clears full lines and updates score
*/
clearLines() {
let linesCount = 0;
for (let row = this.height - 1; row >= 0; row--) {
if (this.grid[row].every(cell => cell !== 0)) {
this.grid.splice(row, 1);
this.grid.unshift(Array(this.width).fill(0));
linesCount++;
row++; // Check the same row index again after splice
}
}
if (linesCount > 0) {
const scoring = [0, 100, 300, 500, 800]; // Standard scoring
this.score += scoring[linesCount];
this.linesCleared += linesCount;
}
}
/**
* Returns the current state for rendering
*/
getState() {
// Return a copy of the grid with the current piece superimposed
const displayGrid = this.grid.map(row => [...row]);
if (this.currentPiece && !this.gameOver) {
const { shape, x, y, type } = this.currentPiece;
shape.forEach((row, rowIndex) => {
row.forEach((value, colIndex) => {
if (value !== 0) {
const gridY = y + rowIndex;
const gridX = x + colIndex;
if (gridY >= 0 && gridY < this.height && gridX >= 0 && gridX < this.width) {
displayGrid[gridY][gridX] = type;
}
}
});
});
}
return {
grid: displayGrid,
score: this.score,
lines: this.linesCleared,
nextPiece: this.nextPiece,
gameOver: this.gameOver
};
}
}
// Export for usage
if (typeof module !== 'undefined' && module.exports) {
module.exports = Tetris;
}

View File

@@ -1 +1 @@
# Anime automation tools package. # tools 패키지

View File

@@ -20,6 +20,7 @@ from tools.qbit_client import QBitClient
from tools.subtitle_downloader import SubtitleDownloader, SubtitleFile from tools.subtitle_downloader import SubtitleDownloader, SubtitleFile
from tools.title_matcher import ( from tools.title_matcher import (
match_titles, make_nas_folder_name, rename_subtitle_to_video, match_titles, make_nas_folder_name, rename_subtitle_to_video,
fetch_english_title,
) )
logger = logging.getLogger("variet.tools.pipeline") logger = logging.getLogger("variet.tools.pipeline")
@@ -81,48 +82,58 @@ class AnimePipeline:
except Exception as e: except Exception as e:
result.errors.append(f"자막 조회 오류: {e}") result.errors.append(f"자막 조회 오류: {e}")
# 3. Nyaa 토렌트 검색 (다중 전략 — suffix 있는/없는 조합) # 3. NAS 기존 폴더 확인 → 검색 전략 결정 (방영 시점 기반)
nas_existing = self._find_existing_nas_folder(anime.subject, anime.start_date)
# 3. Nyaa 토렌트 검색
try: try:
from tools.title_matcher import japanese_to_romaji if nas_existing and nas_existing.video_files:
import re as _re # ── 기존 파일명에서 릴리스명 추출 → Nyaa 검색 (안전) ──
release_name = self._extract_release_name(nas_existing.video_files[0])
if release_name:
logger.info(f"NAS 기존 릴리스명: '{release_name}'")
found = await self.nyaa.search(release_name, use_default_suffix=False)
matched = [t for t in found
if self._title_contains_keyword(t.title, [release_name.lower()])]
result.torrents = matched[:30]
if matched:
logger.info(f"NAS 릴리스명 검색 → {len(found)}건 중 {len(matched)}건 매칭")
romaji_full = japanese_to_romaji(anime.original_subject) if not result.torrents:
# 한자/비ASCII 잔류 문자 제거 → 순수 로마자만 추출 # ── 신규 애니: Jikan API + ASW HEVC 전략 ──
romaji_clean = _re.sub(r'[^\x00-\x7F]+', ' ', romaji_full).strip() eng_titles = await fetch_english_title(anime.original_subject)
romaji_clean = _re.sub(r'\s+', ' ', romaji_clean) eng_default = eng_titles.get("default", "")
eng_english = eng_titles.get("english", "")
synonyms = eng_titles.get("synonyms", [])
# 검색 전략 (query, use_default_suffix) 순서 keywords = self._build_match_keywords(
strategies: list[tuple[str, bool]] = [] eng_default, eng_english, synonyms, anime.original_subject,
if romaji_clean and len(romaji_clean) >= 3:
strategies.append((romaji_clean, True)) # romaji + ASW HEVC
strategies.append((romaji_clean, False)) # romaji only
strategies.append((anime.original_subject, True)) # 원제 + suffix
strategies.append((anime.original_subject, False)) # 원제 only
strategies.append((anime.subject, True)) # 한글 + suffix
strategies.append((anime.subject, False)) # 한글 only
torrents = []
for query, use_suffix in strategies:
torrents = await self.nyaa.search(
query, use_default_suffix=use_suffix,
) )
if torrents: logger.info(f"매칭 키워드: {keywords}")
suffix_label = " +suffix" if use_suffix else ""
logger.info(
f"Nyaa 검색 성공: '{query}'{suffix_label}{len(torrents)}"
)
break
# 제목 매칭 필터 # STEP 1: "ASW HEVC"로 검색 → 키워드로 필터
matched = match_titles( asw_results = await self.nyaa.search("ASW HEVC", use_default_suffix=False)
anime.subject, anime.original_subject, torrents, threshold=0.3 matched = [t for t in asw_results
) if self._title_contains_keyword(t.title, keywords)]
result.torrents = matched[:20] # 상위 20개 if matched:
logger.info(f"ASW HEVC 검색 → {len(asw_results)}건 중 {len(matched)}건 매칭")
else:
# ASW 릴리스 없음 — 사용자에게 안내
result.errors.append(
f"⚠️ ASW HEVC 릴리스가 없습니다.\n"
f"영어 제목: {eng_default or '(조회 실패)'}\n"
f"Nyaa에서 직접 검색해주세요."
)
result.torrents = matched[:30]
except Exception as e: except Exception as e:
result.errors.append(f"Nyaa 검색 오류: {e}") result.errors.append(f"Nyaa 검색 오류: {e}")
# NAS 폴더 생성 # NAS 폴더: 기존 폴더 있으면 재사용, 없으면 새로 생성
result.nas_folder = make_nas_folder_name(anime.subject, anime.start_date) if nas_existing:
result.nas_folder = nas_existing.folder_name
else:
result.nas_folder = make_nas_folder_name(anime.subject, anime.start_date)
result.success = True result.success = True
result.message = ( result.message = (
@@ -143,8 +154,12 @@ class AnimePipeline:
Args: Args:
title: 한글 제목 title: 한글 제목
mode: "auto" (자막+영상), "sub_only" (자막만), "video_only" (영상만) mode:
episode: 특정 에피소드만 (None이면 최신) "auto" — 영상+자막 무조건 다운 (기본)
"sub_required" — 자막 있는 에피소드만 영상 다운
"sub_only" — 자막만
"video_only" — 영상만
episode: 특정 에피소드만 (None이면 빠진 것 전부)
""" """
# 먼저 검색 # 먼저 검색
result = await self.search(title) result = await self.search(title)
@@ -155,13 +170,18 @@ class AnimePipeline:
nas_folder = Path(self.nas_base) / result.nas_folder nas_folder = Path(self.nas_base) / result.nas_folder
# ── 자막 다운로드 ── # ── 자막 다운로드 ──
if mode in ("auto", "sub_only"): if mode in ("auto", "sub_only", "sub_required"):
await self._download_subtitles(result, nas_folder, episode) await self._download_subtitles(result, nas_folder, episode)
# ── 영상 토렌트 추가 ── # ── 영상 토렌트 추가 ──
if mode in ("auto", "video_only"): if mode in ("auto", "video_only"):
force = (mode == "video_only") await self._add_torrents(result, nas_folder, episode)
await self._add_torrents(result, nas_folder, episode, force=force) elif mode == "sub_required":
# 자막이 실제로 다운됐을 때만 영상 추가
if result.subtitles:
await self._add_torrents(result, nas_folder, episode)
else:
result.errors.append("자막이 없어 영상 다운로드를 보류합니다.")
# 결과 메시지 구성 # 결과 메시지 구성
parts = [result.message] parts = [result.message]
@@ -181,8 +201,25 @@ class AnimePipeline:
nas_folder: Path, nas_folder: Path,
episode: Optional[int], episode: Optional[int],
): ):
"""자막 다운로드 처리.""" """자막 다운로드 → 영상 폴더에 직접 저장 + 영상명 매칭 리네임.
sub_dir = nas_folder / "subtitles"
기존 자막이 있는 에피소드는 건너뜀 (수동 자막 보호).
"""
# 영상 폴더에 직접 저장 (subtitles/ 하위 아님)
nas_folder.mkdir(parents=True, exist_ok=True)
# 기존 자막 파일이 있는 에피소드 스캔 → 스킵 대상
existing_sub_eps = set()
sub_exts = {".ass", ".srt", ".ssa", ".sub", ".smi"}
if nas_folder.exists():
for f in nas_folder.iterdir():
if f.suffix.lower() in sub_exts:
ep = self._extract_episode(f.stem)
if ep is not None:
existing_sub_eps.add(ep)
if existing_sub_eps:
logger.info(f"기존 자막 에피소드 (스킵): {sorted(existing_sub_eps)}")
for caption in result.captions: for caption in result.captions:
if not caption.website: if not caption.website:
@@ -195,54 +232,379 @@ class AnimePipeline:
for sub in subs: for sub in subs:
if episode is not None and sub.episode is not None and sub.episode != episode: if episode is not None and sub.episode is not None and sub.episode != episode:
continue continue
# 기존 자막이 있는 에피소드 스킵
if sub.episode is not None and sub.episode in existing_sub_eps:
logger.info(f"자막 스킵 (기존 존재): {sub.episode}화 - {sub.filename}")
continue
try: try:
await self.sub_downloader.download_file(sub, str(sub_dir)) await self.sub_downloader.download_file(sub, str(nas_folder))
result.subtitles.append(sub) result.subtitles.append(sub)
except Exception as e: except Exception as e:
result.errors.append(f"자막 다운로드 실패 ({sub.filename}): {e}") result.errors.append(f"자막 다운로드 실패 ({sub.filename}): {e}")
except Exception as e: except Exception as e:
result.errors.append(f"자막 사이트 접근 실패 ({caption.name}): {e}") result.errors.append(f"자막 사이트 접근 실패 ({caption.name}): {e}")
# 다운로드 후: 기존 영상 파일과 매칭하여 자막 리네임
self._rename_subtitles_to_match_videos(nas_folder, result)
def _rename_subtitles_to_match_videos(
self, folder: Path, result: DownloadResult
):
"""폴더 내 자막 파일을 영상 파일명에 맞게 리네임.
예: [ASW] Sousou no Frieren S2 - 03.mkv
→ 3화.ass 를 [ASW] Sousou no Frieren S2 - 03.ass 로 변경
"""
import re as _re
# 영상 파일 목록 (에피소드 → 파일명)
video_exts = {".mkv", ".mp4", ".avi", ".webm"}
videos = {} # episode_num -> video_path
for f in folder.iterdir():
if f.suffix.lower() in video_exts:
ep = self._extract_episode(f.stem)
if ep is not None:
videos[ep] = f
if not videos:
return
# 자막 파일 리네임
sub_exts = {".ass", ".srt", ".ssa", ".sub"}
for f in folder.iterdir():
if f.suffix.lower() not in sub_exts:
continue
ep = self._extract_episode(f.stem)
if ep is not None and ep in videos:
video_stem = videos[ep].stem
new_name = f"{video_stem}{f.suffix}"
new_path = folder / new_name
if new_path != f and not new_path.exists():
try:
f.rename(new_path)
logger.info(f"자막 리네임: {f.name}{new_name}")
except Exception as e:
logger.warning(f"자막 리네임 실패: {e}")
@staticmethod
def _extract_episode(text: str) -> Optional[int]:
"""텍스트에서 에피소드 번호 추출."""
import re as _re
# 패턴 1: S01E03, S02E07 (SxxExx — 시즌+에피소드, 가장 먼저 체크)
m = _re.search(r'[Ss]\d{1,2}[Ee](\d{1,4})', text)
if m:
return int(m.group(1))
# 패턴 2: "- 03", "- 06", "- 10v2" (torrent 파일명, v2 등 version suffix 허용)
m = _re.search(r'[-]\s*(\d{1,4})(?:v\d)?(?:\s|$|\.|\[|\()', text)
if m:
return int(m.group(1))
# 패턴 3: "3화", "03화"
m = _re.search(r'(\d{1,4})\s*화', text)
if m:
return int(m.group(1))
# 패턴 4: "EP03", "Episode 3"
m = _re.search(r'(?:EP|Episode)\s*(\d{1,4})', text, _re.IGNORECASE)
if m:
return int(m.group(1))
return None
def _find_existing_nas_folder(self, korean_title: str, start_date: str = ""):
"""NAS에서 기존 폴더 찾기 — 제목 + 방영 시점(year/quarter) 기반.
같은 애니라도 방영 분기가 다르면 다른 시즌 → 매칭하지 않음.
예: '최애의 아이 3기'(26_1분기) ≠ [23_2분기]최애의아이 (1기)
"""
import re as _re
from tools.title_matcher import get_quarter
title_norm = _re.sub(r'[^\w]', '', korean_title.lower())
if len(title_norm) < 2:
return None
# 방영 분기 계산
anime_year, anime_quarter = get_quarter(start_date)
try:
all_folders = self.nas.list_anime_folders()
except Exception as e:
logger.warning(f"NAS 폴더 검색 실패: {e}")
return None
candidates = []
for folder in all_folders:
folder_norm = _re.sub(r'[^\w]', '', folder.title.lower())
# 제목 부분 매칭 (양방향)
if not (title_norm in folder_norm or folder_norm in title_norm):
continue
# 방영 분기 일치 확인
if anime_year and folder.year != anime_year:
continue
if anime_quarter and folder.quarter != anime_quarter:
continue
candidates.append(folder)
if not candidates:
return None
best = candidates[0]
logger.info(f"NAS 기존 폴더 발견: {best.folder_name}")
return best
@staticmethod
def _extract_release_name(filename: str) -> str:
"""영상 파일명에서 릴리스 이름 추출.
[ASW] Hime-sama Goumon no Jikan desu - 21 [1080p HEVC].mkv
'Hime-sama Goumon no Jikan desu'
"""
import re as _re
# 확장자 제거
name = _re.sub(r'\.[^.]+$', '', filename)
# [그룹태그] 제거
name = _re.sub(r'^\[[^\]]*\]\s*', '', name)
# 에피소드 번호 이후 제거: " - 21 [...]"
name = _re.sub(r'\s*[-]\s*\d+.*$', '', name).strip()
# S02E09 패턴 제거
name = _re.sub(r'\s*S\d+E\d+.*$', '', name, flags=_re.IGNORECASE).strip()
return name
@staticmethod
def _build_match_keywords(
eng_default: str, eng_english: str,
synonyms: list[str], original_title: str,
) -> list[str]:
"""Jikan 제목들에서 매칭용 키워드 추출.
예: "Sousou no Frieren 2nd Season" → ["Frieren", "Sousou no Frieren"]
synonyms: ["Omagoto"] → ["Omagoto"]
"""
import re as _re
keywords = []
# synonyms 중 짧은 것 (Omagoto 같은 약칭)
for syn in synonyms:
cleaned = syn.strip()
if 3 <= len(cleaned) <= 30:
keywords.append(cleaned.lower())
# eng_default에서 키워드 추출 (시즌 표기 제거)
if eng_default:
clean = _re.sub(r'\s*(2nd|3rd|\d+th)\s*Season.*$', '', eng_default, flags=_re.IGNORECASE).strip()
clean = _re.sub(r'\s*S\d+$', '', clean).strip()
if len(clean) >= 3:
keywords.append(clean.lower())
# eng_english에서 콜론 앞 핵심 단어
if eng_english:
short = eng_english.split(":")[0].strip()
if len(short) >= 3:
keywords.append(short.lower())
# 원제 (일본어) — 정규화 없이 원본으로 비교
if original_title and len(original_title) >= 2:
import re as _re2
clean = _re2.sub(r'\s*第\d+期$', '', original_title).strip()
if clean:
keywords.append(clean.lower())
# 중복 제거
seen = set()
unique = []
for k in keywords:
if k not in seen:
seen.add(k)
unique.append(k)
return unique
@staticmethod
def _title_contains_keyword(nyaa_title: str, keywords: list[str]) -> bool:
"""Nyaa 토렌트 제목에 키워드 중 하나라도 포함되는지 체크.
영문 키워드: 특수문자(하이픈, 따옴표) 제거 후 비교.
일본어 키워드: 원본 그대로 비교.
"""
import re as _re
title_lower = nyaa_title.lower()
# 영문 정규화 버전
title_norm = _re.sub(r'[^a-z0-9\s]', '', title_lower)
for kw in keywords:
if not kw or len(kw) < 2:
continue
# ASCII만 포함된 키워드 → 정규화 비교
kw_norm = _re.sub(r'[^a-z0-9\s]', '', kw)
if kw_norm and len(kw_norm) >= 3 and kw_norm in title_norm:
return True
# 비ASCII(일본어 등) → 원본 비교
if not kw.isascii() and kw in title_lower:
return True
return False
async def _add_torrents( async def _add_torrents(
self, self,
result: DownloadResult, result: DownloadResult,
nas_folder: Path, nas_folder: Path,
episode: Optional[int], episode: Optional[int],
force: bool = False,
): ):
"""토렌트 추가 처리.""" """토렌트 추가 — 빠진 에피소드 전부 다운로드.
릴리스 그룹 일관성: NAS 기존 파일의 릴리스 그룹(예: ASW)이 있으면
같은 그룹의 토렌트만 추가. 매칭 없으면 스킵.
"""
if not result.torrents: if not result.torrents:
result.errors.append("매칭되는 토렌트가 없습니다.") result.errors.append("매칭되는 토렌트가 없습니다.")
return return
# 에피소드 필터링 import math
candidates = result.torrents import re as _re
if episode is not None:
candidates = [t for t in candidates if t.episode == episode]
if not candidates:
result.errors.append(f"{episode}화 토렌트를 찾지 못했습니다.")
return
# auto 모드 기본 조건: 자막이 있어야 영상 다운로드 (force면 무시) # NAS 기존 에피소드 + 릴리스 그룹 스캔
if not force and not result.captions and not result.subtitles: existing_eps = set()
# 자막이 없으면 사용자에게 안내만 existing_groups = [] # 기존 파일들의 릴리스 그룹
result.errors.append("자막이 없어 영상 다운로드를 보류합니다. /anime video로 강제 다운로드 가능") if nas_folder.exists():
video_exts = {".mkv", ".mp4", ".avi", ".webm", ".ts"}
for f in nas_folder.iterdir():
if f.suffix.lower() in video_exts:
ep = self._extract_episode(f.stem)
if ep is not None:
existing_eps.add(ep)
# 릴리스 그룹 추출: [ASW], [SubsPlease] 등
m = _re.match(r'\[([^\]]+)\]', f.name)
if m:
existing_groups.append(m.group(1))
if existing_eps:
logger.info(f"NAS 기존 에피소드: {sorted(existing_eps)}")
# 기존 릴리스 그룹 결정 (가장 많이 등장하는 그룹)
required_group = None
if existing_groups:
from collections import Counter
group_counts = Counter(existing_groups)
dominant_group, count = group_counts.most_common(1)[0]
if count >= 2: # 2개 이상 파일에서 동일 그룹이면 확정
required_group = dominant_group
logger.info(f"NAS 릴리스 그룹: [{required_group}] ({count}개 파일)")
# 에피소드별 최고 점수 토렌트 그룹핑
ep_best: dict[int, tuple[int, object]] = {} # ep → (score, torrent)
for t in result.torrents:
ep = self._extract_episode(t.title)
if ep is None:
continue
# 특정 에피소드 요청 시 해당 에피소드만
if episode is not None and ep != episode:
continue
# NAS 중복 스킵
if ep in existing_eps:
continue
# VOSTFR 제외
title_upper = t.title.upper()
if "VOSTFR" in title_upper or "VOSTA" in title_upper:
continue
# 릴리스 그룹 일관성 필터: NAS에 특정 그룹이 있으면 같은 그룹만 허용
if required_group:
if f"[{required_group}]" not in t.title:
continue
# 스코어링
score = 0
if "[ASW]" in t.title:
score += 100
if "HEVC" in title_upper or "X265" in title_upper:
score += 50
if "1080P" in title_upper:
score += 20
if t.seeders > 0:
score += int(math.log(t.seeders) * 5)
if ep not in ep_best or score > ep_best[ep][0]:
ep_best[ep] = (score, t)
if not ep_best:
if episode is not None:
result.errors.append(f"{episode}화 토렌트를 찾지 못했습니다.")
elif required_group:
result.errors.append(
f"새로 다운로드할 에피소드가 없습니다 "
f"([{required_group}] 릴리스 기준, 모두 NAS에 존재하거나 미출시)."
)
else:
result.errors.append("새로 다운로드할 에피소드가 없습니다 (모두 NAS에 존재).")
return return
# 최상위 1개 (가장 시더 많은) 추가 # 에피소드 순서대로 추가
best = candidates[0] added_count = 0
try: for ep in sorted(ep_best.keys()):
success = await self.qbit.add_torrent( _, torrent = ep_best[ep]
magnet_or_url=best.magnet_link, try:
save_path=str(nas_folder), success = await self.qbit.add_torrent(
category="anime", magnet_or_url=torrent.magnet_link,
tags=result.anime.subject if result.anime else "", save_path=str(nas_folder),
) category="anime",
result.torrent_added = success tags=result.anime.subject if result.anime else "",
if not success: )
result.errors.append("qBittorrent 토렌트 추가 실패") if success:
except Exception as e: added_count += 1
result.errors.append(f"qBittorrent 오류: {e}") logger.info(f"토렌트 추가: ep{ep} - {torrent.title[:50]}")
else:
result.errors.append(f"ep{ep} 토렌트 추가 실패")
except Exception as e:
result.errors.append(f"ep{ep} qBittorrent 오류: {e}")
result.torrent_added = added_count > 0
if added_count > 0:
new_eps = sorted(ep_best.keys())
result.message += f"\n📥 {added_count}개 에피소드 추가: {new_eps}"
@staticmethod
def _select_best_torrent(candidates: list, existing_eps: set = None):
"""ASW HEVC 우선으로 최적 토렌트 선택 (검색 결과 표시용).
스코어링:
+100 [ASW] 그룹
+50 HEVC / x265 코덱
+20 1080p 해상도
+log(시더수) * 5 (최대 ~30)
VOSTFR / non-English 릴리스는 완전 제외.
기존 에피소드는 스킵.
"""
import re as _re
import math
if existing_eps is None:
existing_eps = set()
scored = []
for t in candidates:
title_upper = t.title.upper()
if "VOSTFR" in title_upper or "VOSTA" in title_upper:
continue
score = 0
if "[ASW]" in t.title:
score += 100
if "HEVC" in title_upper or "X265" in title_upper:
score += 50
if "1080P" in title_upper:
score += 20
if t.seeders > 0:
score += int(math.log(t.seeders) * 5)
scored.append((score, t))
if not scored:
return None
scored.sort(key=lambda x: x[0], reverse=True)
return scored[0][1]
async def get_status(self) -> list[dict]: async def get_status(self) -> list[dict]:
"""현재 다운로드 큐 상태.""" """현재 다운로드 큐 상태."""
@@ -263,3 +625,163 @@ class AnimePipeline:
except Exception as e: except Exception as e:
logger.error(f"qBittorrent 상태 조회 오류: {e}") logger.error(f"qBittorrent 상태 조회 오류: {e}")
return [] return []
async def batch_download(
self,
mode: str = "auto",
sub_filter: bool = True,
) -> list[DownloadResult]:
"""이번 분기 애니 일괄 다운로드.
Args:
mode: 다운로드 모드 ("auto", "sub_only", "video_only")
sub_filter: True면 Anissia에 자막이 등록된 애니만 처리
Returns:
각 애니별 DownloadResult 리스트
"""
# 1. NAS에서 이번 분기 애니 폴더 스캔
current_folders = self.nas.get_current_quarter_anime()
if not current_folders:
logger.warning("이번 분기 NAS 폴더 없음")
return []
logger.info(f"이번 분기 NAS 폴더: {len(current_folders)}")
results = []
for folder in current_folders:
title = folder.title
logger.info(f"\n{'='*40}")
logger.info(f"처리 중: {folder.folder_name}")
try:
# 2. Anissia에서 검색 → 자막 정보 확인
anime_list = await self.anissia.search_anime(title)
if not anime_list:
logger.info(f" Anissia 검색 결과 없음 → 건너뜀")
continue
anime = anime_list[0]
# 3. 자막 필터: Anissia에 자막 제작자가 있는지 확인
if sub_filter:
captions = await self.anissia.get_captions(anime.anime_no)
if not captions:
logger.info(f" 자막 없음 → 건너뜀")
continue
logger.info(f" 자막 {len(captions)}건 발견 → 다운로드 진행")
# 4. 기존 에피소드 확인
from pathlib import Path as _Path
nas_path = _Path(self.nas_base) / folder.folder_name
existing_eps = set()
video_exts = {".mkv", ".mp4", ".avi", ".webm", ".ts"}
if nas_path.exists():
for f in nas_path.iterdir():
if f.suffix.lower() in video_exts:
ep = self._extract_episode(f.stem)
if ep is not None:
existing_eps.add(ep)
# 5. 다운로드 실행
result = await self.download(title, mode=mode)
results.append(result)
status = "" if result.success else ""
logger.info(f" {status} {result.message[:100]}")
except Exception as e:
logger.error(f" 오류 ({folder.folder_name}): {e}")
err_result = DownloadResult(
success=False,
message=f"{folder.folder_name}: 오류 - {e}",
errors=[str(e)],
)
results.append(err_result)
return results
# ── CLI 진입점 ──
if __name__ == "__main__":
import sys
import asyncio
import json
args = sys.argv[1:]
pipeline = AnimePipeline()
async def main():
if not args:
print("사용법: python tools/anime_pipeline.py [search|download|batch|status] [옵션]")
return
if args[0] == "search" and len(args) > 1:
# python tools/anime_pipeline.py search "프리렌"
title = " ".join(args[1:])
result = await pipeline.search(title)
print(result.message)
if result.errors:
print(f"⚠️ 오류: {'; '.join(result.errors)}")
elif args[0] == "download" and len(args) > 1:
# python tools/anime_pipeline.py download "프리렌" [--mode auto] [--episode 10]
title_parts = []
mode = "auto"
episode = None
i = 1
while i < len(args):
if args[i] == "--mode" and i + 1 < len(args):
mode = args[i + 1]
i += 2
elif args[i] == "--episode" and i + 1 < len(args):
episode = int(args[i + 1])
i += 2
else:
title_parts.append(args[i])
i += 1
title = " ".join(title_parts)
result = await pipeline.download(title, mode=mode, episode=episode)
print(result.message)
elif args[0] == "batch":
# python tools/anime_pipeline.py batch [--no-sub-filter] [--mode auto]
mode = "auto"
sub_filter = True
i = 1
while i < len(args):
if args[i] == "--no-sub-filter":
sub_filter = False
i += 1
elif args[i] == "--mode" and i + 1 < len(args):
mode = args[i + 1]
i += 2
else:
i += 1
print(f"📦 이번 분기 배치 다운로드 시작 (자막 필터: {'ON' if sub_filter else 'OFF'})")
results = await pipeline.batch_download(mode=mode, sub_filter=sub_filter)
success = sum(1 for r in results if r.success)
failed = sum(1 for r in results if not r.success)
print(f"\n📊 완료: {success}건 성공, {failed}건 실패 (총 {len(results)}건)")
for r in results:
icon = "" if r.success else ""
title = r.anime.subject if r.anime else "?"
print(f" {icon} {title}: {r.message[:80]}")
elif args[0] == "status":
# python tools/anime_pipeline.py status
status = await pipeline.get_status()
if not status:
print("🎬 다운로드 중인 항목 없음")
else:
for s in status:
print(f" {s['progress']} | {s['name'][:50]} | {s['speed']} | ETA: {s['eta']}")
else:
print("사용법: python tools/anime_pipeline.py [search|download|batch|status] [옵션]")
asyncio.run(main())

View File

@@ -110,11 +110,102 @@ class AnissiaClient:
] ]
async def search_anime(self, keyword: str) -> list[AnimeInfo]: async def search_anime(self, keyword: str) -> list[AnimeInfo]:
"""키워드로 전체 편성표에서 검색 (한글/일어 제목 매칭).""" """키워드로 전체 편성표에서 검색 (한글/일어/영문 fuzzy 매칭)."""
import re as _re
all_anime = await self.get_all_schedule() all_anime = await self.get_all_schedule()
keyword_lower = keyword.lower() keyword_lower = keyword.lower()
return [ # 특수문자 제거 버전 (따옴표, 괄호 등)
a for a in all_anime keyword_norm = _re.sub(r'[^\w\s]', '', keyword_lower)
if keyword_lower in a.subject.lower()
or keyword_lower in a.original_subject.lower() try:
] from tools.title_matcher import japanese_to_romaji, title_similarity
use_romaji = True
except ImportError:
use_romaji = False
results = []
fuzzy_candidates = []
for a in all_anime:
subj_lower = a.subject.lower()
orig_lower = a.original_subject.lower()
# 특수문자 제거 버전
subj_norm = _re.sub(r'[^\w\s]', '', subj_lower)
orig_norm = _re.sub(r'[^\w\s]', '', orig_lower)
# 1차: substring 매칭 (원본 + 정규화)
if (keyword_lower in subj_lower or keyword_norm in subj_norm):
results.append(a)
elif (keyword_lower in orig_lower or keyword_norm in orig_norm):
results.append(a)
elif use_romaji:
romaji = japanese_to_romaji(a.original_subject).lower()
# 2차: romaji substring
if keyword_lower in romaji:
results.append(a)
else:
# 3차: 단어 단위 fuzzy — 검색어와 romaji 개별 단어 비교
words = romaji.split()
best_word_sim = max(
(title_similarity(keyword, w) for w in words),
default=0.0,
)
# 전체 문자열 유사도도 참고
full_sim = title_similarity(keyword, romaji)
best_sim = max(best_word_sim, full_sim)
if best_sim >= 0.6:
fuzzy_candidates.append((best_sim, a))
# exact 결과가 없을 때만 fuzzy 결과 사용
if not results and fuzzy_candidates:
fuzzy_candidates.sort(key=lambda x: x[0], reverse=True)
results = [a for _, a in fuzzy_candidates[:10]]
return results
# ── CLI 진입점 ──
if __name__ == "__main__":
import sys
import asyncio
client = AnissiaClient()
args = sys.argv[1:]
async def main():
if not args:
print("사용법: python tools/anissia_client.py [schedule|search|captions] [인자]")
return
if args[0] == "schedule":
# python tools/anissia_client.py schedule 3 (수요일)
week = int(args[1]) if len(args) > 1 else 0
anime_list = await client.get_schedule(week)
day = WEEK_NAMES.get(week, "?")
print(f"📺 {day}요일 편성표 ({len(anime_list)}개):")
for a in anime_list:
cap = f"자막 {a.caption_count}" if a.caption_count else "자막 없음"
print(f" {a.time} {a.subject} ({a.original_subject}) [{cap}]")
elif args[0] == "search" and len(args) > 1:
# python tools/anissia_client.py search "프리렌"
keyword = " ".join(args[1:])
results = await client.search_anime(keyword)
print(f"🔍 '{keyword}' 검색 결과 ({len(results)}개):")
for a in results:
print(f" [{a.anime_no}] {a.subject} ({a.original_subject}) | {WEEK_NAMES.get(a.week, '?')} {a.time}")
elif args[0] == "captions" and len(args) > 1:
# python tools/anissia_client.py captions 12345
anime_no = int(args[1])
captions = await client.get_captions(anime_no)
print(f"📝 자막 목록 ({len(captions)}건):")
for c in captions:
print(f" {c.episode}화 | {c.name} | {c.website} | {c.updated}")
else:
print("사용법: python tools/anissia_client.py [schedule|search|captions] [인자]")
asyncio.run(main())

View File

@@ -150,3 +150,44 @@ class NasScanner:
"total_size_gb": round(sum(f.total_size_gb for f in folders), 2), "total_size_gb": round(sum(f.total_size_gb for f in folders), 2),
"folders": folders, "folders": folders,
} }
# ── CLI 진입점 ──
if __name__ == "__main__":
import sys
import json
scanner = NasScanner()
args = sys.argv[1:]
if not args or args[0] == "scan":
# python tools/nas_scanner.py scan [--year 26] [--quarter 1]
year = quarter = None
for i, a in enumerate(args):
if a == "--year" and i + 1 < len(args):
year = int(args[i + 1])
if a == "--quarter" and i + 1 < len(args):
quarter = int(args[i + 1])
folders = scanner.list_anime_folders(year=year, quarter=quarter)
for f in folders:
print(f"📁 {f.folder_name} | 영상 {f.video_count}개 | 자막 {f.subtitle_count}개 | {f.total_size_gb:.1f}GB")
print(f"\n{len(folders)}개 애니, 영상 {sum(f.video_count for f in folders)}")
elif args[0] == "search" and len(args) > 1:
# python tools/nas_scanner.py search "프리렌"
keyword = " ".join(args[1:])
results = scanner.search(keyword)
for f in results:
print(f"📁 {f.folder_name} | 영상 {f.video_count}개 | 자막 {f.subtitle_count}")
if not results:
print(f"'{keyword}' 검색 결과 없음")
elif args[0] == "summary":
# python tools/nas_scanner.py summary
summary = scanner.get_summary()
print(json.dumps(summary, ensure_ascii=False, indent=2, default=str))
else:
print("사용법: python tools/nas_scanner.py [scan|search|summary] [옵션]")

View File

@@ -154,3 +154,48 @@ class NyaaClient:
# 시더 수 내림차순 정렬 # 시더 수 내림차순 정렬
results.sort(key=lambda r: r.seeders, reverse=True) results.sort(key=lambda r: r.seeders, reverse=True)
return results return results
# ── CLI 진입점 ──
if __name__ == "__main__":
import sys
import asyncio
args = sys.argv[1:]
client = NyaaClient()
async def main():
if not args or args[0] == "search":
# python tools/nyaa_client.py search "Sousou no Frieren" [--suffix "ASW HEVC"]
query_parts = []
suffix = "ASW HEVC"
i = 1 if args and args[0] == "search" else 0
while i < len(args):
if args[i] == "--suffix" and i + 1 < len(args):
suffix = args[i + 1]
i += 2
elif args[i] == "--no-suffix":
suffix = ""
i += 1
else:
query_parts.append(args[i])
i += 1
if not query_parts:
print("사용법: python tools/nyaa_client.py search \"제목\" [--suffix \"ASW HEVC\"]")
return
query = " ".join(query_parts)
client.default_suffix = suffix
results = await client.search(query, use_default_suffix=bool(suffix))
print(f"🔍 Nyaa 검색: '{query}' +'{suffix}'{len(results)}")
for r in results[:20]:
ep = f" {r.episode}" if r.episode else ""
print(f" [{r.group}] {r.title[:60]}... | {r.size} | S:{r.seeders}{ep}")
print(f" magnet: {r.magnet_link[:80]}...")
else:
print("사용법: python tools/nyaa_client.py search \"제목\" [--suffix \"ASW HEVC\"]")
asyncio.run(main())

View File

@@ -196,3 +196,66 @@ class QBitClient:
} }
except Exception as e: except Exception as e:
return {"connected": False, "error": str(e), "url": self.url} return {"connected": False, "error": str(e), "url": self.url}
async def delete_torrent(self, info_hash: str, delete_files: bool = False) -> bool:
"""토렌트 삭제 (완료 후 정리용)."""
await self._ensure_login()
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.post(
f"{self.url}/api/v2/torrents/delete",
data={
"hashes": info_hash,
"deleteFiles": str(delete_files).lower(),
},
cookies=self._cookies(),
)
return resp.status_code == 200
# ── CLI 진입점 ──
if __name__ == "__main__":
import sys
import asyncio
args = sys.argv[1:]
client = QBitClient()
async def main():
if not args or args[0] == "status":
# python tools/qbit_client.py status
torrents = await client.list_torrents(category="anime")
if not torrents:
print("🎬 다운로드 중인 애니 없음")
return
print(f"🎬 다운로드 현황 ({len(torrents)}건):")
for t in torrents:
speed = f"{t.download_speed / (1024**2):.1f}MB/s" if t.download_speed > 0 else "-"
eta = f"{t.eta // 60}" if t.eta > 0 else ""
print(f" {t.progress*100:.0f}% | {t.name[:50]} | {speed} | ETA: {eta}")
elif args[0] == "add" and len(args) > 1:
# python tools/qbit_client.py add "magnet:..." --path "\\NAS\path"
magnet = args[1]
path = ""
for i, a in enumerate(args):
if a == "--path" and i + 1 < len(args):
path = args[i + 1]
ok = await client.add_torrent(magnet, save_path=path)
print(f"{'✅ 추가 성공' if ok else '❌ 추가 실패'}")
elif args[0] == "delete" and len(args) > 1:
# python tools/qbit_client.py delete <hash> [--files]
hash_ = args[1]
delete_files = "--files" in args
ok = await client.delete_torrent(hash_, delete_files=delete_files)
print(f"{'✅ 삭제 성공' if ok else '❌ 삭제 실패'}")
elif args[0] == "test":
info = await client.test_connection()
print(f"연결: {'' if info['connected'] else ''} {info}")
else:
print("사용법: python tools/qbit_client.py [status|add|delete|test] [옵션]")
asyncio.run(main())

View File

@@ -51,20 +51,16 @@ def _extract_episode_from_text(text: str) -> Optional[int]:
def parse_google_drive_links(html: str) -> list[SubtitleFile]: def parse_google_drive_links(html: str) -> list[SubtitleFile]:
"""HTML에서 Google Drive 다운로드 링크 추출. """HTML에서 Google Drive 다운로드 링크 추출.
패턴: drive.google.com/file/d/{fileId}/view 지원 패턴:
→ 직접 다운로드: drive.google.com/uc?id={fileId}&export=download 1. drive.google.com/file/d/{fileId}/view
2. drive.google.com/uc?id={fileId}&export=download (직접 다운로드)
""" """
pattern = r'https://drive\.google\.com/file/d/([a-zA-Z0-9_-]+)/view[^"]*'
matches = re.findall(pattern, html)
# 링크 주변 텍스트에서 에피소드 정보 추출
link_pattern = r'<a[^>]*href="(https://drive\.google\.com/file/d/[^"]+)"[^>]*>([^<]*)</a>'
link_matches = re.findall(link_pattern, html)
results = [] results = []
seen_ids = set() seen_ids = set()
for url, text in link_matches: # 패턴 1: /file/d/{id}/view — HTML <a> 태그
link_pattern = r'<a[^>]*href="(https://drive\.google\.com/file/d/[^"]+)"[^>]*>([^<]*)</a>'
for url, text in re.findall(link_pattern, html):
m = re.search(r'/d/([a-zA-Z0-9_-]+)/', url) m = re.search(r'/d/([a-zA-Z0-9_-]+)/', url)
if not m: if not m:
continue continue
@@ -72,19 +68,15 @@ def parse_google_drive_links(html: str) -> list[SubtitleFile]:
if file_id in seen_ids: if file_id in seen_ids:
continue continue
seen_ids.add(file_id) seen_ids.add(file_id)
episode = _extract_episode_from_text(text)
download_url = f"https://drive.google.com/uc?id={file_id}&export=download"
results.append(SubtitleFile( results.append(SubtitleFile(
filename=text.strip() or f"subtitle_{file_id}", filename=text.strip() or f"subtitle_{file_id}",
download_url=download_url, download_url=f"https://drive.google.com/uc?id={file_id}&export=download",
platform="google_drive", platform="google_drive",
episode=episode, episode=_extract_episode_from_text(text),
)) ))
# 매칭되지 않은 bare ID도 추가 # 패턴 1: bare ID (태그 밖)
for file_id in matches: for file_id in re.findall(r'drive\.google\.com/file/d/([a-zA-Z0-9_-]+)/', html):
if file_id not in seen_ids: if file_id not in seen_ids:
seen_ids.add(file_id) seen_ids.add(file_id)
results.append(SubtitleFile( results.append(SubtitleFile(
@@ -93,6 +85,23 @@ def parse_google_drive_links(html: str) -> list[SubtitleFile]:
platform="google_drive", platform="google_drive",
)) ))
# 패턴 2: uc?id={id} 직접 다운로드 URL (Blogspot 등)
uc_pattern = r'drive\.google\.com/uc\?[^"\s\)]*id=([a-zA-Z0-9_-]+)[^"\s\)]*'
for file_id in re.findall(uc_pattern, html):
if file_id in seen_ids:
continue
seen_ids.add(file_id)
# 주변 텍스트에서 파일명 추출 시도 (마크다운: [파일명](url))
md_pattern = r'\[([^\]]+)\]\([^)]*' + re.escape(file_id) + r'[^)]*\)'
md_match = re.search(md_pattern, html)
filename = md_match.group(1).strip() if md_match else f"subtitle_{file_id}"
results.append(SubtitleFile(
filename=filename,
download_url=f"https://drive.google.com/uc?id={file_id}&export=download",
platform="google_drive",
episode=_extract_episode_from_text(filename),
))
return results return results
@@ -205,20 +214,32 @@ class SubtitleDownloader:
if "download.blog.naver.com" in html or "blogfiles.pstatic.net" in html: if "download.blog.naver.com" in html or "blogfiles.pstatic.net" in html:
results.extend(parse_naver_links(html)) results.extend(parse_naver_links(html))
# 범용: 직접 자막 파일 링크 탐지 # 범용: 직접 자막 파일 링크 탐지 (HTML href + 마크다운)
generic_pattern = r'href="([^"]+\.(?:ass|srt|ssa|sub|zip|7z))"'
generic = re.findall(generic_pattern, html, re.IGNORECASE)
seen_urls = {r.download_url for r in results} seen_urls = {r.download_url for r in results}
for gurl in generic:
# HTML <a href="...">
for gurl in re.findall(r'href="([^"]+\.(?:ass|srt|ssa|sub|zip|7z)(?:\?[^"]*)?)"', html, re.IGNORECASE):
if gurl not in seen_urls: if gurl not in seen_urls:
filename = gurl.split("/")[-1].split("?")[0] seen_urls.add(gurl)
filename = unquote(gurl.split("/")[-1].split("?")[0])
results.append(SubtitleFile( results.append(SubtitleFile(
filename=unquote(filename), filename=filename,
download_url=gurl, download_url=gurl,
platform="generic", platform="generic",
episode=_extract_episode_from_text(filename), episode=_extract_episode_from_text(filename),
)) ))
# 마크다운 [텍스트](url) — Blogspot 등
for text, gurl in re.findall(r'\[([^\]]+)\]\((https?://[^)]+\.(?:ass|srt|ssa|sub|zip|7z)[^)]*)\)', html, re.IGNORECASE):
if gurl not in seen_urls:
seen_urls.add(gurl)
results.append(SubtitleFile(
filename=text.strip(),
download_url=gurl,
platform="generic",
episode=_extract_episode_from_text(text),
))
logger.info(f"자막 {len(results)}건 발견: {url}") logger.info(f"자막 {len(results)}건 발견: {url}")
return results return results
@@ -227,7 +248,7 @@ class SubtitleDownloader:
sub: SubtitleFile, sub: SubtitleFile,
save_dir: Optional[str] = None, save_dir: Optional[str] = None,
) -> str: ) -> str:
"""자막 파일 다운로드 → 로컬 저장. 저장 경로 반환.""" """자막 파일 다운로드 → 로컬 저장. ZIP이면 자동 해제. 저장 경로 반환."""
target_dir = Path(save_dir) if save_dir else self.download_dir target_dir = Path(save_dir) if save_dir else self.download_dir
target_dir.mkdir(parents=True, exist_ok=True) target_dir.mkdir(parents=True, exist_ok=True)
@@ -248,13 +269,55 @@ class SubtitleDownloader:
# Content-Disposition에서 실제 파일명 추출 # Content-Disposition에서 실제 파일명 추출
cd = resp.headers.get("content-disposition", "") cd = resp.headers.get("content-disposition", "")
if "filename" in cd: if "filename" in cd:
m = re.search(r'filename[*]?=["\']?(?:UTF-8\'\')?([^"\';\n]+)', cd) m = re.search(r'filename[*]?=["\']?(?:UTF-8\'\')?([^"\';\\n]+)', cd)
if m: if m:
sub.filename = unquote(m.group(1).strip()) sub.filename = unquote(m.group(1).strip())
filepath = target_dir / sub.filename filepath = target_dir / sub.filename
filepath.write_bytes(resp.content) filepath.write_bytes(resp.content)
# ZIP/7z 자동 해제
extracted = self._extract_archive(filepath, target_dir)
if extracted:
sub.local_path = extracted[0] # 첫 번째 자막 파일
sub.filename = Path(extracted[0]).name
logger.info(f"자막 ZIP 해제 완료: {len(extracted)}건 → {target_dir}")
return extracted[0]
sub.local_path = str(filepath) sub.local_path = str(filepath)
logger.info(f"자막 다운로드 완료: {filepath}") logger.info(f"자막 다운로드 완료: {filepath}")
return str(filepath) return str(filepath)
@staticmethod
def _extract_archive(filepath: Path, target_dir: Path) -> list[str]:
"""ZIP/7z 파일 해제 → 자막 파일(.ass/.srt/.ssa/.sub) 경로 리스트 반환."""
import zipfile
suffix = filepath.suffix.lower()
if suffix not in (".zip", ".7z"):
return []
extracted = []
if suffix == ".zip":
try:
with zipfile.ZipFile(filepath, "r") as zf:
for name in zf.namelist():
# 디렉토리 건너뛰기
if name.endswith("/"):
continue
ext = Path(name).suffix.lower()
if ext in (".ass", ".srt", ".ssa", ".sub"):
# 중첩 폴더 무시, 파일만 추출
out_name = Path(name).name
out_path = target_dir / out_name
with zf.open(name) as src, open(out_path, "wb") as dst:
dst.write(src.read())
extracted.append(str(out_path))
# ZIP 원본 삭제
filepath.unlink(missing_ok=True)
except (zipfile.BadZipFile, Exception) as e:
logger.warning(f"ZIP 해제 실패: {filepath} - {e}")
return extracted

View File

@@ -10,9 +10,70 @@ import unicodedata
from difflib import SequenceMatcher from difflib import SequenceMatcher
from typing import Optional from typing import Optional
import httpx
logger = logging.getLogger("variet.tools.matcher") logger = logging.getLogger("variet.tools.matcher")
# ──────────────────────────────────────────────
# 영어 제목 조회 (Jikan API / MyAnimeList)
# ──────────────────────────────────────────────
async def fetch_english_title(japanese_title: str) -> dict[str, str]:
"""Jikan API로 일본어 원제의 영어/로마자 제목 조회.
Returns:
{"default": "Sousou no Frieren 2nd Season",
"english": "Frieren: Beyond Journey's End Season 2",
"synonyms": ["Frieren at the Funeral Season 2"]}
실패 시 빈 dict.
"""
try:
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.get(
"https://api.jikan.moe/v4/anime",
params={"q": japanese_title, "limit": 5},
)
resp.raise_for_status()
data = resp.json()
items = data.get("data", [])
if not items:
return {}
# 원제와 가장 잘 매칭되는 항목 선택
best = None
best_score = 0.0
for item in items:
jp = item.get("title_japanese", "")
score = SequenceMatcher(None, japanese_title, jp).ratio()
if score > best_score:
best_score = score
best = item
if not best or best_score < 0.5:
return {}
result = {
"default": best.get("title", ""),
"english": best.get("title_english") or "",
"synonyms": [],
}
for t in best.get("titles", []):
if t["type"] == "Synonym":
result["synonyms"].append(t["title"])
logger.info(
f"Jikan 영어 제목 조회: {japanese_title}"
f"default={result['default']}, english={result['english']}"
)
return result
except Exception as e:
logger.warning(f"Jikan API 조회 실패: {e}")
return {}
# ────────────────────────────────────────────── # ──────────────────────────────────────────────
# 일어 → 로마자 변환 테이블 (히라가나/카타카나) # 일어 → 로마자 변환 테이블 (히라가나/카타카나)
# ────────────────────────────────────────────── # ──────────────────────────────────────────────
@@ -66,24 +127,35 @@ def _kata_to_hira(text: str) -> str:
def japanese_to_romaji(text: str) -> str: def japanese_to_romaji(text: str) -> str:
"""일본어 텍스트를 로마자로 근사 변환.""" """일본어 텍스트를 로마자로 변환 (pykakasi 기반, fallback: 카나 테이블)."""
try:
import pykakasi
kks = pykakasi.kakasi()
result_items = kks.convert(text)
romaji = " ".join(item["hepburn"] for item in result_items)
# 연속 공백 정리
romaji = re.sub(r'\s+', ' ', romaji).strip()
return romaji
except ImportError:
logger.warning("pykakasi 미설치 — 카나 테이블 fallback 사용")
return _japanese_to_romaji_fallback(text)
def _japanese_to_romaji_fallback(text: str) -> str:
"""일본어→로마자 fallback (카나만 변환, 한자는 그대로)."""
text = _kata_to_hira(text) text = _kata_to_hira(text)
result = [] result = []
i = 0 i = 0
while i < len(text): while i < len(text):
# 장음 기호 (ー U+30FC, ー가 히라가나로 안 변환되므로 여기서 처리)
if text[i] == '\u30FC': # ー if text[i] == '\u30FC': # ー
# 장음: 이전 모음 반복 (간략화: 스킵)
i += 1 i += 1
continue continue
# 2글자 매칭 우선 (きゃ 등)
if i + 1 < len(text) and text[i:i+2] in _KANA_ROMAJI: if i + 1 < len(text) and text[i:i+2] in _KANA_ROMAJI:
result.append(_KANA_ROMAJI[text[i:i+2]]) result.append(_KANA_ROMAJI[text[i:i+2]])
i += 2 i += 2
elif text[i] in _KANA_ROMAJI: elif text[i] in _KANA_ROMAJI:
romaji = _KANA_ROMAJI[text[i]] romaji = _KANA_ROMAJI[text[i]]
# 촉음(っ) 처리: 다음 자음 반복
if text[i] == '' and i + 1 < len(text): if text[i] == '' and i + 1 < len(text):
next_romaji = _KANA_ROMAJI.get(text[i+1], "") next_romaji = _KANA_ROMAJI.get(text[i+1], "")
if next_romaji: if next_romaji:
@@ -92,13 +164,13 @@ def japanese_to_romaji(text: str) -> str:
result.append(romaji) result.append(romaji)
i += 1 i += 1
else: else:
# 한자, 영어, 숫자 등 → 그대로
result.append(text[i]) result.append(text[i])
i += 1 i += 1
return "".join(result) return "".join(result)
def normalize_title(title: str) -> str: def normalize_title(title: str) -> str:
"""제목 비교용 정규화: 소문자 + 특수문자 제거 + 공백 정리.""" """제목 비교용 정규화: 소문자 + 특수문자 제거 + 공백 정리."""
title = title.lower().strip() title = title.lower().strip()
@@ -155,8 +227,14 @@ def match_titles(
best_sim = max(sim_romaji, sim_korean, sim_original) best_sim = max(sim_romaji, sim_korean, sim_original)
if best_sim >= threshold: # ASW HEVC 릴리스는 threshold 면제 (약칭으로 올라와도 포함)
scored.append((best_sim, result)) title_upper = result.title.upper()
is_preferred = "[ASW]" in result.title and ("HEVC" in title_upper or "X265" in title_upper)
if best_sim >= threshold or is_preferred:
# ASW HEVC 릴리스는 유사도 보너스 (+0.5) → 정렬 시 상위 배치
effective_sim = best_sim + 0.5 if is_preferred else best_sim
scored.append((effective_sim, result))
# 유사도 내림차순 정렬 # 유사도 내림차순 정렬
scored.sort(key=lambda x: x[0], reverse=True) scored.sort(key=lambda x: x[0], reverse=True)

View File

@@ -1,7 +1,41 @@
{ {
"5608566207": {
"name": "test_1_orphan_20260307",
"path": "c:\\Users\\Certes\\Desktop\\VW_Proj\\test_1_orphan_20260307",
"channel_id": 0,
"git": {
"url": "",
"token": "",
"repo": "",
"branch": "main"
},
"vikunja": {
"url": "",
"token": "",
"project_id": 0
},
"docs_path": "docs/wiki"
},
"8350378037": {
"name": "test_2_orphan_20260307",
"path": "c:\\Users\\Certes\\Desktop\\VW_Proj\\test_2_orphan_20260307",
"channel_id": 0,
"git": {
"url": "",
"token": "",
"repo": "",
"branch": "main"
},
"vikunja": {
"url": "",
"token": "",
"project_id": 0
},
"docs_path": "docs/wiki"
},
"1480113683849023661": { "1480113683849023661": {
"name": "variet-agent", "name": "variet-agent",
"path": "c:\\Users\\Variet-Worker\\Desktop\\VW_Proj\\variet-agent", "path": "c:\\Users\\Certes\\Desktop\\variet-agent",
"channel_id": 1480113683849023661, "channel_id": 1480113683849023661,
"git": { "git": {
"url": "", "url": "",
@@ -15,5 +49,22 @@
"project_id": 0 "project_id": 0
}, },
"docs_path": "docs/wiki" "docs_path": "docs/wiki"
},
"3237068326": {
"name": "test_1_orphan_20260314",
"path": "c:\\Users\\Certes\\Desktop\\VW_Proj\\test_1_orphan_20260314",
"channel_id": 0,
"git": {
"url": "",
"token": "",
"repo": "",
"branch": "main"
},
"vikunja": {
"url": "",
"token": "",
"project_id": 0
},
"docs_path": "docs/wiki"
} }
} }