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:
@@ -137,6 +137,38 @@ def init_db() -> None:
|
|||||||
""")
|
""")
|
||||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_rr_month ON revenue_records(record_month DESC)")
|
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 ──────────────────────────────────────────────────────────
|
# ── music_tasks CRUD ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -583,3 +615,88 @@ def get_revenue_dashboard() -> dict:
|
|||||||
"by_month": [dict(r) for r in by_month],
|
"by_month": [dict(r) for r in by_month],
|
||||||
"by_country": [dict(r) for r in by_country],
|
"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]
|
||||||
|
|||||||
@@ -17,7 +17,10 @@ from .db import (
|
|||||||
update_video_project_status, delete_video_project,
|
update_video_project_status, delete_video_project,
|
||||||
create_revenue_record, get_all_revenue_records,
|
create_revenue_record, get_all_revenue_records,
|
||||||
update_revenue_record, delete_revenue_record, get_revenue_dashboard,
|
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 .local_provider import run_local_generation
|
||||||
from .suno_provider import (
|
from .suno_provider import (
|
||||||
run_suno_generation, run_suno_extend, run_vocal_removal,
|
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):
|
if not delete_revenue_record(record_id):
|
||||||
raise HTTPException(status_code=404, detail="Record not found")
|
raise HTTPException(status_code=404, detail="Record not found")
|
||||||
return {"ok": True}
|
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
102
music-lab/app/market.py
Normal 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]
|
||||||
37
music-lab/tests/test_market.py
Normal file
37
music-lab/tests/test_market.py
Normal 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
|
||||||
Reference in New Issue
Block a user