From 26b9eea0dcede2a69d0dcfa5be94ffa6cc4f4abc Mon Sep 17 00:00:00 2001 From: gahusb Date: Fri, 1 May 2026 12:26:37 +0900 Subject: [PATCH] =?UTF-8?q?feat(music-lab):=20market=5Ftrends=C2=B7trend?= =?UTF-8?q?=5Freports=20DB=20+=20market.py=20+=20/api/music/market=205?= =?UTF-8?q?=EA=B0=9C=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- music-lab/app/db.py | 117 +++++++++++++++++++++++++++++++++ music-lab/app/main.py | 47 +++++++++++++ music-lab/app/market.py | 102 ++++++++++++++++++++++++++++ music-lab/tests/test_market.py | 37 +++++++++++ 4 files changed, 303 insertions(+) create mode 100644 music-lab/app/market.py create mode 100644 music-lab/tests/test_market.py diff --git a/music-lab/app/db.py b/music-lab/app/db.py index cf7fd86..df4c31d 100644 --- a/music-lab/app/db.py +++ b/music-lab/app/db.py @@ -137,6 +137,38 @@ def init_db() -> None: """) conn.execute("CREATE INDEX IF NOT EXISTS idx_rr_month ON revenue_records(record_month DESC)") + # ── market_trends 테이블 ────────────────────────────────────────── + conn.execute(""" + CREATE TABLE IF NOT EXISTS market_trends ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + source TEXT NOT NULL DEFAULT '', + country TEXT NOT NULL DEFAULT '', + genre TEXT NOT NULL DEFAULT '', + keyword TEXT NOT NULL DEFAULT '', + score REAL NOT NULL DEFAULT 0.0, + rank INTEGER, + metadata TEXT NOT NULL DEFAULT '{}', + collected_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) + ) + """) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_mt_country_source " + "ON market_trends(country, source, collected_at DESC)" + ) + + # ── trend_reports 테이블 ────────────────────────────────────────── + conn.execute(""" + CREATE TABLE IF NOT EXISTS trend_reports ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + report_date TEXT UNIQUE NOT NULL DEFAULT '', + top_genres TEXT NOT NULL DEFAULT '[]', + top_keywords TEXT NOT NULL DEFAULT '[]', + recommended_styles TEXT NOT NULL DEFAULT '[]', + insights TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) + ) + """) + # ── music_tasks CRUD ────────────────────────────────────────────────────────── @@ -583,3 +615,88 @@ def get_revenue_dashboard() -> dict: "by_month": [dict(r) for r in by_month], "by_country": [dict(r) for r in by_country], } + + +# ── market_trends CRUD ──────────────────────────────────────────────────────── + +def insert_market_trends(trends: list) -> None: + with _conn() as conn: + conn.executemany( + """INSERT INTO market_trends (source, country, genre, keyword, score, rank, metadata) + VALUES (?, ?, ?, ?, ?, ?, ?)""", + [(t.get("source",""), t.get("country",""), t.get("genre",""), + t.get("keyword",""), t.get("score", 0.0), t.get("rank"), + json.dumps(t.get("metadata", {}))) + for t in trends], + ) + + +def get_market_trends( + country: str = None, genre: str = None, source: str = None, days: int = 7 +) -> list: + with _conn() as conn: + q = "SELECT * FROM market_trends WHERE collected_at >= datetime('now', ?)" + params: list = [f"-{days} days"] + if country: + q += " AND country=?"; params.append(country) + if genre: + q += " AND genre=?"; params.append(genre) + if source: + q += " AND source=?"; params.append(source) + q += " ORDER BY collected_at DESC LIMIT 500" + rows = conn.execute(q, params).fetchall() + return [ + {"id": r["id"], "source": r["source"], "country": r["country"], + "genre": r["genre"], "keyword": r["keyword"], "score": r["score"], + "rank": r["rank"], "metadata": json.loads(r["metadata"]), + "collected_at": r["collected_at"]} + for r in rows + ] + + +# ── trend_reports CRUD ──────────────────────────────────────────────────────── + +def upsert_trend_report(data: dict) -> None: + with _conn() as conn: + conn.execute( + """INSERT INTO trend_reports + (report_date, top_genres, top_keywords, recommended_styles, insights) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(report_date) DO UPDATE SET + top_genres=excluded.top_genres, + top_keywords=excluded.top_keywords, + recommended_styles=excluded.recommended_styles, + insights=excluded.insights""", + (data["report_date"], json.dumps(data["top_genres"]), + json.dumps(data["top_keywords"]), json.dumps(data["recommended_styles"]), + data["insights"]), + ) + + +def get_latest_trend_report() -> Optional[Dict[str, Any]]: + with _conn() as conn: + row = conn.execute( + "SELECT * FROM trend_reports ORDER BY report_date DESC LIMIT 1" + ).fetchone() + if not row: + return None + return { + "id": row["id"], + "report_date": row["report_date"], + "top_genres": json.loads(row["top_genres"]), + "top_keywords": json.loads(row["top_keywords"]), + "recommended_styles": json.loads(row["recommended_styles"]), + "insights": row["insights"], + "created_at": row["created_at"], + } + + +def get_trend_reports(limit: int = 10) -> list: + with _conn() as conn: + rows = conn.execute( + "SELECT id, report_date, insights, created_at FROM trend_reports " + "ORDER BY report_date DESC LIMIT ?", (limit,) + ).fetchall() + return [{"id": r["id"], "report_date": r["report_date"], + "insights": r["insights"][:100], "created_at": r["created_at"]} + for r in rows] diff --git a/music-lab/app/main.py b/music-lab/app/main.py index 0d9aa1c..0520190 100644 --- a/music-lab/app/main.py +++ b/music-lab/app/main.py @@ -17,7 +17,10 @@ from .db import ( update_video_project_status, delete_video_project, create_revenue_record, get_all_revenue_records, update_revenue_record, delete_revenue_record, get_revenue_dashboard, + get_market_trends as _get_market_trends, + get_latest_trend_report, get_trend_reports as _get_trend_reports, ) +from .market import ingest_trends, get_suggestions from .local_provider import run_local_generation from .suno_provider import ( run_suno_generation, run_suno_extend, run_vocal_removal, @@ -811,3 +814,47 @@ def remove_revenue(record_id: int): if not delete_revenue_record(record_id): raise HTTPException(status_code=404, detail="Record not found") return {"ok": True} + + +# ── 시장 조사 API ───────────────────────────────────────────────────────────── + +class MarketIngestRequest(BaseModel): + trends: list + report_date: str = "" + + +@app.post("/api/music/market/ingest") +def market_ingest(req: MarketIngestRequest): + """agent-office → 트렌드 데이터 수신 + 리포트 생성.""" + from datetime import date + report_date = req.report_date or date.today().isoformat() + report = ingest_trends(req.trends, report_date) + return {"ok": True, "trends_saved": len(req.trends), "report_date": report_date} + + +@app.get("/api/music/market/trends") +def list_market_trends( + country: Optional[str] = None, + genre: Optional[str] = None, + source: Optional[str] = None, + days: int = 7, +): + return {"trends": _get_market_trends(country, genre, source, days)} + + +@app.get("/api/music/market/report/latest") +def get_market_report_latest(): + report = get_latest_trend_report() + if not report: + raise HTTPException(status_code=404, detail="리포트 없음 — 아직 수집 전") + return report + + +@app.get("/api/music/market/report") +def list_market_reports(limit: int = 10): + return {"reports": _get_trend_reports(limit)} + + +@app.get("/api/music/market/suggest") +def market_suggest(limit: int = 5): + return {"suggestions": get_suggestions(limit)} diff --git a/music-lab/app/market.py b/music-lab/app/market.py new file mode 100644 index 0000000..f108526 --- /dev/null +++ b/music-lab/app/market.py @@ -0,0 +1,102 @@ +# music-lab/app/market.py +import os +from collections import Counter, defaultdict +from typing import Any, Dict, List, Optional + +from .db import ( + get_latest_trend_report, get_trend_reports, + insert_market_trends, upsert_trend_report, +) + +ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY", "") + +GENRE_PROMPTS: Dict[str, str] = { + "lo-fi": "lo-fi hip hop, chill, relaxing beats, study music, 85 BPM, jazzy chords", + "phonk": "dark phonk, aggressive 808 bass, Memphis trap, distorted synths, 140 BPM", + "ambient": "ambient, atmospheric, ethereal pads, slow evolving textures, no percussion", + "pop": "upbeat pop, catchy melody, modern production, 120 BPM", + "funk": "baile funk, Brazilian funk, energetic, 150 BPM", + "latin": "reggaeton, latin pop, dembow rhythm, 100 BPM", + "general": "music, modern production, wide appeal", +} + + +def ingest_trends(trends: List[Dict[str, Any]], report_date: str) -> Dict[str, Any]: + """agent-office 트렌드 수신 → 저장 + 리포트 생성.""" + insert_market_trends(trends) + report = _build_report(trends, report_date) + upsert_trend_report(report) + return report + + +def _build_report(trends: List[Dict[str, Any]], report_date: str) -> Dict[str, Any]: + genre_scores: Dict[str, float] = defaultdict(float) + genre_countries: Dict[str, set] = defaultdict(set) + keywords: List[str] = [] + + for t in trends: + g = t.get("genre") or "general" + genre_scores[g] += t.get("score", 0.0) + genre_countries[g].add(t.get("country", "")) + kw = t.get("keyword", "") + if kw: + keywords.append(kw) + + top_genres = sorted( + [{"genre": g, "score": round(s, 3), "countries": list(genre_countries[g])} + for g, s in genre_scores.items()], + key=lambda x: x["score"], reverse=True, + )[:10] + + kw_counts = Counter(keywords) + top_keywords = [kw for kw, _ in kw_counts.most_common(15)] + + recommended_styles = [ + { + "genre": g["genre"], + "suno_prompt": GENRE_PROMPTS.get(g["genre"], GENRE_PROMPTS["general"]), + "target_countries": g["countries"][:3], + "reason": f"트렌딩 score {g['score']:.2f}", + } + for g in top_genres[:5] + ] + + return { + "report_date": report_date, + "top_genres": top_genres, + "top_keywords": top_keywords, + "recommended_styles": recommended_styles, + "insights": _generate_insights(top_genres, top_keywords), + } + + +def _generate_insights(top_genres: list, top_keywords: list) -> str: + if not top_genres: + return "아직 수집된 트렌드 데이터가 없습니다." + if not ANTHROPIC_API_KEY: + names = ", ".join(g["genre"] for g in top_genres[:3]) + return f"이번 주 인기 장르: {names}. 해당 장르 중심 제작을 추천합니다." + + import anthropic + client = anthropic.Anthropic(api_key=ANTHROPIC_API_KEY) + genre_str = ", ".join(f"{g['genre']}({g['score']:.1f})" for g in top_genres[:5]) + kw_str = ", ".join(top_keywords[:10]) + try: + msg = client.messages.create( + model="claude-haiku-4-5-20251001", + max_tokens=300, + messages=[{"role": "user", "content": + f"YouTube 음악 트렌드 인사이트를 2-3문장으로 요약.\n" + f"인기 장르: {genre_str}\n인기 키워드: {kw_str}"}], + ) + return msg.content[0].text.strip() + except Exception: + names = ", ".join(g["genre"] for g in top_genres[:3]) + return f"인기 장르: {names}." + + +def get_suggestions(limit: int = 5) -> List[Dict[str, Any]]: + report = get_latest_trend_report() + if not report: + return [] + return report.get("recommended_styles", [])[:limit] diff --git a/music-lab/tests/test_market.py b/music-lab/tests/test_market.py new file mode 100644 index 0000000..d62ebbe --- /dev/null +++ b/music-lab/tests/test_market.py @@ -0,0 +1,37 @@ +# music-lab/tests/test_market.py + +def test_ingest_and_report(tmp_db): + from app.db import init_db + from app.market import ingest_trends, get_suggestions + init_db() + + trends = [ + {"source": "youtube", "country": "BR", "genre": "lo-fi", "keyword": "lofi study", "score": 0.9, "rank": 1, "metadata": {}}, + {"source": "youtube", "country": "ID", "genre": "pop", "keyword": "pop hits", "score": 0.7, "rank": 2, "metadata": {}}, + {"source": "billboard", "country": "US", "genre": "pop", "keyword": "top 40", "score": 0.8, "rank": 1, "metadata": {}}, + ] + report = ingest_trends(trends, "2026-05-01") + assert report["report_date"] == "2026-05-01" + assert len(report["top_genres"]) >= 2 + # pop이 lo-fi보다 score 높아야 함 (2건) + genres_by_score = [g["genre"] for g in report["top_genres"]] + assert genres_by_score[0] == "pop" + + suggestions = get_suggestions(limit=3) + assert len(suggestions) >= 1 + assert "suno_prompt" in suggestions[0] + + +def test_ingest_idempotent(tmp_db): + """같은 날 두 번 ingest해도 report가 upsert 돼야 함.""" + from app.db import init_db, get_trend_reports + from app.market import ingest_trends + init_db() + + trends = [{"source": "youtube", "country": "BR", "genre": "lo-fi", + "keyword": "chill", "score": 0.8, "rank": 1, "metadata": {}}] + ingest_trends(trends, "2026-05-01") + ingest_trends(trends, "2026-05-01") # 두 번째 + + reports = get_trend_reports() + assert len([r for r in reports if r["report_date"] == "2026-05-01"]) == 1