Files
ALwrity/backend/api/charts.py
ajaysi 644e72d289 feat: Brainstorm Topics with GSC + Issue #518 fixes + Blog Editor enhancements
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
2026-05-20 22:44:15 +05:30

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"}