feat(insta-lab): GET /keywords/ranked + POST /slates/{id}/decision

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-11 02:23:33 +09:00
parent c3a6e78954
commit 7d1857c8a4
2 changed files with 74 additions and 1 deletions

View File

@@ -6,6 +6,7 @@ import json
import logging
import os
import zipfile
from datetime import datetime, timezone
from typing import Optional
from fastapi import FastAPI, HTTPException, BackgroundTasks, Body, Query
@@ -21,7 +22,7 @@ from .config import (
)
import redis.asyncio as aioredis
from . import db, news_collector, keyword_extractor, card_writer, trend_collector
from . import db, news_collector, keyword_extractor, card_writer, trend_collector, selection, selection_judge
from .internal_router import router as internal_router
logger = logging.getLogger(__name__)
@@ -152,6 +153,22 @@ def list_keywords(
return {"items": db.list_trending_keywords(category=category, used=used)}
@app.get("/api/insta/keywords/ranked")
def ranked_keywords(limit: int = Query(20, ge=1, le=100), threshold: float = Query(0.6, ge=0.0, le=1.0)):
candidates = db.list_trending_keywords(used=False)
if not candidates:
return {"items": []}
issued = db.list_recent_issued_topics(window_days=14)
prefs = {p["category"]: p["weight"] for p in db.get_preferences()}
claude_scores = selection_judge.judge_candidates(candidates)
now_iso = datetime.now(timezone.utc).isoformat()
scored = selection.score_candidates(
candidates, issued, prefs, claude_scores=claude_scores,
threshold=threshold, now_iso=now_iso,
)
return {"items": scored[:limit]}
# ── Slates ───────────────────────────────────────────────────────
class SlateRequest(BaseModel):
keyword: str
@@ -282,6 +299,20 @@ def download_package(slate_id: int):
})
class DecisionBody(BaseModel):
decision: str # "approved" | "rejected"
@app.post("/api/insta/slates/{slate_id}/decision")
def slate_decision(slate_id: int, body: DecisionBody):
if not db.get_card_slate(slate_id):
raise HTTPException(404, "slate not found")
if body.decision not in ("approved", "rejected"):
raise HTTPException(400, "decision must be approved|rejected")
db.set_slate_decision(slate_id, body.decision)
return db.get_card_slate(slate_id)
@app.delete("/api/insta/slates/{slate_id}")
def delete_slate(slate_id: int):
if not db.get_card_slate(slate_id):

View File

@@ -0,0 +1,42 @@
import pytest
from fastapi.testclient import TestClient
from app import db, config, selection_judge
@pytest.fixture
def client(tmp_path, monkeypatch):
monkeypatch.setattr(config, "DB_PATH", str(tmp_path / "insta.db"))
monkeypatch.setattr(db, "DB_PATH", str(tmp_path / "insta.db"))
monkeypatch.setattr(selection_judge, "judge_candidates", lambda c: {})
db.init_db()
from app.main import app
return TestClient(app)
def test_ranked_returns_sorted_eligible(client, monkeypatch):
db.add_trending_keyword({"keyword": "금리", "category": "economy", "score": 0.9})
r = client.get("/api/insta/keywords/ranked?threshold=0.0&limit=10")
assert r.status_code == 200
items = r.json()["items"]
assert len(items) >= 1
assert "final_score" in items[0] and "eligible" in items[0]
def test_decision_approve_publishes(client):
sid = db.add_card_slate({"keyword": "금리", "category": "economy"})
r = client.post(f"/api/insta/slates/{sid}/decision", json={"decision": "approved"})
assert r.status_code == 200
assert db.get_card_slate(sid)["status"] == "published"
def test_decision_reject(client):
sid = db.add_card_slate({"keyword": "환율", "category": "economy"})
r = client.post(f"/api/insta/slates/{sid}/decision", json={"decision": "rejected"})
assert r.status_code == 200
assert db.get_card_slate(sid)["status"] == "rejected"
def test_decision_invalid_400(client):
sid = db.add_card_slate({"keyword": "x", "category": "economy"})
r = client.post(f"/api/insta/slates/{sid}/decision", json={"decision": "maybe"})
assert r.status_code == 400