Issue #518 - Subscription not updating after checkout: - Fix stale closure in SubscriptionContext checkout polling (use subscriptionRef) - Move checkout success polling from InitialRouteHandler into SubscriptionContext - Remove redundant polling code from InitialRouteHandler - Fix plan label: 'Free' instead of 'No Plan', proper capitalization - Add plan refresh button in UserBadge - Add 'View Costing Details' to UserBadge dropdown - Rename 'ALwrity Podcast Maker' to 'Podcast Creator' across UI - Clean subscription=success URL param after verification Blog Writer WYSIWYG Editor enhancements: - Per-section preview toggle (view/edit icons) - Enhanced hover-based toolbar - Circular SVG progress stats bar with detailed tooltip - Research tool chips in stats bar footer - Per-section TTS with useTextToSpeech hook (browser native) - Full blog preview modal with print/PDF support - PlayAllTTSButton: sequential playback with progress bar - OnThisPageNav: floating sidebar with scroll tracking - Section data attributes for scroll anchoring GSC Brainstorm Topics feature: - Backend: gsc_brainstorm_service.py (rule-based + LLM recommendations) - Backend: POST /gsc/brainstorm endpoint with 3-word minimum validation - Frontend: gscBrainstorm.ts API client - Frontend: useGSCBrainstormConnection hook (popup OAuth, no /onboarding redirect) - Frontend: useGSCBrainstorm hook (connect check + brainstorm call) - Frontend: GSCBrainstormModal (3-tab results: Opportunities, Gaps, AI Recs) - Frontend: BrainstormButton (visible at 3+ words, GSC connect overlay) - Wire BrainstormButton into ManualResearchForm and ResearchAction - Add blog_writer to gsc_auth router features for ALWRITY_ENABLED_FEATURES
192 lines
6.8 KiB
Python
192 lines
6.8 KiB
Python
"""
|
|
Chart API — Shared chart generation endpoints for Blog Writer, Podcast Maker, etc.
|
|
|
|
Two modes:
|
|
1. Explicit: POST /api/charts/generate with { chart_type, chart_data, title }
|
|
2. AI-driven: POST /api/charts/generate with { text } → LLM infers chart_type + data
|
|
|
|
Both return { preview_url, chart_id, chart_type?, chart_data?, title? }
|
|
"""
|
|
|
|
import uuid
|
|
from pathlib import Path
|
|
from typing import Dict, Any, Optional
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
from fastapi.responses import FileResponse
|
|
from pydantic import BaseModel, Field
|
|
from loguru import logger
|
|
|
|
from middleware.auth_middleware import get_current_user, get_current_user_with_query_token
|
|
from api.story_writer.utils.auth import require_authenticated_user
|
|
from services.chart_service import get_chart_service, VALID_CHART_TYPES
|
|
|
|
|
|
router = APIRouter(prefix="/api/charts", tags=["Charts"])
|
|
|
|
|
|
class ChartGenerateRequest(BaseModel):
|
|
"""Request for chart generation.
|
|
|
|
Provide either:
|
|
- chart_type + chart_data (explicit mode), OR
|
|
- text (AI inference mode — LLM determines chart_type + data)
|
|
"""
|
|
chart_data: Optional[Dict[str, Any]] = Field(
|
|
default=None,
|
|
description="Chart data dict (labels, values, before/after, etc.)"
|
|
)
|
|
chart_type: Optional[str] = Field(
|
|
default=None,
|
|
description=f"Chart type: {', '.join(VALID_CHART_TYPES)}"
|
|
)
|
|
title: str = Field(default="", description="Chart title")
|
|
subtitle: Optional[str] = Field(default="", description="Optional subtitle")
|
|
text: Optional[str] = Field(
|
|
default=None,
|
|
description="Text to infer chart from (AI mode). Mutually exclusive with chart_type+chart_data."
|
|
)
|
|
section_heading: Optional[str] = Field(
|
|
default=None,
|
|
description="Blog section heading for context (AI mode with research)"
|
|
)
|
|
section_key_points: Optional[list] = Field(
|
|
default=None,
|
|
description="Key points from the section (AI mode with research)"
|
|
)
|
|
|
|
|
|
class ChartGenerateResponse(BaseModel):
|
|
"""Response for chart generation."""
|
|
preview_url: str = ""
|
|
chart_id: str = ""
|
|
chart_type: Optional[str] = None
|
|
chart_data: Optional[Dict[str, Any]] = None
|
|
title: Optional[str] = None
|
|
warnings: list = Field(default_factory=list, description="Pipeline warnings (e.g. Exa search failures)")
|
|
|
|
|
|
@router.post("/generate", response_model=ChartGenerateResponse)
|
|
async def generate_chart(
|
|
request: ChartGenerateRequest,
|
|
current_user: Dict[str, Any] = Depends(get_current_user),
|
|
):
|
|
"""
|
|
Generate a chart PNG preview.
|
|
|
|
Two modes:
|
|
1. Explicit: Provide chart_type + chart_data
|
|
2. AI-driven: Provide text, and the LLM infers chart_type + chart_data
|
|
"""
|
|
user_id = require_authenticated_user(current_user)
|
|
|
|
try:
|
|
chart_svc = get_chart_service(user_id=user_id)
|
|
|
|
if request.text and not request.chart_type:
|
|
# AI inference mode
|
|
logger.info(f"[Charts] AI inference mode for user {user_id}, text length={len(request.text)}")
|
|
result = await chart_svc.generate_chart_from_text(
|
|
text=request.text,
|
|
user_id=user_id,
|
|
section_heading=request.section_heading,
|
|
section_key_points=request.section_key_points,
|
|
)
|
|
|
|
if not result.get("path"):
|
|
raise HTTPException(status_code=500, detail="Chart generation failed")
|
|
|
|
chart_id = result["chart_id"]
|
|
filename = result.get("filename", f"chart_preview_{chart_id}.png")
|
|
|
|
return ChartGenerateResponse(
|
|
preview_url=f"/api/charts/preview/{chart_id}/{filename}",
|
|
chart_id=chart_id,
|
|
chart_type=result.get("chart_type"),
|
|
chart_data=result.get("chart_data"),
|
|
title=result.get("title"),
|
|
warnings=result.get("warnings", []),
|
|
)
|
|
|
|
elif request.chart_type and request.chart_data:
|
|
# Explicit mode
|
|
chart_type = request.chart_type
|
|
if chart_type not in VALID_CHART_TYPES:
|
|
# Try normalizing aliases
|
|
from services.chart_service import _normalize_chart_type
|
|
chart_type = _normalize_chart_type(chart_type)
|
|
if chart_type not in VALID_CHART_TYPES:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"Invalid chart_type. Must be one of: {VALID_CHART_TYPES}"
|
|
)
|
|
|
|
logger.info(f"[Charts] Explicit mode: type={chart_type}, user={user_id}")
|
|
|
|
chart_id = uuid.uuid4().hex[:8]
|
|
result = chart_svc.generate_chart(
|
|
chart_data=request.chart_data,
|
|
chart_type=chart_type,
|
|
title=request.title,
|
|
subtitle=request.subtitle or "",
|
|
chart_id=chart_id,
|
|
)
|
|
|
|
if not result.get("path"):
|
|
raise HTTPException(status_code=500, detail="Chart generation failed — check chart_data format")
|
|
|
|
filename = result.get("filename", f"chart_preview_{chart_id}.png")
|
|
|
|
return ChartGenerateResponse(
|
|
preview_url=f"/api/charts/preview/{chart_id}/{filename}",
|
|
chart_id=chart_id,
|
|
chart_type=chart_type,
|
|
chart_data=request.chart_data,
|
|
title=request.title,
|
|
)
|
|
|
|
else:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="Provide either 'text' (AI mode) or 'chart_type' + 'chart_data' (explicit mode)"
|
|
)
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"[Charts] Generation failed: {e}")
|
|
raise HTTPException(status_code=500, detail=f"Chart generation failed: {str(e)}")
|
|
|
|
|
|
@router.get("/preview/{chart_id}/{filename}")
|
|
async def serve_chart_preview(
|
|
chart_id: str,
|
|
filename: str,
|
|
current_user: Dict[str, Any] = Depends(get_current_user_with_query_token),
|
|
):
|
|
"""Serve chart preview PNG files. Auth via header or query token."""
|
|
user_id = require_authenticated_user(current_user)
|
|
|
|
if ".." in filename or "/" in filename or "\\" in filename:
|
|
raise HTTPException(status_code=400, detail="Invalid filename")
|
|
|
|
chart_svc = get_chart_service(user_id=user_id)
|
|
file_path = chart_svc.get_chart_preview_path(chart_id)
|
|
|
|
if not file_path.exists():
|
|
raise HTTPException(status_code=404, detail="Chart preview not found")
|
|
|
|
if not str(file_path.resolve()).startswith(str(chart_svc.output_dir.resolve())):
|
|
raise HTTPException(status_code=403, detail="Access denied")
|
|
|
|
return FileResponse(
|
|
path=str(file_path),
|
|
media_type="image/png",
|
|
filename=filename,
|
|
)
|
|
|
|
|
|
@router.get("/health")
|
|
async def charts_health():
|
|
"""Health check for Charts service."""
|
|
return {"status": "ok", "service": "charts"} |