[F4] /api/portfolio 응답의 summary.total_buy가 종목별 단가 × 수량의 합이 되도록 fix. 기존 인라인 코드가 purchase_price를 수량 미곱산으로 단순 누적해 명세(qty 100 · avg 72000 → 7,200,000)와 어긋났음. API_SPEC.md에 purchase_price 필드 의미 + total_buy 계산식 명시. test 3건 (단가 곱산, avg_price 폴백, 다종목 합산). [F6] insta-trends spec/plan 상단에 "google_trends → youtube_trending" 변경 이력 추가. Google Trends endpoint 폐기로 source 교체된 이력이 본문 검색 시 혼란 주는 문제 차단. 사유 cross-ref: feedback_external_data_sources.md
1786 lines
63 KiB
Markdown
1786 lines
63 KiB
Markdown
# 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.
|
|
|
|
## ⚠️ 변경 이력
|
|
|
|
- **2026-05-17**: 본문에 `google_trends` source로 기재된 모든 task와 코드 블록은 **실제 구현에서 `youtube_trending`으로 교체됨**. Google Trends 비공식 endpoint(RSS + dailytrends JSON 양쪽) 모두 404 폐기 확인. YouTube Data API v3 mostPopular로 source 대체 + pytrends 의존성 제거. 운영 코드는 현재 `youtube_trending` 사용 중. 이 plan을 다시 실행할 일이 있으면 본문의 `google_trends` 단어를 `youtube_trending`으로 읽어달라. 자세한 사유와 교체 체크리스트는 `feedback_external_data_sources.md`.
|
|
|
|
**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": "<b>기준금리</b> 인상", "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 `<PullToRefresh>` + `<TriggerPanel />` + `<KeywordsPanel onCreateSlate={...} />` + `<SlatesPanel ... />` + `<SlateDetail .../>` + `<PromptTemplatesEditor />`. 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 (
|
|
<div className="ic">
|
|
<div className="ic-tabbar">
|
|
<button
|
|
className={`ic-tab ${activeTab === 'cards' ? 'is-active' : ''}`}
|
|
onClick={() => switchTab('cards')}
|
|
>🎴 Cards</button>
|
|
<button
|
|
className={`ic-tab ${activeTab === 'trends' ? 'is-active' : ''}`}
|
|
onClick={() => switchTab('trends')}
|
|
>📈 Trends</button>
|
|
</div>
|
|
|
|
{activeTab === 'cards' && (
|
|
<>
|
|
{/* PASTE the existing PullToRefresh + 5 panels block exactly as it was */}
|
|
</>
|
|
)}
|
|
|
|
{activeTab === 'trends' && (
|
|
<div className="ic-trends-grid">
|
|
<AccountFocusPanel />
|
|
<ExternalTrendsPanel onCreateSlate={handleCreateSlate} />
|
|
<PreferenceImpactPanel />
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
```
|
|
|
|
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 (
|
|
<section className="ic-panel ic-panel--focus">
|
|
<h3 className="ic-panel__title">🎯 이 계정의 주제 (카테고리 가중치)</h3>
|
|
<p className="ic-panel__hint">슬라이더는 각 카테고리에 자동 추출 키워드 비율을 결정합니다. 합계는 자동 정규화됩니다.</p>
|
|
<div className="ic-focus__list">
|
|
{Object.entries(draft).map(([cat, val]) => (
|
|
<div key={cat} className="ic-focus__row">
|
|
<label className="ic-focus__label">{cat}</label>
|
|
<input
|
|
type="range" min="0" max="100" value={val}
|
|
onChange={e => setDraft({ ...draft, [cat]: Number(e.target.value) })}
|
|
className="ic-focus__slider"
|
|
/>
|
|
<span className="ic-focus__num">{val}%</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<div className="ic-focus__add">
|
|
<input
|
|
type="text" placeholder="신규 카테고리 (영문 소문자)"
|
|
value={newCat} onChange={e => setNewCat(e.target.value)}
|
|
/>
|
|
<button onClick={addCat}>+ 추가</button>
|
|
</div>
|
|
<button className="ic-focus__save" onClick={save} disabled={saving}>
|
|
{saving ? '저장 중...' : '저장'}
|
|
</button>
|
|
<div className="ic-focus__hint">
|
|
💡 신규 카테고리를 추가했다면 Cards 탭의 Prompt Templates Editor에서
|
|
<code>category_seeds</code>에 시드 키워드도 함께 정의해야 자동 추출에 반영됩니다.
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
```
|
|
|
|
- [ ] **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) => (
|
|
<div className="ic-trend__row" key={`${t.source}-${t.id}`}>
|
|
<span className="ic-trend__cat-dot" style={{ background: CATEGORY_COLORS[t.category] || '#6B7280' }} />
|
|
<span className="ic-trend__kw">{t.keyword}</span>
|
|
<span className="ic-trend__score">{(t.score || 0).toFixed(2)}</span>
|
|
<button
|
|
className="ic-trend__make"
|
|
onClick={() => onCreateSlate?.({ keyword: t.keyword, category: t.category })}
|
|
>🎴</button>
|
|
</div>
|
|
);
|
|
|
|
const naverGrouped = groupByCat(naver);
|
|
return (
|
|
<section className="ic-panel ic-panel--trends">
|
|
<div className="ic-panel__head">
|
|
<h3 className="ic-panel__title">📈 외부 트렌드</h3>
|
|
<div className="ic-panel__actions">
|
|
<span className="ic-panel__hint">
|
|
{lastFetched ? `마지막 수집: ${fmtDate(lastFetched)}` : '아직 수집 없음'}
|
|
</span>
|
|
<button onClick={trigger} disabled={collecting}>
|
|
{collecting ? '수집 중...' : '🔄 수동 수집'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
{task && <TaskStatusBox task={task} />}
|
|
<div className="ic-trends__cols">
|
|
<div className="ic-trends__col">
|
|
<h4>🔥 NAVER 인기</h4>
|
|
{Object.keys(naverGrouped).length === 0 && <p className="ic-empty">없음</p>}
|
|
{Object.entries(naverGrouped).map(([cat, items]) => (
|
|
<div key={cat} className="ic-trend__group">
|
|
<div className="ic-trend__group-head" style={{ color: CATEGORY_COLORS[cat] || '#6B7280' }}>{cat}</div>
|
|
{items.map(renderRow)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
<div className="ic-trends__col">
|
|
<h4>🌐 Google Trends</h4>
|
|
{google.length === 0 && <p className="ic-empty">없음</p>}
|
|
{google.map(renderRow)}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
```
|
|
|
|
- [ ] **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 (
|
|
<section className="ic-panel ic-panel--impact">
|
|
<h3 className="ic-panel__title">📊 다음 자동 추출 미리보기</h3>
|
|
<div className="ic-impact__row">
|
|
{breakdown.map(b => (
|
|
<div key={b.category} className="ic-impact__chip">
|
|
<span className="ic-impact__cat">{b.category}</span>
|
|
<span className="ic-impact__count">{b.count}개</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
```
|
|
|
|
- [ ] **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":"<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 |
|