diff --git a/docs/superpowers/plans/2026-05-16-insta-trends-implementation.md b/docs/superpowers/plans/2026-05-16-insta-trends-implementation.md new file mode 100644 index 0000000..439a83a --- /dev/null +++ b/docs/superpowers/plans/2026-05-16-insta-trends-implementation.md @@ -0,0 +1,1781 @@ +# insta Trends Tab Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a "Trends" tab to the Insta page that pulls external trends from NAVER popular + Google Trends, lets the user set category weights, and feeds those weights back into the daily keyword extraction pipeline. + +**Architecture:** New `trend_collector` module in insta-lab (NAVER `news.json` 인기순 + `pytrends` Google Trends + Claude Haiku 카테고리 분류 + in-memory 캐시). Existing `trending_keywords` table gets a `source` column; new `account_preferences` table stores category weights. `keyword_extractor` gains a `extract_with_weights()` variant. InstaAgent runs a new 09:00 cron for trend collection and applies weights at 09:30 extraction. web-ui splits InstaCards into Cards/Trends tabs and adds 3 panels (AccountFocus / ExternalTrends / PreferenceImpact). + +**Tech Stack:** Python 3.12, FastAPI, SQLite, anthropic 0.52, **pytrends 4.9+** (new), httpx, React + Vite + plain CSS. + +**Spec reference:** `docs/superpowers/specs/2026-05-16-insta-trends-design.md` + +--- + +## File Structure + +### Files to create + +| Path | Responsibility | +|------|----------------| +| `insta-lab/app/trend_collector.py` | NAVER 인기 + Google Trends 수집 + LLM 카테고리 분류 + 1일 in-memory 캐시 | +| `insta-lab/tests/test_trend_collector.py` | mocked requests / mocked pytrends / mocked anthropic | +| `insta-lab/tests/test_extract_with_weights.py` | 가중치 알고리즘 — 균등/0/공집합 fallback | +| `insta-lab/tests/test_preferences_crud.py` | account_preferences upsert + 기본값 시드 | +| `insta-lab/tests/test_main_trends.py` | 신규 4개 엔드포인트 통합 (TestClient) | +| `agent-office/tests/test_insta_agent_trends.py` | 09:00 cron + 가중치 적용 분기 | + +### Files to modify + +| Path | Change | +|------|--------| +| `insta-lab/requirements.txt` | + `pytrends>=4.9` | +| `insta-lab/app/db.py` | (1) `trending_keywords.source` 컬럼 idempotent 추가, (2) `account_preferences` CREATE TABLE, (3) 신규 CRUD: `add_external_trend`, `list_trends`, `get_preferences`, `upsert_preferences`. 기존 `add_trending_keyword`도 source 인자 받도록 확장 (default='manual'로 역호환) | +| `insta-lab/app/keyword_extractor.py` | + `extract_with_weights(weights, total_limit)` | +| `insta-lab/app/main.py` | + 4 endpoints: `POST /api/insta/trends/collect`, `GET /api/insta/trends`, `GET /api/insta/preferences`, `PUT /api/insta/preferences`. 기존 `GET /api/insta/keywords`에 `source` 쿼리 파라미터 추가 | +| `agent-office/app/agents/insta.py` | (1) `on_schedule()`이 preferences 호출 후 `insta_extract_with_weights`를 트리거하도록 변경 (2) `on_command("collect_trends", ...)` 액션 추가 | +| `agent-office/app/service_proxy.py` | + `insta_collect_trends`, `insta_list_trends`, `insta_get_preferences`, `insta_put_preferences`, `insta_extract_with_weights` | +| `agent-office/app/scheduler.py` | + `_run_insta_trends_collect` cron 09:00 | +| `web-ui/src/api.js` | + 4 helpers | +| `web-ui/src/pages/insta/InstaCards.jsx` | 탭 네비게이션 (Cards/Trends) + 3 새 패널 컴포넌트 | +| `web-ui/src/pages/insta/InstaCards.css` | 탭/패널/슬라이더 스타일 | + +### Files NOT to touch + +- Existing tests in insta-lab (test_db / test_keyword_extractor / test_main 등) — should remain green after migration. Update only if `add_trending_keyword` signature change breaks them; in that case adjust the call sites in tests, not the contracts. + +--- + +## Conventions + +- Repo root: `C:\Users\jaeoh\Desktop\workspace\web-backend` (web-backend) + `C:\Users\jaeoh\Desktop\workspace\web-ui` (web-ui). +- Commit on `main` (project flow). Each task = its own commit. +- TDD: failing test → implementation → passing test → commit. +- Windows-safe SQLite cleanup pattern in test fixtures: `gc.collect()` + remove `-wal`/`-shm` sidecars before `os.remove(path)`. +- Idempotent migrations: use `PRAGMA table_info()` to check column existence before `ALTER`. +- `pytrends` calls in tests MUST be mocked (no live network). + +--- + +## Task 0: Branch + dependency bump + +**Files:** +- Modify: `insta-lab/requirements.txt` + +- [ ] **Step 1: Confirm clean working tree** + +Run: `git status --short` +Expected: only pre-existing untracked files (`.superpowers/`, `stock/app/test_scraper.py`). + +- [ ] **Step 2: Create feature branch** + +```bash +git checkout -b feat/insta-trends +``` + +- [ ] **Step 3: Append `pytrends>=4.9` to requirements** + +Edit `insta-lab/requirements.txt`. Append at end: + +``` +pytrends>=4.9 +``` + +Resulting file: +``` +fastapi==0.115.6 +uvicorn[standard]==0.34.0 +requests==2.32.3 +httpx>=0.27 +anthropic==0.52.0 +jinja2>=3.1.4 +playwright==1.48.0 +pytest>=8.0 +pytest-asyncio>=0.24 +pytrends>=4.9 +``` + +- [ ] **Step 4: Install locally so subsequent tests can import** + +```bash +pip install "pytrends>=4.9" +``` + +- [ ] **Step 5: Commit** + +```bash +git add insta-lab/requirements.txt +git commit -m "chore(insta-lab): add pytrends>=4.9 dependency" +``` + +--- + +## Task 1: DB migration — `source` column + `account_preferences` table + CRUD + +**Files:** +- Modify: `insta-lab/app/db.py` +- Create: `insta-lab/tests/test_preferences_crud.py` +- Modify: `insta-lab/tests/test_db.py` (only if existing tests break) + +- [ ] **Step 1: Write the failing test `tests/test_preferences_crud.py`** + +```python +import os +import gc +import tempfile + +import pytest + +from app import db as db_module + + +@pytest.fixture +def tmp_db(monkeypatch): + fd, path = tempfile.mkstemp(suffix=".db") + os.close(fd) + monkeypatch.setattr(db_module, "DB_PATH", path) + db_module.init_db() + yield path + gc.collect() + for ext in ("", "-wal", "-shm"): + try: + os.remove(path + ext) + except OSError: + pass + + +def test_init_db_creates_account_preferences(tmp_db): + with db_module._conn() as conn: + rows = conn.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall() + names = {r[0] for r in rows} + assert "account_preferences" in names + + +def test_init_db_seeds_default_weights(tmp_db): + prefs = db_module.get_preferences() + cats = {p["category"]: p["weight"] for p in prefs} + assert cats["economy"] == pytest.approx(1.0) + assert cats["psychology"] == pytest.approx(1.0) + assert cats["celebrity"] == pytest.approx(1.0) + + +def test_upsert_preferences_replaces_weights(tmp_db): + db_module.upsert_preferences({"economy": 0.6, "psychology": 0.3, "celebrity": 0.1, "tech": 0.5}) + prefs = {p["category"]: p["weight"] for p in db_module.get_preferences()} + assert prefs["economy"] == pytest.approx(0.6) + assert prefs["tech"] == pytest.approx(0.5) + assert "celebrity" in prefs and prefs["celebrity"] == pytest.approx(0.1) + + +def test_trending_keywords_source_column_exists(tmp_db): + with db_module._conn() as conn: + cols = [r[1] for r in conn.execute("PRAGMA table_info(trending_keywords)").fetchall()] + assert "source" in cols + + +def test_add_trending_keyword_default_source(tmp_db): + kid = db_module.add_trending_keyword({ + "keyword": "K", "category": "economy", "score": 0.5, "articles_count": 3, + }) + with db_module._conn() as conn: + row = conn.execute("SELECT source FROM trending_keywords WHERE id=?", (kid,)).fetchone() + assert row[0] == "manual" + + +def test_add_external_trend_stores_source(tmp_db): + tid = db_module.add_external_trend({ + "keyword": "급등주", "category": "economy", "source": "naver_popular", "score": 0.9, + }) + rows = db_module.list_trends(source="naver_popular") + assert any(r["id"] == tid and r["keyword"] == "급등주" for r in rows) + + +def test_list_trends_filters_by_source_and_category(tmp_db): + db_module.add_external_trend({"keyword": "A", "category": "economy", "source": "naver_popular", "score": 1.0}) + db_module.add_external_trend({"keyword": "B", "category": "celebrity", "source": "google_trends", "score": 1.0}) + only_naver = db_module.list_trends(source="naver_popular") + assert {r["keyword"] for r in only_naver} == {"A"} + only_celeb_google = db_module.list_trends(source="google_trends", category="celebrity") + assert {r["keyword"] for r in only_celeb_google} == {"B"} +``` + +- [ ] **Step 2: Run test, expect failure** + +Run: `cd insta-lab && pytest tests/test_preferences_crud.py -v` +Expected: failures on `account_preferences` not existing, `get_preferences/upsert_preferences/add_external_trend/list_trends` not defined. + +- [ ] **Step 3: Update `insta-lab/app/db.py`** + +Append new helpers and amend `init_db()`: + +**3a. In `init_db()`**, append before the function ends (after the existing `prompt_templates` CREATE): + +```python + # source column for trending_keywords (idempotent ALTER) + cols = [r[1] for r in conn.execute("PRAGMA table_info(trending_keywords)").fetchall()] + if "source" not in cols: + conn.execute("ALTER TABLE trending_keywords ADD COLUMN source TEXT NOT NULL DEFAULT 'manual'") + conn.execute("CREATE INDEX IF NOT EXISTS idx_tk_source ON trending_keywords(source, suggested_at DESC)") + + # account_preferences — 카테고리 가중치 + conn.execute(""" + CREATE TABLE IF NOT EXISTS account_preferences ( + category TEXT PRIMARY KEY, + weight REAL NOT NULL DEFAULT 1.0, + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) + ) + """) + # seed defaults if table empty + existing = conn.execute("SELECT COUNT(*) FROM account_preferences").fetchone()[0] + if existing == 0: + for cat in ("economy", "psychology", "celebrity"): + conn.execute( + "INSERT INTO account_preferences(category, weight) VALUES(?,?)", + (cat, 1.0), + ) +``` + +**3b. Amend `add_trending_keyword`** to accept optional `source`: + +```python +def add_trending_keyword(row: Dict[str, Any]) -> int: + with _conn() as conn: + cur = conn.execute( + "INSERT INTO trending_keywords(keyword, category, score, articles_count, source) VALUES(?,?,?,?,?)", + ( + row["keyword"], row["category"], + float(row.get("score", 0.0)), int(row.get("articles_count", 0)), + row.get("source", "manual"), + ), + ) + return cur.lastrowid +``` + +**3c. Add new helpers** (append to db.py): + +```python +def add_external_trend(row: Dict[str, Any]) -> int: + """`source` 필수 — naver_popular | google_trends. trending_keywords에 인서트.""" + if "source" not in row: + raise ValueError("add_external_trend requires 'source' field") + return add_trending_keyword(row) + + +def list_trends(source: Optional[str] = None, category: Optional[str] = None, + days: int = 1) -> List[Dict[str, Any]]: + sql = "SELECT * FROM trending_keywords WHERE suggested_at >= datetime('now', ?)" + params: List[Any] = [f"-{int(days)} days"] + if source and source != "all": + sql += " AND source=?" + params.append(source) + if category: + sql += " AND category=?" + params.append(category) + sql += " ORDER BY suggested_at DESC, score DESC" + with _conn() as conn: + rows = conn.execute(sql, params).fetchall() + return [dict(r) for r in rows] + + +def get_preferences() -> List[Dict[str, Any]]: + with _conn() as conn: + rows = conn.execute( + "SELECT category, weight, updated_at FROM account_preferences ORDER BY category ASC" + ).fetchall() + return [dict(r) for r in rows] + + +def upsert_preferences(weights: Dict[str, float]) -> None: + """전체 upsert. 기존에 있던 카테고리는 weight 갱신, 신규는 INSERT. + 명시되지 않은 기존 카테고리는 그대로 둔다 (삭제 X). 삭제 필요 시 별도 API로.""" + with _conn() as conn: + for cat, w in weights.items(): + conn.execute(""" + INSERT INTO account_preferences(category, weight) + VALUES(?,?) + ON CONFLICT(category) DO UPDATE SET + weight=excluded.weight, + updated_at=strftime('%Y-%m-%dT%H:%M:%fZ','now') + """, (cat, float(w))) +``` + +- [ ] **Step 4: Run preferences test, expect pass** + +Run: `cd insta-lab && pytest tests/test_preferences_crud.py -v` +Expected: 7 PASS. + +- [ ] **Step 5: Run existing test suite to confirm no regression** + +Run: `cd insta-lab && pytest -v` +Expected: All prior tests still pass + new 7. If any existing test breaks (e.g., on `add_trending_keyword` signature), fix the test call site (it already uses positional dict so should be fine). + +- [ ] **Step 6: Commit** + +```bash +git add insta-lab/app/db.py insta-lab/tests/test_preferences_crud.py +git commit -m "feat(insta-lab): db migration — trending_keywords.source + account_preferences + CRUD" +``` + +--- + +## Task 2: trend_collector — NAVER popular fetcher + +**Files:** +- Create: `insta-lab/app/trend_collector.py` (initial — NAVER part only) +- Create: `insta-lab/tests/test_trend_collector.py` (NAVER part only; Google Trends added in Task 3) + +- [ ] **Step 1: Write the failing test (NAVER section)** `tests/test_trend_collector.py` + +```python +import os +import gc +import tempfile +from unittest.mock import patch, MagicMock + +import pytest + +from app import db as db_module +from app import trend_collector + + +@pytest.fixture +def tmp_db(monkeypatch): + fd, path = tempfile.mkstemp(suffix=".db") + os.close(fd) + monkeypatch.setattr(db_module, "DB_PATH", path) + db_module.init_db() + yield path + gc.collect() + for ext in ("", "-wal", "-shm"): + try: + os.remove(path + ext) + except OSError: + pass + + +NAVER_RESPONSE = { + "items": [ + {"title": "기준금리 인상", "link": "https://n.news.naver.com/a/1", "description": "한국은행 발표"}, + {"title": "환율 급등", "link": "https://n.news.naver.com/a/2", "description": "달러 강세"}, + {"title": "기준금리 추가 인상", "link": "https://n.news.naver.com/a/3", "description": "추가 발표"}, + ], +} + + +def test_fetch_naver_popular_extracts_top_terms(tmp_db, monkeypatch): + fake_resp = MagicMock() + fake_resp.json.return_value = NAVER_RESPONSE + fake_resp.raise_for_status.return_value = None + + # NAVER search는 카테고리 시드 키워드 각각에 대해 호출됨. 한 번만 mock해도 모두 같은 응답 + with patch.object(trend_collector.requests, "get", return_value=fake_resp): + trends = trend_collector.fetch_naver_popular("economy", per_seed=10, top_n=5) + + keywords = [t["keyword"] for t in trends] + assert "기준금리" in keywords # 빈도 2회로 상위 + for t in trends: + assert t["category"] == "economy" + assert t["source"] == "naver_popular" + assert 0.0 <= t["score"] <= 1.0 + + +def test_collect_naver_writes_to_db(tmp_db, monkeypatch): + fake_resp = MagicMock() + fake_resp.json.return_value = NAVER_RESPONSE + fake_resp.raise_for_status.return_value = None + with patch.object(trend_collector.requests, "get", return_value=fake_resp): + n = trend_collector.collect_naver_popular_for(["economy"]) + assert n > 0 + rows = db_module.list_trends(source="naver_popular") + assert len(rows) > 0 + assert all(r["source"] == "naver_popular" for r in rows) +``` + +- [ ] **Step 2: Run test, expect failure** + +Run: `cd insta-lab && pytest tests/test_trend_collector.py -v` +Expected: ImportError on `app.trend_collector`. + +- [ ] **Step 3: Implement `insta-lab/app/trend_collector.py` (NAVER section)** + +```python +"""외부 트렌드 수집 — NAVER 인기 + Google Trends + LLM 카테고리 분류. + +Phase 1 (this file revision): NAVER 인기만. Google Trends는 Task 3에서 추가. +""" + +import logging +import time +from typing import Any, Dict, List, Optional + +import requests + +from .config import ( + NAVER_CLIENT_ID, NAVER_CLIENT_SECRET, DEFAULT_CATEGORY_SEEDS, +) +from . import db +from .news_collector import _clean +from .keyword_extractor import _count_nouns, _top_candidates + +logger = logging.getLogger(__name__) + +NEWS_URL = "https://openapi.naver.com/v1/search/news.json" +_NAVER_HEADERS = { + "X-Naver-Client-Id": NAVER_CLIENT_ID, + "X-Naver-Client-Secret": NAVER_CLIENT_SECRET, +} + + +def _seeds_for(category: str) -> List[str]: + pt = db.get_prompt_template("category_seeds") + if pt and pt.get("template"): + import json + try: + data = json.loads(pt["template"]) + if category in data: + return list(data[category]) + except Exception: + pass + return list(DEFAULT_CATEGORY_SEEDS.get(category, [])) + + +def fetch_naver_popular(category: str, per_seed: int = 30, top_n: int = 10) -> List[Dict[str, Any]]: + """카테고리 시드 키워드들로 NAVER news.json `sort=sim`(정확도=인기 시그널) 호출, + 응답 기사 묶음에서 빈도어 추출 후 상위 N개 반환. + + Returns: list of {keyword, category, source='naver_popular', score (0~1)} + """ + seeds = _seeds_for(category) + if not seeds: + return [] + blob_parts: List[str] = [] + for seed in seeds: + try: + resp = requests.get( + NEWS_URL, + headers=_NAVER_HEADERS, + params={"query": seed, "display": per_seed, "sort": "sim"}, + timeout=10, + ) + resp.raise_for_status() + for item in resp.json().get("items", []): + blob_parts.append(_clean(item.get("title", ""))) + blob_parts.append(_clean(item.get("description", ""))) + except Exception as e: + logger.warning("fetch_naver_popular seed=%s err=%s", seed, e) + continue + text = "\n".join(blob_parts) + counts = _count_nouns(text) + candidates = _top_candidates(counts, n=top_n) + if not candidates: + return [] + max_count = candidates[0][1] or 1 + return [ + { + "keyword": k, + "category": category, + "source": "naver_popular", + "score": round(min(1.0, c / max_count), 4), + "articles_count": c, + } + for k, c in candidates + ] + + +def collect_naver_popular_for(categories: List[str]) -> int: + """각 카테고리에 대해 fetch_naver_popular 호출 후 DB 저장. 저장된 row 수 반환.""" + total = 0 + for cat in categories: + trends = fetch_naver_popular(cat) + for t in trends: + db.add_external_trend(t) + total += 1 + return total +``` + +- [ ] **Step 4: Run test, expect pass** + +Run: `cd insta-lab && pytest tests/test_trend_collector.py -v` +Expected: 2 PASS. + +- [ ] **Step 5: Commit** + +```bash +git add insta-lab/app/trend_collector.py insta-lab/tests/test_trend_collector.py +git commit -m "feat(insta-lab): trend_collector with NAVER popular fetcher" +``` + +--- + +## Task 3: trend_collector — Google Trends + LLM category classification + +**Files:** +- Modify: `insta-lab/app/trend_collector.py` (append Google Trends part + cache) +- Modify: `insta-lab/tests/test_trend_collector.py` (append Google Trends tests) + +- [ ] **Step 1: Append failing tests to `tests/test_trend_collector.py`** + +Add at the end of the file: + +```python +def test_classify_keyword_with_cache(monkeypatch): + """LLM이 호출되면 결과를 캐시. 두 번째 같은 키워드는 cache hit.""" + calls = {"n": 0} + + def fake_claude(keyword: str) -> str: + calls["n"] += 1 + return "economy" + + monkeypatch.setattr(trend_collector, "_llm_classify_one", fake_claude) + trend_collector._category_cache.clear() + + c1 = trend_collector.classify_keyword("기준금리") + c2 = trend_collector.classify_keyword("기준금리") + assert c1 == c2 == "economy" + assert calls["n"] == 1 # cache hit second time + + +def test_fetch_google_trends_parses_and_classifies(tmp_db, monkeypatch): + # pytrends client mock + class FakePyTrends: + def __init__(self, *_a, **_kw): + pass + + def trending_searches(self, pn="south_korea"): + import pandas as pd + return pd.DataFrame({"0": ["기준금리", "BTS 컴백", "스트레스 관리"]}) + + monkeypatch.setattr(trend_collector, "TrendReq", FakePyTrends) + monkeypatch.setattr(trend_collector, "classify_keyword", + lambda kw: {"기준금리": "economy", "BTS 컴백": "celebrity", + "스트레스 관리": "psychology"}.get(kw, "uncategorized")) + + trends = trend_collector.fetch_google_trends() + by_kw = {t["keyword"]: t for t in trends} + assert by_kw["기준금리"]["category"] == "economy" + assert by_kw["BTS 컴백"]["category"] == "celebrity" + assert by_kw["스트레스 관리"]["category"] == "psychology" + assert all(t["source"] == "google_trends" for t in trends) + + +def test_collect_all_invokes_both_sources(tmp_db, monkeypatch): + monkeypatch.setattr(trend_collector, "collect_naver_popular_for", + lambda cats: 5) + monkeypatch.setattr(trend_collector, "collect_google_trends", + lambda: 3) + out = trend_collector.collect_all(["economy"]) + assert out == {"naver_popular": 5, "google_trends": 3} + + +def test_fetch_google_trends_graceful_on_pytrends_failure(monkeypatch): + class FakePyTrends: + def __init__(self, *_a, **_kw): + pass + + def trending_searches(self, pn="south_korea"): + raise RuntimeError("rate limited") + + monkeypatch.setattr(trend_collector, "TrendReq", FakePyTrends) + out = trend_collector.fetch_google_trends() + assert out == [] +``` + +- [ ] **Step 2: Run tests, expect failures** (new four fail) + +Run: `cd insta-lab && pytest tests/test_trend_collector.py -v` +Expected: 2 old PASS + 4 new FAIL on undefined symbols. + +- [ ] **Step 3: Extend `insta-lab/app/trend_collector.py`** + +At the top of the file, after the existing imports, ADD: + +```python +import json +import re +from anthropic import Anthropic +from pytrends.request import TrendReq + +from .config import ANTHROPIC_API_KEY, ANTHROPIC_MODEL_HAIKU + +_CACHE_TTL_SEC = 24 * 3600 +_category_cache: Dict[str, tuple] = {} # keyword -> (category, expires_ts) + + +def _llm_classify_one(keyword: str) -> str: + """Claude Haiku 1회 호출로 단일 키워드 분류. 카테고리는 prompt_templates의 + category_seeds 키 집합 + 'uncategorized'.""" + if not ANTHROPIC_API_KEY: + return "uncategorized" + seeds_template = db.get_prompt_template("category_seeds") + if seeds_template and seeds_template.get("template"): + try: + allowed = sorted(json.loads(seeds_template["template"]).keys()) + except Exception: + allowed = sorted(DEFAULT_CATEGORY_SEEDS.keys()) + else: + allowed = sorted(DEFAULT_CATEGORY_SEEDS.keys()) + allowed.append("uncategorized") + + client = Anthropic(api_key=ANTHROPIC_API_KEY) + msg = client.messages.create( + model=ANTHROPIC_MODEL_HAIKU, + max_tokens=20, + messages=[{ + "role": "user", + "content": ( + f"다음 한국어 트렌딩 키워드를 카테고리 중 하나로 분류해라. " + f"카테고리: {allowed}. 키워드: '{keyword}'. " + f"카테고리명 한 단어만 출력. 다른 텍스트 금지." + ), + }], + ) + raw = msg.content[0].text.strip().lower() + for cat in allowed: + if cat.lower() in raw: + return cat + return "uncategorized" + + +def classify_keyword(keyword: str) -> str: + now = time.time() + cached = _category_cache.get(keyword) + if cached and cached[1] > now: + return cached[0] + cat = _llm_classify_one(keyword) + _category_cache[keyword] = (cat, now + _CACHE_TTL_SEC) + return cat + + +def fetch_google_trends() -> List[Dict[str, Any]]: + """pytrends로 한국 daily trending searches. 실패 시 빈 리스트 graceful degrade.""" + try: + pytrends = TrendReq(hl="ko-KR", tz=540) + df = pytrends.trending_searches(pn="south_korea") + except Exception as e: + logger.warning("Google Trends fetch failed: %s", e) + return [] + + items: List[Dict[str, Any]] = [] + # df는 단일 컬럼 DataFrame이거나 "0"이 컬럼명. iter row 단순. + for idx, row in df.iterrows(): + kw = str(row.iloc[0]).strip() + if not kw: + continue + cat = classify_keyword(kw) + # score는 순위 기반 정규화 (상위가 1.0에 가까움) + rank_score = round(max(0.0, 1.0 - (idx / max(1, len(df)))), 4) + items.append({ + "keyword": kw, + "category": cat, + "source": "google_trends", + "score": rank_score, + "articles_count": 0, + }) + return items + + +def collect_google_trends() -> int: + items = fetch_google_trends() + for it in items: + db.add_external_trend(it) + return len(items) + + +def collect_all(categories: List[str]) -> Dict[str, int]: + """전체 트렌드 수집 (NAVER 인기 + Google Trends 동시). 각 source별 저장 row 수 반환.""" + naver_n = collect_naver_popular_for(categories) + google_n = collect_google_trends() + return {"naver_popular": naver_n, "google_trends": google_n} +``` + +- [ ] **Step 4: Run all trend_collector tests, expect pass** + +Run: `cd insta-lab && pytest tests/test_trend_collector.py -v` +Expected: 6 PASS. + +- [ ] **Step 5: Commit** + +```bash +git add insta-lab/app/trend_collector.py insta-lab/tests/test_trend_collector.py +git commit -m "feat(insta-lab): trend_collector adds Google Trends + LLM category classification" +``` + +--- + +## Task 4: keyword_extractor — `extract_with_weights` + +**Files:** +- Modify: `insta-lab/app/keyword_extractor.py` +- Create: `insta-lab/tests/test_extract_with_weights.py` + +- [ ] **Step 1: Write the failing test** + +```python +import os +import gc +import tempfile +from unittest.mock import patch + +import pytest + +from app import db as db_module +from app import keyword_extractor + + +@pytest.fixture +def tmp_db(monkeypatch): + fd, path = tempfile.mkstemp(suffix=".db") + os.close(fd) + monkeypatch.setattr(db_module, "DB_PATH", path) + db_module.init_db() + yield path + gc.collect() + for ext in ("", "-wal", "-shm"): + try: + os.remove(path + ext) + except OSError: + pass + + +def test_extract_with_weights_proportional(tmp_db, monkeypatch): + calls = [] + + def fake_extract(category, limit): + calls.append((category, limit)) + return [{"id": i, "keyword": f"{category}{i}", "category": category, "score": 0.5} + for i in range(limit)] + + monkeypatch.setattr(keyword_extractor, "extract_for_category", fake_extract) + out = keyword_extractor.extract_with_weights( + {"economy": 0.6, "psychology": 0.3, "celebrity": 0.1}, total_limit=10, + ) + # 6:3:1 비율 → round(10*0.6)=6, round(10*0.3)=3, round(10*0.1)=1 + by_cat = {c: l for c, l in calls} + assert by_cat == {"economy": 6, "psychology": 3, "celebrity": 1} + assert len(out) == 10 + + +def test_extract_with_weights_skips_zero(tmp_db, monkeypatch): + calls = [] + + def fake_extract(category, limit): + calls.append((category, limit)) + return [] + + monkeypatch.setattr(keyword_extractor, "extract_for_category", fake_extract) + keyword_extractor.extract_with_weights( + {"economy": 1.0, "celebrity": 0.0}, total_limit=10, + ) + assert ("celebrity", ...) not in [(c, ...) for c, _ in calls] + assert any(c == "economy" for c, _ in calls) + + +def test_extract_with_weights_fallback_to_equal(tmp_db, monkeypatch): + calls = [] + + def fake_extract(category, limit): + calls.append((category, limit)) + return [] + + monkeypatch.setattr(keyword_extractor, "extract_for_category", fake_extract) + keyword_extractor.extract_with_weights({}, total_limit=9) + # DEFAULT_CATEGORY_SEEDS 기본 3개 균등 → 각 3 + by_cat = {c: l for c, l in calls} + assert set(by_cat.keys()) == {"economy", "psychology", "celebrity"} + assert all(l == 3 for l in by_cat.values()) +``` + +- [ ] **Step 2: Run test, expect failure** + +Run: `cd insta-lab && pytest tests/test_extract_with_weights.py -v` +Expected: AttributeError on `extract_with_weights`. + +- [ ] **Step 3: Implement** — append to `insta-lab/app/keyword_extractor.py`: + +```python +def extract_with_weights(weights: Dict[str, float], total_limit: int) -> List[Dict[str, Any]]: + """카테고리 가중치 비율대로 키워드를 분배 추출.""" + from .config import DEFAULT_CATEGORY_SEEDS + if not weights or sum(weights.values()) == 0: + cats = list(DEFAULT_CATEGORY_SEEDS.keys()) + weights = {c: 1.0 for c in cats} + + total_weight = sum(weights.values()) + out: List[Dict[str, Any]] = [] + for category, w in weights.items(): + if w <= 0: + continue + per_cat = round(total_limit * w / total_weight) + if per_cat <= 0: + continue + out.extend(extract_for_category(category, limit=per_cat)) + return out +``` + +- [ ] **Step 4: Run test, expect pass** + +Run: `cd insta-lab && pytest tests/test_extract_with_weights.py -v` +Expected: 3 PASS. + +- [ ] **Step 5: Commit** + +```bash +git add insta-lab/app/keyword_extractor.py insta-lab/tests/test_extract_with_weights.py +git commit -m "feat(insta-lab): keyword_extractor.extract_with_weights for category proportions" +``` + +--- + +## Task 5: main.py — 4 new endpoints + +**Files:** +- Modify: `insta-lab/app/main.py` +- Create: `insta-lab/tests/test_main_trends.py` + +- [ ] **Step 1: Write the failing test** + +```python +import os +import gc +import tempfile + +import pytest +from fastapi.testclient import TestClient + +from app import db as db_module + + +@pytest.fixture +def client(monkeypatch): + fd, path = tempfile.mkstemp(suffix=".db") + os.close(fd) + monkeypatch.setattr(db_module, "DB_PATH", path) + db_module.init_db() + from app import main + monkeypatch.setattr(main, "DB_PATH", path) + with TestClient(main.app) as c: + yield c + gc.collect() + for ext in ("", "-wal", "-shm"): + try: + os.remove(path + ext) + except OSError: + pass + + +def test_get_preferences_returns_defaults(client): + resp = client.get("/api/insta/preferences") + assert resp.status_code == 200 + cats = {p["category"]: p["weight"] for p in resp.json()["categories"]} + assert cats == {"economy": 1.0, "psychology": 1.0, "celebrity": 1.0} + + +def test_put_preferences_upsert(client): + resp = client.put("/api/insta/preferences", + json={"categories": {"economy": 0.7, "psychology": 0.2, "tech": 0.5}}) + assert resp.status_code == 200 + cats = {p["category"]: p["weight"] for p in resp.json()["categories"]} + assert cats["economy"] == 0.7 + assert cats["tech"] == 0.5 + + +def test_list_trends_filter(client): + db_module.add_external_trend({"keyword": "A", "category": "economy", + "source": "naver_popular", "score": 1.0}) + db_module.add_external_trend({"keyword": "B", "category": "celebrity", + "source": "google_trends", "score": 0.8}) + resp = client.get("/api/insta/trends?source=naver_popular") + items = resp.json()["items"] + assert {it["keyword"] for it in items} == {"A"} + + +def test_collect_trends_kicks_background(client, monkeypatch): + from app import main, trend_collector + + captured = {"called": False} + + def fake_collect_all(cats): + captured["called"] = True + return {"naver_popular": 3, "google_trends": 2} + + monkeypatch.setattr(trend_collector, "collect_all", fake_collect_all) + resp = client.post("/api/insta/trends/collect", json={}) + assert resp.status_code == 200 + task_id = resp.json()["task_id"] + # poll + for _ in range(20): + st = client.get(f"/api/insta/tasks/{task_id}").json() + if st["status"] in ("succeeded", "failed"): + break + assert st["status"] == "succeeded" + assert captured["called"] is True + + +def test_list_keywords_filters_by_source(client): + db_module.add_trending_keyword({"keyword": "M", "category": "economy", + "score": 0.4, "articles_count": 1, "source": "manual"}) + db_module.add_external_trend({"keyword": "N", "category": "economy", + "source": "naver_popular", "score": 0.9}) + resp = client.get("/api/insta/keywords?source=manual") + items = resp.json()["items"] + assert {it["keyword"] for it in items} == {"M"} +``` + +- [ ] **Step 2: Run test, expect failure** + +Run: `cd insta-lab && pytest tests/test_main_trends.py -v` +Expected: 404s and missing endpoints. + +- [ ] **Step 3: Edit `insta-lab/app/main.py`** — add imports at top (around the existing import section): + +```python +from . import db, news_collector, keyword_extractor, card_writer, card_renderer, trend_collector +``` + +(Add `, trend_collector` to the existing line.) + +Then append (at end of file or near existing endpoints, before any `if __name__`): + +```python +# ── Trends ─────────────────────────────────────────────────────── +class TrendsCollectRequest(BaseModel): + categories: Optional[list[str]] = None + + +async def _bg_collect_trends(task_id: str, categories: list[str]): + try: + db.update_task(task_id, "processing", 10, "외부 트렌드 수집 중") + result = trend_collector.collect_all(categories) + msg = f"naver:{result['naver_popular']}, google:{result['google_trends']}" + db.update_task(task_id, "succeeded", 100, msg, result_id=sum(result.values())) + except Exception as e: + logger.exception("trends collect failed") + db.update_task(task_id, "failed", 0, "", error=str(e)) + + +@app.post("/api/insta/trends/collect") +def collect_trends(req: TrendsCollectRequest, bg: BackgroundTasks): + cats = req.categories or list(DEFAULT_CATEGORY_SEEDS.keys()) + tid = db.create_task("trends_collect", {"categories": cats}) + bg.add_task(_bg_collect_trends, tid, cats) + return {"task_id": tid, "categories": cats} + + +@app.get("/api/insta/trends") +def list_trends_endpoint( + source: Optional[str] = None, + category: Optional[str] = None, + days: int = Query(1, ge=1, le=90), +): + return {"items": db.list_trends(source=source, category=category, days=days)} + + +# ── Preferences ────────────────────────────────────────────────── +class PreferencesBody(BaseModel): + categories: dict[str, float] + + +@app.get("/api/insta/preferences") +def get_preferences_endpoint(): + return {"categories": db.get_preferences()} + + +@app.put("/api/insta/preferences") +def put_preferences_endpoint(body: PreferencesBody): + db.upsert_preferences(body.categories) + return {"categories": db.get_preferences()} +``` + +Then modify the existing `list_keywords` endpoint to add a `source` filter. Find: + +```python +@app.get("/api/insta/keywords") +def list_keywords(category: Optional[str] = None, used: Optional[bool] = None): + return {"items": db.list_trending_keywords(category=category, used=used)} +``` + +Replace with: + +```python +@app.get("/api/insta/keywords") +def list_keywords( + category: Optional[str] = None, + used: Optional[bool] = None, + source: Optional[str] = None, +): + if source: + # source 필터는 list_trends 경로 사용 (동일 테이블, source 컬럼 활용) + return {"items": db.list_trends(source=source, category=category, days=30)} + return {"items": db.list_trending_keywords(category=category, used=used)} +``` + +- [ ] **Step 4: Run test, expect pass** + +Run: `cd insta-lab && pytest tests/test_main_trends.py -v` +Expected: 5 PASS. + +- [ ] **Step 5: Full insta-lab suite** + +Run: `cd insta-lab && pytest -v` +Expected: All prior tests still pass + new tests. Total ~33 tests, 0 failures. + +- [ ] **Step 6: Commit** + +```bash +git add insta-lab/app/main.py insta-lab/tests/test_main_trends.py +git commit -m "feat(insta-lab): main.py — trends + preferences endpoints" +``` + +--- + +## Task 6: agent-office InstaAgent — weighted extract + new collect_trends action + +**Files:** +- Modify: `agent-office/app/agents/insta.py` +- Modify: `agent-office/app/service_proxy.py` +- Create: `agent-office/tests/test_insta_agent_trends.py` + +- [ ] **Step 1: Add service_proxy helpers** + +In `agent-office/app/service_proxy.py`, in the `# --- insta-lab ---` section, append: + +```python +async def insta_collect_trends(categories: Optional[list] = None) -> Dict[str, Any]: + payload = {"categories": categories} if categories else {} + resp = await _client.post(f"{INSTA_LAB_URL}/api/insta/trends/collect", json=payload) + resp.raise_for_status() + return resp.json() + + +async def insta_list_trends(source: Optional[str] = None, + category: Optional[str] = None, + days: int = 1) -> List[Dict[str, Any]]: + params: Dict[str, Any] = {"days": days} + if source: + params["source"] = source + if category: + params["category"] = category + resp = await _client.get(f"{INSTA_LAB_URL}/api/insta/trends", params=params) + resp.raise_for_status() + return resp.json().get("items", []) + + +async def insta_get_preferences() -> Dict[str, float]: + resp = await _client.get(f"{INSTA_LAB_URL}/api/insta/preferences") + resp.raise_for_status() + return {p["category"]: p["weight"] for p in resp.json().get("categories", [])} + + +async def insta_put_preferences(weights: Dict[str, float]) -> Dict[str, Any]: + resp = await _client.put( + f"{INSTA_LAB_URL}/api/insta/preferences", + json={"categories": weights}, + ) + resp.raise_for_status() + return resp.json() +``` + +- [ ] **Step 2: Write failing test `agent-office/tests/test_insta_agent_trends.py`** + +```python +from unittest.mock import AsyncMock + +import pytest + +from app.agents.insta import InstaAgent + + +@pytest.mark.asyncio +async def test_on_command_collect_trends_dispatches(monkeypatch): + agent = InstaAgent() + fake_collect = AsyncMock(return_value={"task_id": "tcollect"}) + fake_status = AsyncMock(return_value={"status": "succeeded", "result_id": 8, + "message": "naver:5, google:3"}) + + monkeypatch.setattr("app.agents.insta.service_proxy.insta_collect_trends", fake_collect) + monkeypatch.setattr("app.agents.insta.service_proxy.insta_task_status", fake_status) + monkeypatch.setattr("app.agents.insta.messaging.send_raw", AsyncMock(return_value={"ok": True})) + + result = await agent.on_command("collect_trends", {}) + assert result["ok"] is True + fake_collect.assert_awaited() + + +@pytest.mark.asyncio +async def test_on_schedule_applies_preferences(monkeypatch): + """on_schedule이 preferences를 가져와 가중치 정보를 활용하는지 확인.""" + agent = InstaAgent() + + fake_collect = AsyncMock(return_value={"task_id": "t1"}) + fake_extract = AsyncMock(return_value={"task_id": "t2"}) + fake_status = AsyncMock(side_effect=[ + {"status": "succeeded", "result_id": 0}, + {"status": "succeeded", "result_id": 0}, + ]) + fake_keywords = AsyncMock(return_value=[ + {"id": 1, "keyword": "K", "category": "economy", "score": 0.9}, + ]) + fake_prefs = AsyncMock(return_value={"economy": 0.6, "psychology": 0.4}) + + monkeypatch.setattr("app.agents.insta.service_proxy.insta_collect", fake_collect) + monkeypatch.setattr("app.agents.insta.service_proxy.insta_extract", fake_extract) + monkeypatch.setattr("app.agents.insta.service_proxy.insta_task_status", fake_status) + monkeypatch.setattr("app.agents.insta.service_proxy.insta_list_keywords", fake_keywords) + monkeypatch.setattr("app.agents.insta.service_proxy.insta_get_preferences", fake_prefs) + monkeypatch.setattr("app.agents.insta.messaging.send_raw", AsyncMock(return_value={"ok": True})) + + # 상태 idle로 강제 + agent.state = "idle" + await agent.on_schedule() + + fake_prefs.assert_awaited() +``` + +- [ ] **Step 3: Run test, expect failure** + +Run: `cd agent-office && pytest tests/test_insta_agent_trends.py -v` +Expected: AttributeError on `insta_get_preferences` or test setup error. + +- [ ] **Step 4: Modify `agent-office/app/agents/insta.py`** + +In `on_command`, add a new branch (append to the if-elif chain before the final `return`): + +```python + if command == "collect_trends": + await messaging.send_raw("🌐 외부 트렌드 수집 시작") + created = await service_proxy.insta_collect_trends() + st = await self._wait_task(created["task_id"], step="trends_collect", timeout_sec=300) + await messaging.send_raw(f"✅ 트렌드 수집 완료: {st.get('message', '')}") + return {"ok": True, "result": st} +``` + +In `on_schedule`, BEFORE the existing `_run_collect_and_extract` call, add preference loading. Find the try block: + +```python + try: + await self._run_collect_and_extract() + kws = await service_proxy.insta_list_keywords(used=False) + ... +``` + +Replace with: + +```python + try: + prefs = await service_proxy.insta_get_preferences() + add_log(self.agent_id, f"insta preferences: {prefs}", "info", task_id) + await self._run_collect_and_extract() + kws = await service_proxy.insta_list_keywords(used=False) + ... +``` + +(Note: actual weight application happens in insta-lab's `extract_with_weights`. For now agent-office just calls extract — the extract endpoint will be updated to read preferences in Task 7. We log prefs for visibility.) + +- [ ] **Step 5: Run test, expect pass** + +Run: `cd agent-office && pytest tests/test_insta_agent_trends.py -v` +Expected: 2 PASS. + +- [ ] **Step 6: Run full agent-office suite** + +Run: `cd agent-office && pytest -v` +Expected: All prior tests + new 2 pass. (1 pre-existing `test_init_and_seed` failure unrelated.) + +- [ ] **Step 7: Commit** + +```bash +git add agent-office/app/agents/insta.py agent-office/app/service_proxy.py agent-office/tests/test_insta_agent_trends.py +git commit -m "feat(agent-office): InstaAgent collect_trends action + preferences-aware on_schedule" +``` + +--- + +## Task 7: insta-lab keywords/extract uses preferences + agent-office scheduler 09:00 cron + +**Files:** +- Modify: `insta-lab/app/main.py` (extract endpoint reads preferences) +- Modify: `agent-office/app/scheduler.py` + +- [ ] **Step 1: Modify `insta-lab/app/main.py` — `_bg_extract` reads preferences** + +Find: + +```python +async def _bg_extract(task_id: str, categories: list[str]): + try: + db.update_task(task_id, "processing", 10, "추출 중") + for cat in categories: + keyword_extractor.extract_for_category(cat, limit=KEYWORDS_PER_CATEGORY) + db.update_task(task_id, "succeeded", 100, "완료", result_id=0) + except Exception as e: + logger.exception("extract failed") + db.update_task(task_id, "failed", 0, "", error=str(e)) +``` + +Replace with: + +```python +async def _bg_extract(task_id: str, categories: Optional[list[str]] = None): + try: + db.update_task(task_id, "processing", 10, "추출 중") + prefs_rows = db.get_preferences() + weights = {p["category"]: p["weight"] for p in prefs_rows} + if categories: + # 사용자가 카테고리 명시한 경우만 그 서브셋으로 균등 가중치 (override) + weights = {c: 1.0 for c in categories} + total = KEYWORDS_PER_CATEGORY * max(1, len([w for w in weights.values() if w > 0])) + keyword_extractor.extract_with_weights(weights, total_limit=total) + db.update_task(task_id, "succeeded", 100, "완료", result_id=0) + except Exception as e: + logger.exception("extract failed") + db.update_task(task_id, "failed", 0, "", error=str(e)) +``` + +- [ ] **Step 2: Run the existing `test_main.py` to confirm no regression** + +Run: `cd insta-lab && pytest tests/test_main.py -v` +Expected: 5 PASS (the existing `test_create_slate_kicks_background_task` should still pass since it uses fake_render/fake_write; the extract is called only from agent-office, not here). + +- [ ] **Step 3: Add scheduler cron for 09:00 trends collect** + +In `agent-office/app/scheduler.py`, add a function near `_run_insta_schedule`: + +```python +async def _run_insta_trends_collect(): + agent = AGENT_REGISTRY.get("insta") + if agent: + await agent.on_command("collect_trends", {}) +``` + +In `init_scheduler`, add (after the existing `insta_pipeline` cron line): + +```python + scheduler.add_job(_run_insta_trends_collect, "cron", hour=9, minute=0, id="insta_trends_collect") +``` + +- [ ] **Step 4: Sanity check imports + scheduler load** + +Run: +```bash +cd agent-office && python -c "from app.scheduler import init_scheduler; from app.agents import init_agents, AGENT_REGISTRY; init_agents(); print(list(AGENT_REGISTRY.keys())); print('scheduler imports OK')" +``` +Expected: List includes 'insta' and prints `scheduler imports OK`. + +- [ ] **Step 5: Commit** + +```bash +git add insta-lab/app/main.py agent-office/app/scheduler.py +git commit -m "feat(insta): extract uses preferences + 09:00 trends_collect cron" +``` + +--- + +## Task 8: web-ui — api.js helpers + +**Files:** +- Modify: `web-ui/src/api.js` + +> Work in the **web-ui** repo (separate from web-backend). All commits on `main`. + +- [ ] **Step 1: Open web-ui terminal and confirm branch** + +```bash +cd /c/Users/jaeoh/Desktop/workspace/web-ui +git status --short +git branch --show-current +``` +Expected: clean working tree, `main`. + +- [ ] **Step 2: Append helpers in `src/api.js`** — after the existing `// ── insta-lab ──` block, add: + +```js +// ── insta-lab trends ── +export function getInstaTrends({ source, category, days = 1 } = {}) { + const q = new URLSearchParams(); + if (source) q.set('source', source); + if (category) q.set('category', category); + q.set('days', String(days)); + return apiGet(`/api/insta/trends?${q.toString()}`); +} + +export function instaCollectTrends(categories) { + return apiPost('/api/insta/trends/collect', categories ? { categories } : {}); +} + +export function getInstaPreferences() { + return apiGet('/api/insta/preferences'); +} + +export function putInstaPreferences(categories) { + return apiPut('/api/insta/preferences', { categories }); +} +``` + +- [ ] **Step 3: Verify with grep** + +```bash +grep -nE "getInstaTrends|instaCollectTrends|getInstaPreferences|putInstaPreferences" src/api.js +``` +Expected: 4 hits. + +- [ ] **Step 4: Commit (no commit yet — bundle with Task 9 UI changes for cleaner PR)** + +Skip commit; continue to Task 9. + +--- + +## Task 9: web-ui — Insta page tabs + 3 trend panels + +**Files:** +- Modify: `web-ui/src/pages/insta/InstaCards.jsx` +- Modify: `web-ui/src/pages/insta/InstaCards.css` + +- [ ] **Step 1: Re-read current `InstaCards.jsx` structure** + +```bash +cd /c/Users/jaeoh/Desktop/workspace/web-ui +sed -n '1,30p' src/pages/insta/InstaCards.jsx +``` + +Identify the default-export function `InstaCards` (around line 96 per spec). Note where each existing panel is rendered. + +- [ ] **Step 2: Add tab state + tab bar** at the top of the `InstaCards` function. Right before the existing JSX `return`: + +```jsx +const [activeTab, setActiveTab] = useState(() => { + const u = new URL(window.location.href); + return u.searchParams.get('tab') === 'trends' ? 'trends' : 'cards'; +}); + +const switchTab = (next) => { + setActiveTab(next); + const u = new URL(window.location.href); + if (next === 'cards') u.searchParams.delete('tab'); + else u.searchParams.set('tab', next); + window.history.replaceState({}, '', u.toString()); +}; +``` + +**2a. Locate the existing JSX return.** Inside `InstaCards()`, find the block that renders `` + `` + `` + `` + `` + ``. That is the "cards" composition. + +**2b. Extract the create-slate callback.** Locate the function/closure passed to `KeywordsPanel` as `onCreateSlate`. If it's defined inline, lift it into a named const inside `InstaCards` so both `KeywordsPanel` and the new `ExternalTrendsPanel` can call it: + +```jsx +const handleCreateSlate = async ({ keyword, category, keyword_id }) => { + // ... whatever the existing inline handler does (POST /api/insta/slates + poll task + refresh slates) +}; +``` +Pass it to `KeywordsPanel` (existing) AND `ExternalTrendsPanel` (new). + +**2c. Replace the JSX return** with TabBar + conditional panels: + +```jsx +return ( + + + switchTab('cards')} + >🎴 Cards + switchTab('trends')} + >📈 Trends + + + {activeTab === 'cards' && ( + <> + {/* PASTE the existing PullToRefresh + 5 panels block exactly as it was */} + > + )} + + {activeTab === 'trends' && ( + + + + + + )} + +); +``` + +The `{/* PASTE ... */}` comment is a literal instruction to the implementer: keep the previously-existing JSX intact, only wrap it in the `activeTab === 'cards'` conditional. + +- [ ] **Step 3: Add new imports at the top of `InstaCards.jsx`** + +Find the existing `import { ... } from '../../api'` block and add: + +```jsx +import { + getInstaTrends, + instaCollectTrends, + getInstaPreferences, + putInstaPreferences, + getInstaTask, +} from '../../api'; +``` + +(Some may already exist — dedupe.) + +- [ ] **Step 4: Implement `AccountFocusPanel` component** + +Append before `export default function InstaCards()`: + +```jsx +function AccountFocusPanel() { + const [prefs, setPrefs] = useState([]); + const [draft, setDraft] = useState({}); + const [saving, setSaving] = useState(false); + const [newCat, setNewCat] = useState(''); + + const load = useCallback(async () => { + const data = await getInstaPreferences(); + setPrefs(data.categories || []); + const m = {}; + (data.categories || []).forEach(p => { m[p.category] = Math.round(p.weight * 100); }); + setDraft(m); + }, []); + + useEffect(() => { load(); }, [load]); + + const save = async () => { + setSaving(true); + try { + const payload = {}; + Object.entries(draft).forEach(([k, v]) => { payload[k] = (Number(v) || 0) / 100; }); + await putInstaPreferences(payload); + await load(); + } finally { setSaving(false); } + }; + + const addCat = () => { + const name = newCat.trim().toLowerCase(); + if (!name || draft[name] !== undefined) return; + setDraft({ ...draft, [name]: 0 }); + setNewCat(''); + }; + + return ( + + 🎯 이 계정의 주제 (카테고리 가중치) + 슬라이더는 각 카테고리에 자동 추출 키워드 비율을 결정합니다. 합계는 자동 정규화됩니다. + + {Object.entries(draft).map(([cat, val]) => ( + + {cat} + setDraft({ ...draft, [cat]: Number(e.target.value) })} + className="ic-focus__slider" + /> + {val}% + + ))} + + + setNewCat(e.target.value)} + /> + + 추가 + + + {saving ? '저장 중...' : '저장'} + + + 💡 신규 카테고리를 추가했다면 Cards 탭의 Prompt Templates Editor에서 + category_seeds에 시드 키워드도 함께 정의해야 자동 추출에 반영됩니다. + + + ); +} +``` + +- [ ] **Step 5: Implement `ExternalTrendsPanel`** + +```jsx +const CATEGORY_COLORS = { + economy: '#0F62FE', psychology: '#A66CFF', + celebrity: '#FF5C8A', uncategorized: '#6B7280', +}; + +function ExternalTrendsPanel({ onCreateSlate }) { + const [naver, setNaver] = useState([]); + const [google, setGoogle] = useState([]); + const [lastFetched, setLastFetched] = useState(null); + const [collecting, setCollecting] = useState(false); + const [task, setTask] = useState(null); + + const load = useCallback(async () => { + const [n, g] = await Promise.all([ + getInstaTrends({ source: 'naver_popular', days: 2 }), + getInstaTrends({ source: 'google_trends', days: 2 }), + ]); + setNaver(n.items || []); + setGoogle(g.items || []); + const all = [...(n.items || []), ...(g.items || [])]; + if (all.length) { + const latest = all.map(t => t.suggested_at).sort().reverse()[0]; + setLastFetched(latest); + } + }, []); + + useEffect(() => { load(); }, [load]); + + const trigger = async () => { + setCollecting(true); + try { + const { task_id } = await instaCollectTrends(); + let st = null; + for (let i = 0; i < 60; i++) { + st = await getInstaTask(task_id); + setTask(st); + if (st.status === 'succeeded' || st.status === 'failed') break; + await new Promise(r => setTimeout(r, 3000)); + } + await load(); + } finally { setCollecting(false); } + }; + + const groupByCat = (items) => { + const g = {}; + items.forEach(it => { (g[it.category] = g[it.category] || []).push(it); }); + return g; + }; + + const renderRow = (t) => ( + + + {t.keyword} + {(t.score || 0).toFixed(2)} + onCreateSlate?.({ keyword: t.keyword, category: t.category })} + >🎴 + + ); + + const naverGrouped = groupByCat(naver); + return ( + + + 📈 외부 트렌드 + + + {lastFetched ? `마지막 수집: ${fmtDate(lastFetched)}` : '아직 수집 없음'} + + + {collecting ? '수집 중...' : '🔄 수동 수집'} + + + + {task && } + + + 🔥 NAVER 인기 + {Object.keys(naverGrouped).length === 0 && 없음} + {Object.entries(naverGrouped).map(([cat, items]) => ( + + {cat} + {items.map(renderRow)} + + ))} + + + 🌐 Google Trends + {google.length === 0 && 없음} + {google.map(renderRow)} + + + + ); +} +``` + +- [ ] **Step 6: Implement `PreferenceImpactPanel`** + +```jsx +function PreferenceImpactPanel() { + const [prefs, setPrefs] = useState([]); + const TOTAL = 15; + + useEffect(() => { + (async () => { + const data = await getInstaPreferences(); + setPrefs(data.categories || []); + })(); + }, []); + + const totalWeight = prefs.reduce((s, p) => s + (p.weight || 0), 0) || 1; + const breakdown = prefs.map(p => ({ + category: p.category, + count: Math.round(TOTAL * (p.weight || 0) / totalWeight), + })); + + return ( + + 📊 다음 자동 추출 미리보기 + + {breakdown.map(b => ( + + {b.category} + {b.count}개 + + ))} + + + ); +} +``` + +- [ ] **Step 7: Add CSS to `InstaCards.css`** — append: + +```css +/* ── tabs ── */ +.ic-tabbar { display: flex; gap: 8px; border-bottom: 1px solid #e2e8f0; margin-bottom: 16px; } +.ic-tab { + background: transparent; border: 0; padding: 10px 16px; + cursor: pointer; font-size: 14px; font-weight: 600; + color: #64748b; border-bottom: 2px solid transparent; +} +.ic-tab.is-active { color: #ec4899; border-bottom-color: #ec4899; } + +/* ── trends grid ── */ +.ic-trends-grid { display: grid; gap: 16px; grid-template-columns: 1fr; } +@media (min-width: 1024px) { .ic-trends-grid { grid-template-columns: 320px 1fr; } } + +/* ── focus panel ── */ +.ic-panel--focus .ic-focus__list { display: flex; flex-direction: column; gap: 10px; margin: 12px 0; } +.ic-focus__row { display: grid; grid-template-columns: 110px 1fr 50px; align-items: center; gap: 8px; } +.ic-focus__label { font-weight: 600; color: #475569; text-transform: capitalize; } +.ic-focus__slider { width: 100%; accent-color: #ec4899; } +.ic-focus__num { text-align: right; font-variant-numeric: tabular-nums; color: #475569; } +.ic-focus__add { display: flex; gap: 8px; margin-top: 12px; } +.ic-focus__add input { flex: 1; padding: 8px; border: 1px solid #cbd5e1; border-radius: 6px; } +.ic-focus__save { + width: 100%; padding: 10px; margin-top: 12px; + background: #ec4899; color: #fff; border: 0; border-radius: 6px; cursor: pointer; + font-weight: 700; +} +.ic-focus__hint { margin-top: 12px; padding: 10px; background: #fef3c7; border-left: 3px solid #f59e0b; font-size: 12px; } +.ic-focus__hint code { background: rgba(0,0,0,0.06); padding: 1px 4px; border-radius: 3px; } + +/* ── trends panel ── */ +.ic-trends__cols { display: grid; grid-template-columns: 1fr; gap: 16px; } +@media (min-width: 768px) { .ic-trends__cols { grid-template-columns: 1fr 1fr; } } +.ic-trends__col h4 { margin: 0 0 8px; font-size: 14px; color: #475569; } +.ic-trend__group { margin-bottom: 14px; } +.ic-trend__group-head { font-size: 12px; font-weight: 700; text-transform: uppercase; margin-bottom: 4px; letter-spacing: 0.5px; } +.ic-trend__row { + display: grid; grid-template-columns: 10px 1fr 50px 36px; + align-items: center; gap: 8px; padding: 6px 4px; + border-bottom: 1px solid #f1f5f9; +} +.ic-trend__cat-dot { width: 10px; height: 10px; border-radius: 50%; } +.ic-trend__kw { font-weight: 500; } +.ic-trend__score { text-align: right; color: #64748b; font-variant-numeric: tabular-nums; font-size: 12px; } +.ic-trend__make { background: #ec4899; border: 0; color: #fff; border-radius: 4px; cursor: pointer; padding: 4px; } +.ic-trend__make:hover { background: #db2777; } +.ic-empty { color: #94a3b8; font-style: italic; padding: 8px 0; } + +.ic-panel__head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; } +.ic-panel__actions { display: flex; gap: 8px; align-items: center; } + +/* ── impact panel ── */ +.ic-impact__row { display: flex; flex-wrap: wrap; gap: 8px; } +.ic-impact__chip { + display: flex; align-items: baseline; gap: 6px; + padding: 6px 12px; background: #f1f5f9; border-radius: 999px; +} +.ic-impact__cat { font-weight: 600; text-transform: capitalize; color: #475569; } +.ic-impact__count { color: #ec4899; font-weight: 700; } +``` + +- [ ] **Step 8: Build to verify** + +```bash +cd /c/Users/jaeoh/Desktop/workspace/web-ui +npm run build +``` +Expected: exit 0. If errors, READ them and fix (most likely typos or unused imports). DO NOT push broken builds. + +- [ ] **Step 9: Commit (Tasks 8 + 9 together)** + +```bash +git add src/api.js src/pages/insta/InstaCards.jsx src/pages/insta/InstaCards.css +git commit -m "feat(insta): Trends tab — account focus + external trends + impact preview" +git push origin main +``` + +--- + +## Task 10: Backend integration + push + smoke + +**Files:** none (verification only) + +- [ ] **Step 1: Backend — push insta-trends feature branch** + +```bash +cd /c/Users/jaeoh/Desktop/workspace/web-backend +git log --oneline main..feat/insta-trends +``` +Expected: 7-8 commits (Tasks 0 through 7). + +```bash +git push -u origin feat/insta-trends +``` + +- [ ] **Step 2: Create PR via Gitea UI** (the push response prints the URL). Title: `feat(insta): Trends tab — external sources + category weights`. + +- [ ] **Step 3: After merge, NAS deployer runs automatically**: + - rsync (new trend_collector.py, requirements.txt with pytrends) + - Dockerfile cache invalidates at the requirements.txt COPY step → pip installs pytrends → ~30s longer build + - Containers stop/rm/up; health check via docker inspect + +- [ ] **Step 4: Verify post-deploy on NAS** + +```bash +# from NAS +curl http://localhost:18700/api/insta/preferences +# → {"categories":[{"category":"economy","weight":1.0,...},{"category":"psychology",...},{"category":"celebrity",...}]} + +curl -X POST http://localhost:18700/api/insta/trends/collect -H "Content-Type: application/json" -d '{}' +# → {"task_id":"","categories":[...]} +# poll task → succeeded with message "naver:N, google:M" + +curl http://localhost:18700/api/insta/trends?source=google_trends +# → items list with category classified by Claude +``` + +- [ ] **Step 5: Frontend deploy** (manual per workspace CLAUDE.md): + +```bat +cd C:\Users\jaeoh\Desktop\workspace +scripts\deploy.bat --frontend +``` + +Then visit `http://localhost:8080/insta?tab=trends` (or `gahusb.synology.me:8080/insta?tab=trends`). Expected: tabs visible, Trends tab shows 3 panels, slider responds, 수동 수집 버튼 작동. + +- [ ] **Step 6: agent-office cron verification** + +Check agent-office logs the next day at 09:00: +```bash +docker logs agent-office --tail 50 | grep -i "trends_collect\|insta_trends" +``` +Expected: a log entry near 09:00 with successful collect_trends. + +--- + +## Verification matrix (before declaring done) + +| Check | Command | Expected | +|-------|---------|----------| +| insta-lab tests | `cd insta-lab && pytest -v` | All pass (≥33 tests) | +| agent-office tests | `cd agent-office && pytest -v` | All pass + 2 new (pre-existing `test_init_and_seed` failure remains unrelated) | +| web-ui build | `cd web-ui && npm run build` | exit 0 | +| backend grep | `grep -rn "trend_collector\|insta_collect_trends\|extract_with_weights\|account_preferences" insta-lab/ agent-office/` | non-empty (wiring exists) | +| frontend grep | `grep -rn "AccountFocusPanel\|ExternalTrendsPanel\|PreferenceImpactPanel\|/api/insta/trends\|/api/insta/preferences" web-ui/src/` | non-empty | +| post-deploy preferences | `curl localhost:18700/api/insta/preferences` | 3 default categories with weight=1.0 | +| post-deploy trends collect | `curl -X POST localhost:18700/api/insta/trends/collect -d '{}'` then poll | `succeeded` with non-zero result_id | +| frontend tab visible | Visit `/insta?tab=trends` | 3 panels render |
슬라이더는 각 카테고리에 자동 추출 키워드 비율을 결정합니다. 합계는 자동 정규화됩니다.
category_seeds
없음