feat(music-lab): market_trends·trend_reports DB + market.py + /api/music/market 5개 API

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-01 12:26:37 +09:00
parent 3b9dcfe0dd
commit 26b9eea0dc
4 changed files with 303 additions and 0 deletions

View File

@@ -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]

View File

@@ -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)}

102
music-lab/app/market.py Normal file
View File

@@ -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]

View File

@@ -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