orchestrator.py·models.py·ai/·scrapers/·mcp/ 소스까지 직접 읽어 정리했다.이 레포가 무엇을 하는 물건인가.
당신이 매일 아침 Hacker News, 좋아하는 서브레딧, 블로그 RSS, 텔레그램 채널을 일일이 열어 "오늘 볼 만한 게 뭐지?"를 가린다면, Horizon은 그 "여러 출처에서 가져오기 → 같은 이야기 합치기 → 중요도 점수 매기기 → 배경·댓글 붙이기 → 한 편의 요약 만들기" 전 과정을 하나의 비동기 파이프라인으로 자동화한다.
핵심은 단순 요약기가 아니라는 점이다. AI는 노이즈를 줄이는 데는 뛰어나지만, 어떤 출처를 신뢰할지·어떤 댓글이 기사를 다르게 보게 하는지는 사람의 취향이 필요하다. Horizon은 출처·임계값·모델·언어·배달 채널을 모두 하나의 JSON 설정으로 사용자가 통제하게 해서 그 인간 레이어를 유지한다.
Horizon은 한 줄로 말하면 "설정 가능한 개인용 AI 뉴스 어그리게이터"다. API 호출 한 번으로 끝나는 도구가 아니라, 7단계 워크플로(정의 → 수집 → 중복제거 → 점수·필터 → 보강 → 요약 → 배달)를 HorizonOrchestrator가 조율한다. AI 제공자는 Claude·GPT·Gemini·DeepSeek·Doubao·MiniMax·Ollama 등 OpenAI 호환 API를 자유롭게 꽂을 수 있고, 결과는 영어·중국어로 동시 생성된다. GitHub Actions 크론으로 매일 자동 실행해 Pages에 게시하는 것이 대표 사용 시나리오다.
트렌딩 이유 · 일반 RSS 리더/요약봇 대비 장점.
뉴스 요약봇은 흔하다. Horizon이 주목받는 이유는 "진짜 실전에서 골치 아픈 부분"을 파이프라인 단계마다 풀었기 때문이다. 첫째 7개 이질적 출처를 단일 모델(ContentItem)로 통합, 둘째 교차 출처 URL 병합 + AI 의미론적 중복 제거(같은 사건의 여러 보도 합치기), 셋째 0~10 점수화 후 임계값 필터로 노이즈 차단, 넷째 웹 검색으로 배경지식을 보강하고 댓글까지 요약, 다섯째 4가지 배달 채널 + MCP 노출이다.
| 비교 항목 | 일반 RSS 리더 / 요약봇 | Horizon |
|---|---|---|
| 출처 범위 | RSS 위주, 단일 종류 | HN·RSS·Reddit·Telegram·X·GitHub·OpenBB·OSSInsight 7+종 통합 |
| 중복 처리 | 없음(같은 기사 여러 번 노출) | URL 정규화 병합 + AI 의미론적 토픽 중복제거 |
| 중요도 판단 | 최신순/인기순 | AI 0~10 점수 + 임계값 필터 + 카테고리별 쿼터 |
| 배경 보강 | 제목·요약만 | 웹 검색(DDGS)으로 낯선 개념·기업·용어 배경 추가 |
| 커뮤니티 의견 | 없음 | HN/Reddit 상위 댓글 수집·요약(여론 맥락) |
| 언어 | 원문 그대로 | 영어·중국어 동시 생성 |
| AI 제공자 | 고정(보통 OpenAI) | Claude·GPT·Gemini·DeepSeek·Ollama 등 교체 가능 |
| 배달 | 앱 내 피드 | GitHub Pages·이메일·웹훅(Feishu/Slack/Discord)·MCP |
출처를 단순히 다 긁어 LLM에 던지면 세 가지가 무너진다. ① 같은 사건이 HN·Reddit·X에 중복되어 같은 요약이 반복되고, ② 중요도 구분 없이 사소한 업데이트와 패러다임 전환이 같은 비중으로 나오며, ③ 맥락 없는 제목만 읽고는 "이게 왜 중요한지" 알 수 없다. 게다가 출처마다 API·인증·페이지네이션이 전부 달라 코드가 스파게티가 된다.
Horizon은 수집을 모든 스크래퍼가 공통 BaseScraper 인터페이스를 구현하고 결과를 단일 ContentItem으로 반환하게 통일했다. 그 위에서 병합 → 점수 → 필터 → 보강 → 요약을 순차 단계로 쪼개, 각 단계가 명확한 입출력을 갖는 파이프라인을 만들었다. 무엇을 신뢰하고 얼마나 엄격히 거를지는 전부 data/config.json으로 사용자가 정한다. "넓게 모으되, 사람 기준으로 정확하게 거른다"가 설계 철학이다.
백엔드(파이프라인 엔진)·프론트엔드(브리핑/배달)·인프라 각각.
Horizon은 100% 파이썬(3.11+)이며, 수집은 본질적으로 I/O 바운드(여러 사이트 응답 대기)라 httpx.AsyncClient + asyncio.gather로 모든 출처를 동시에 긁는다. AI 단계는 제공자 추상화 계층(ai/client.py)으로 어떤 LLM이든 같은 complete(system, user) 인터페이스로 부른다.
| 요소 | 역할 |
|---|---|
| Python 3.11+ | 전체 언어. 타입힌트·async/await·dataclass 적극 사용. |
httpx | 비동기 HTTP 클라이언트. AsyncClient로 모든 스크래퍼가 커넥션 공유. |
asyncio | 동시성 런타임. gather(*tasks, return_exceptions=True)로 출처 동시 수집. |
pydantic v2 | 핵심 데이터 모델(ContentItem·Config) 검증·직렬화. HttpUrl 타입 등. |
anthropic·openai·google-genai | 3대 LLM SDK. 그 외는 OpenAI 호환 base_url로 흡수(DeepSeek·Doubao·Ollama 등). |
tenacity | AI 호출 재시도(retry + wait_exponential). LLM 일시 오류 대응. |
feedparser | RSS/Atom 파싱. |
ddgs | DuckDuckGo 검색 — 배경지식 보강(enrich) 단계의 웹 검색기. |
beautifulsoup4 | 스크래핑한 HTML에서 본문 추출. |
rich | 터미널 진행 막대·컬러 로그(파이프라인 단계별 출력). |
mcp | Model Context Protocol 서버 — 파이프라인 단계를 AI 어시스턴트 도구로 노출. |
| 요소 | 역할 |
|---|---|
| Markdown 브리핑 | 최종 산출물. DailySummarizer가 요약·태그·참조로 구조화한 .md 생성. |
| GitHub Pages (Jekyll) | 생성된 md를 docs/_posts/에 front matter 붙여 복사 → Pages가 매일 사이트 발행. |
| 이메일 (SMTP/IMAP) | services/email.py — 셀프호스팅 뉴스레터. 구독/해지를 IMAP으로 자동 처리. |
| 웹훅 | services/webhook.py — Feishu/Lark·DingTalk·Slack·Discord·커스텀 엔드포인트로 푸시. |
markdown | md → HTML 변환(이메일/웹 표시용). |
| 요소 | 역할 |
|---|---|
uv + pyproject.toml | astral-sh의 초고속 패키지 매니저. uv sync --frozen + uv.lock으로 재현 가능 설치. |
| Docker | python:3.11-slim 기반. uv로 의존성 설치, data/를 볼륨 마운트. |
| docker-compose | --hours 24 윈도우로 일일 실행. restart: unless-stopped. |
| GitHub Actions | daily-summary.yml 크론 → 브리핑 생성 → Pages 자동 배포(deploy-docs.yml). |
| 설정 마법사 | horizon-wizard — 관심사 입력 → config.json 자동 생성(대화형). |
| 선택 의존성(extras) | openbb(금융), twitter(Playwright), dev(pytest)를 extra로 분리. |
/chat/completions 요청·응답 형식을 그대로 따르는 LLM 엔드포인트. DeepSeek·Doubao·MiniMax·Ollama·Together 등 수많은 제공자가 이 규격을 지원하므로, Horizon은 base_url만 바꿔 끼우면 같은 코드로 다른 모델을 쓸 수 있다.설정 → 동시 수집 → 병합 → 점수·필터 → 보강 → 이중언어 요약 → 배달.
입력은 JSON 설정 1개 + 시간 윈도우(기본 24시간). HorizonOrchestrator.run()이 전 단계를 조율한다. 설정에 켜진 스크래퍼들이 asyncio.gather로 동시에 콘텐츠를 긁어 와 모두 ContentItem으로 정규화하고, URL 병합 → AI 점수 → 임계값 필터 → AI 토픽 중복제거 → 카테고리 쿼터 → 웹검색 보강 → 언어별 요약 → 배달 순으로 흐른다. 각 단계가 명확한 "입력 리스트 → 출력 리스트" 변환이라 추적과 디버깅이 쉽다.
ContentItem아키텍처의 토대는 "7개 이질적 출처를 단 하나의 모델로 통일"한 것이다. HN 스토리든 트윗이든 GitHub 릴리스든, 스크래퍼는 전부 같은 ContentItem(pydantic 모델)을 반환한다. 이후 모든 단계는 출처가 무엇인지 신경 쓰지 않고 이 모델만 다룬다.
class ContentItem(BaseModel):
id: str # "{source}:{subtype}:{native_id}"
source_type: SourceType # github / hackernews / rss / reddit ...
title: str
url: HttpUrl
content: Optional[str] = None
author: Optional[str] = None
published_at: datetime
metadata: Dict[str, Any] = {} # score, comments, subreddit ...
# ── AI 분석 결과(나중에 채워짐) ──
ai_score: Optional[float] = None # 0~10 중요도
ai_reason: Optional[str] = None
ai_summary: Optional[str] = None
ai_tags: List[str] = []
출처별 고유 정보(서브레딧·좋아요 수·댓글 등)는 자유로운 metadata 사전에 담아 모델을 더럽히지 않는다. ai_* 필드는 처음엔 비어 있다가 점수·요약 단계에서 채워지는 "단계가 진행되며 풍부해지는 객체"다.
Horizon은 중복을 두 단계로 처리한다. 단순한 것은 코드로, 어려운 것은 AI로.
| 단계 | 방식 | 잡는 것 |
|---|---|---|
| ② 교차 출처 병합 | URL 정규화 후 문자열 비교(코드) | 같은 링크가 HN·Reddit·X에 동시 등장 → 하나로 |
| ⑤ 토픽 중복제거 | 제목·태그·요약을 AI에 한 번에 보내 판정 | 다른 링크지만 같은 사건 보도 → 최고점만 남김 |
⑤가 영리한 점은 프롬프트(TOPIC_DEDUP_SYSTEM)에서 "같은 제품이라도 다른 사건이면 중복 아님"("Gemma 4 출시" vs "Gemma 4 탈옥")이라고 못 박고, 애매하면 분리 쪽으로 기우는(err on the side of keeping separate) 보수적 설계라는 점이다. 또 점수 내림차순으로 정렬된 상태에서 처리해 각 중복 그룹의 첫 번째(최고점)가 자동으로 대표가 되게 한다.
편집장이 기사 더미를 정리한다고 하자. ② 단계는 "똑같은 기사 복사본"을 손으로 골라 버리는 일이다(URL이 같으니 기계도 한다). ⑤ 단계는 "제목과 출처는 다르지만 같은 사건을 다룬 기사들"을 알아보는 일이라 사람(=AI)의 판단이 필요하다. Horizon은 쉬운 건 기계에, 어려운 건 AI에 맡겨 비용과 정확도를 동시에 잡는다.
Horizon은 어떤 모델을 쓰든 코드가 바뀌지 않게 추상 기반 클래스 AIClient를 두고, complete(system, user) 하나로 통일했다. create_ai_client(config) 팩토리가 설정의 provider에 따라 Anthropic/OpenAI/Gemini 구현체를 만들어 준다. DeepSeek·Doubao·Ollama처럼 OpenAI 호환인 것들은 base_url만 바꿔 OpenAI 클라이언트를 재사용한다.
| 패턴 | Horizon에서의 구현 |
|---|---|
| 파이프라인(단계 분리) | run()이 fetch→merge→score→filter→dedup→balance→enrich→summarize→deliver를 순차 호출. 각 단계 = 리스트 변환. |
| 어댑터 / 통합 모델 | 모든 스크래퍼가 BaseScraper 구현 → 단일 ContentItem 반환. |
| 전략(Strategy) / 팩토리 | create_ai_client()가 provider별 AIClient 구현 선택. complete() 인터페이스 통일. |
| 동시 수집 | asyncio.gather(*tasks, return_exceptions=True) — 한 출처가 실패해도 나머지는 진행. |
| 동시성 제한 | 분석/보강은 asyncio.Semaphore(concurrency)로 API 폭주·레이트리밋 방지. |
| 재시도 | @retry(stop_after_attempt(3), wait_exponential) — LLM 일시 오류 흡수. |
| 설정 주도 | 출처·임계값·모델·언어·배달을 모두 config.json으로 외부화. ${ENV} 치환 지원. |
| 우아한 실패 | 중복제거 AI 실패 시 원본 리스트 그대로 반환(파이프라인 안 멈춤). 단계별 try/except. |
src/ 안에 스크래퍼·AI·서비스·MCP가 책임별로 나뉘어 있다.
① models.py의 ContentItem·SourceType으로 "데이터가 어떻게 흐르나" 파악 → ② scrapers/base.py로 수집기 계약 확인 → ③ orchestrator.py의 run()으로 전체 단계 흐름 정독 → ④ ai/analyzer.py·prompts.py로 점수 로직 → ⑤ ai/client.py로 멀티 LLM 추상화. 이 다섯 파일이면 핵심 통찰의 90%를 얻는다.
멀티소스 + AI 파이프라인에서 무엇을 배우나.
Horizon의 가장 큰 교훈은 "복잡한 일을 명확한 입출력을 가진 작은 단계의 사슬로 만들라"이다. run()을 읽으면 fetch→merge→score→filter→dedup→balance→enrich→summarize→deliver가 각각 "리스트를 받아 리스트를 반환"하는 변환임이 보인다. 이렇게 하면 단계마다 단위 테스트가 쉽고, 중간 결과를 로깅·검사할 수 있으며(실제로 MCP는 각 단계를 개별 도구로 노출), 새 단계 삽입도 쉽다.
7개 출처는 API·인증·응답 형식이 전부 다르다. Horizon은 이를 BaseScraper 추상 클래스 + 단일 ContentItem으로 흡수했다. 새 출처를 추가해도 fetch(since) -> List[ContentItem]만 구현하면 파이프라인 나머지는 손댈 필요가 없다. "외부 세계의 다양성을 경계에서 정규화하고, 내부는 단일 모델로 단순하게"의 모범이다.
ai/client.py는 "애플리케이션 코드가 특정 LLM SDK에 묶이지 않게 하는" 실전 예제다. 추상 AIClient.complete() 뒤로 Anthropic·OpenAI·Gemini 구현을 숨기고, OpenAI 호환 제공자는 base_url로 흡수한다. 이 덕분에 사용자는 설정 한 줄로 Claude ↔ GPT ↔ 로컬 Ollama를 갈아탈 수 있다. 벤더 락인을 피하는 설계를 배우기 좋다.
complete(system, user) -> str 추상 메서드를 가진 베이스 클래스를 만들고, OpenAI용·더미(에코)용 두 구현을 작성한 뒤 설정값으로 어느 쪽을 쓸지 고르는 팩토리를 짜 보라.한 출처가 죽어도 전체가 멈추면 안 된다. Horizon은 gather(..., return_exceptions=True)로 예외를 결과로 받아 출처별로 격리하고, AI 단계는 Semaphore로 동시 호출 수를 제한해 레이트리밋·풀 고갈을 막는다. 또 tenacity로 지수 백오프 재시도를 건다. "외부에 의존하는 시스템은 반드시 실패를 전제로 설계하라"는 원칙의 실물.
gather(..., return_exceptions=True)로 돌려, 실패는 로깅하고 성공만 모으는 코드를 짜 보라. 그다음 Semaphore(3)로 동시성을 3으로 제한해 보라.prompts.py는 "AI에게 시킬 판단을 어떻게 명세하나"의 교과서다. 점수 프롬프트는 9-10/7-8/5-6/3-4/0-2 각 구간을 구체 예시와 함께 정의하고, JSON으로만 응답하게 강제한다. 중복 프롬프트는 경계 사례("같은 제품 다른 사건")를 명시해 오판을 줄인다. 출력 포맷을 JSON으로 고정 + 파싱 실패 시 폴백(parse_json_response)하는 견고함도 배울 점이다.
설치는 가볍지만 "API 키와 설정"이 실질적 진입 장벽이다.
| 항목 | 요구사항 |
|---|---|
| 런타임 | Python 3.11 이상. uv sync(권장) 또는 pip install -e .. |
| AI 키(필수) | 최소 하나의 LLM API 키 — ANTHROPIC_API_KEY·OPENAI_API_KEY 등. 로컬 Ollama는 키 불필요. |
| 네트워크 | 인터넷(출처 수집·웹검색 보강·LLM 호출). 오프라인 운영은 사실상 불가. |
| GitHub 토큰(권장) | GITHUB_TOKEN — 없으면 시간당 60요청, 있으면 5,000요청. |
| Twitter(선택) | Apify 모드는 APIFY_TOKEN, 또는 twitter extra의 Playwright 모드. |
| 금융 뉴스(선택) | uv sync --extra openbb — OpenBB Platform SDK. |
| 배포 옵션 | 로컬 CLI · Docker(compose) · GitHub Actions 크론(서버 없이 무료 자동화). |
| 비용 고려 | 수집량 × 출처 수 × 점수/보강/요약 LLM 호출 = 토큰 비용. 임계값·동시성·max_items로 통제. |
uv run horizon-wizard로 관심사를 입력해 config.json을 자동 생성한 뒤, 처음엔 RSS 1~2개와 HN만 켜고 저렴한 모델(예: gpt-4o-mini·gemini-flash·로컬 Ollama)로 돌려 비용을 체감하라. 익숙해지면 출처·언어·배달 채널을 늘리고 GitHub Actions 크론으로 자동화하면 된다.
난이도별 5단계 — 소스를 읽고 직접 손대 본다.
uv sync 후 LLM 키 하나를 .env에 넣고 uv run horizon-wizard로 관심사를 입력해 config.json을 생성한다. RSS 한두 개와 HN만 켜고 uv run horizon --hours 48을 돌려 data/summaries/에 생성된 마크다운 브리핑을 읽어 본다.
orchestrator.py의 run()을 처음부터 끝까지 읽고, fetch→merge→score→filter→dedup→balance→enrich→summarize→deliver 각 단계가 어떤 리스트를 받아 어떤 리스트를 내보내는지를 표로 그려 본다. 임계값(ai_score_threshold)을 바꿔 통과 개수가 어떻게 변하는지 로그로 확인.
config.json의 sources.rss에 좋아하는 블로그 피드를 추가하고, prompts.py의 CONTENT_ANALYSIS_SYSTEM 점수 기준을 본인 관심사(예: "프론트엔드보다 인프라를 높게")에 맞게 살짝 수정해 결과 순위 변화를 비교한다.
scrapers/base.py의 BaseScraper를 상속해, 임의의 공개 JSON API(예: Lobsters·Dev.to)에서 글을 가져와 ContentItem으로 변환하는 새 스크래퍼를 만든다. orchestrator.fetch_all_sources()에 끼워 넣어 기존 파이프라인이 그대로 점수·요약까지 처리하는지 확인한다.
horizon-mcp를 실행하고 MCP 클라이언트(예: Claude Desktop)에 연결해 hz_fetch_items→hz_score_items→hz_filter_items→hz_generate_summary를 단계별로 호출해 본다. mcp/service.py가 오케스트레이터 단계를 어떻게 래핑하는지, 실행 이력(run_store.py)이 어떻게 저장되는지 추적한다.
6주 코스 — 비동기 수집 기초부터 LLM 파이프라인 설계까지.
| 주차 | 주제 | 실습 · 참고 |
|---|---|---|
| 1주차 | asyncio / httpx 기초 — 코루틴·이벤트 루프·gather | 공식 asyncio 문서 · 여러 API 동시 수집 미니 스크립트 |
| 2주차 | 견고한 동시성 — 실패 격리·세마포어·재시도(tenacity) | orchestrator.fetch_all_sources() 정독 · return_exceptions 실험 |
| 3주차 | 도메인 모델링 — pydantic으로 통합 모델 설계 | models.py 분석 · ContentItem 직접 확장 |
| 4주차 | 어댑터 패턴 — 이질적 출처 정규화 | scrapers/ 비교 · 새 스크래퍼 구현(과제 4) |
| 5주차 | LLM 통합 — 제공자 추상화·프롬프트·JSON 파싱 | ai/client.py·analyzer.py·prompts.py · 멀티 LLM 팩토리 |
| 6주차 | 배달·자동화 — MCP·웹훅·GitHub Actions 크론 | mcp/server.py·services/ · 일일 자동 발행 워크플로 구성 |
본문·소스에 나온 용어 빠른 참조.
| 용어 | 의미 |
|---|---|
| HorizonOrchestrator | 전 파이프라인을 조율하는 핵심 클래스. run()이 9단계를 순차 실행. |
| ContentItem | 모든 출처를 통일한 pydantic 데이터 모델. 단계가 진행되며 ai_* 필드가 채워짐. |
| BaseScraper | 모든 수집기의 추상 부모. fetch(since) -> List[ContentItem] 구현 강제. |
| SourceType | 지원 출처 열거형: github·hackernews·rss·reddit·telegram·twitter·openbb·ossinsight. |
| 교차 출처 병합 | 같은 URL이 여러 출처에 등장하면 URL 정규화 후 하나로 합치는 코드 기반 중복제거(② 단계). |
| 토픽 중복제거 | 다른 링크지만 같은 사건을 다룬 기사들을 AI로 묶어 최고점만 남기는 의미론적 중복제거(⑤ 단계). |
| ai_score | AI가 매긴 0~10 중요도 점수. ai_score_threshold 이상만 브리핑에 포함. |
| enrich (보강) | 임계값 통과 항목에 DDGS 웹검색으로 배경지식·맥락을 붙이는 2차 AI 패스. |
| balanced digest | 카테고리별 쿼터(limit)와 전체 상한(max_items)으로 주제 쏠림을 막는 단계(⑥). |
| AIClient / create_ai_client | LLM 제공자 추상화. complete(system, user)로 통일, 팩토리가 구현 선택. |
| OpenAI 호환 | OpenAI API 규격을 따르는 엔드포인트. base_url만 바꿔 DeepSeek·Ollama 등 재사용. |
| uv | astral-sh의 초고속 파이썬 패키지 매니저. uv sync --frozen + uv.lock으로 재현 설치. |
| MCP (Model Context Protocol) | AI 어시스턴트가 도구를 호출하는 표준. Horizon은 파이프라인 단계를 hz_* 도구로 노출. |
| parse_json_response | LLM 응답에서 JSON만 안전하게 추출하는 헬퍼. 여러 전략으로 파싱 실패에 대비. |
| GitHub Pages 발행 | 생성된 md를 Jekyll front matter 붙여 docs/_posts/에 복사 → Pages가 일일 사이트 발행. |