296 lines
10 KiB
Python
296 lines
10 KiB
Python
"""
|
||
Brainstorming endpoints for generating Google search prompts and running a
|
||
single grounded search to surface topic ideas. Built for reusability across
|
||
editors. Uses the existing Gemini provider modules.
|
||
"""
|
||
|
||
from fastapi import APIRouter, HTTPException
|
||
from pydantic import BaseModel, Field
|
||
from typing import List, Dict, Any, Optional
|
||
from loguru import logger
|
||
|
||
from services.llm_providers.gemini_provider import gemini_structured_json_response
|
||
|
||
try:
|
||
from services.llm_providers.gemini_grounded_provider import GeminiGroundedProvider
|
||
GROUNDED_AVAILABLE = True
|
||
except Exception:
|
||
GROUNDED_AVAILABLE = False
|
||
|
||
|
||
router = APIRouter(prefix="/api/brainstorm", tags=["Brainstorming"])
|
||
|
||
|
||
class PersonaPayload(BaseModel):
|
||
persona_name: Optional[str] = None
|
||
archetype: Optional[str] = None
|
||
core_belief: Optional[str] = None
|
||
tonal_range: Optional[Dict[str, Any]] = None
|
||
linguistic_fingerprint: Optional[Dict[str, Any]] = None
|
||
|
||
|
||
class PlatformPersonaPayload(BaseModel):
|
||
content_format_rules: Optional[Dict[str, Any]] = None
|
||
engagement_patterns: Optional[Dict[str, Any]] = None
|
||
content_types: Optional[Dict[str, Any]] = None
|
||
tonal_range: Optional[Dict[str, Any]] = None
|
||
|
||
|
||
class PromptRequest(BaseModel):
|
||
seed: str = Field(..., description="Idea seed provided by end user")
|
||
persona: Optional[PersonaPayload] = None
|
||
platformPersona: Optional[PlatformPersonaPayload] = None
|
||
count: int = Field(5, ge=3, le=10, description="Number of prompts to generate (default 5)")
|
||
|
||
|
||
class PromptResponse(BaseModel):
|
||
prompts: List[str]
|
||
|
||
|
||
@router.post("/prompts", response_model=PromptResponse)
|
||
async def generate_prompts(req: PromptRequest) -> PromptResponse:
|
||
"""Generate N high-signal Google search prompts using Gemini structured output."""
|
||
try:
|
||
persona_line = ""
|
||
if req.persona:
|
||
parts = []
|
||
if req.persona.persona_name:
|
||
parts.append(req.persona.persona_name)
|
||
if req.persona.archetype:
|
||
parts.append(f"({req.persona.archetype})")
|
||
persona_line = " ".join(parts)
|
||
|
||
platform_hints = []
|
||
if req.platformPersona and req.platformPersona.content_format_rules:
|
||
limit = req.platformPersona.content_format_rules.get("character_limit")
|
||
if limit:
|
||
platform_hints.append(f"respect LinkedIn character limit {limit}")
|
||
|
||
sys_prompt = (
|
||
"You are an expert LinkedIn strategist who crafts precise Google search prompts "
|
||
"to ideate content topics. Follow Google grounding best-practices: be specific, "
|
||
"time-bound (2024-2025), include entities, and prefer intent-rich phrasing."
|
||
)
|
||
|
||
prompt = f"""
|
||
Seed: {req.seed}
|
||
Persona: {persona_line or 'N/A'}
|
||
Guidelines:
|
||
- Generate {req.count} distinct, high-signal Google search prompts.
|
||
- Each prompt should include concrete entities (companies, tools, frameworks) when possible.
|
||
- Prefer phrasing that yields recent, authoritative sources.
|
||
- Avoid generic phrasing ("latest trends") unless combined with concrete qualifiers.
|
||
- Optimize for LinkedIn thought leadership and practicality.
|
||
{('Platform hints: ' + ', '.join(platform_hints)) if platform_hints else ''}
|
||
|
||
Return only the list of prompts.
|
||
""".strip()
|
||
|
||
schema = {
|
||
"type": "object",
|
||
"properties": {
|
||
"prompts": {
|
||
"type": "array",
|
||
"items": {"type": "string"}
|
||
}
|
||
}
|
||
}
|
||
|
||
result = gemini_structured_json_response(
|
||
prompt=prompt,
|
||
schema=schema,
|
||
temperature=0.2,
|
||
top_p=0.9,
|
||
top_k=40,
|
||
max_tokens=2048,
|
||
system_prompt=sys_prompt,
|
||
)
|
||
|
||
prompts = []
|
||
if isinstance(result, dict) and isinstance(result.get("prompts"), list):
|
||
prompts = [str(p).strip() for p in result["prompts"] if str(p).strip()]
|
||
|
||
if not prompts:
|
||
# Minimal fallback: derive simple variations
|
||
base = req.seed.strip()
|
||
prompts = [
|
||
f"Recent data-backed insights about {base}",
|
||
f"Case studies and benchmarks on {base}",
|
||
f"Implementation playbooks for {base}",
|
||
f"Common pitfalls and solutions in {base}",
|
||
f"Industry leader perspectives on {base}",
|
||
]
|
||
|
||
return PromptResponse(prompts=prompts[: req.count])
|
||
except Exception as e:
|
||
logger.error(f"Error generating brainstorm prompts: {e}")
|
||
raise HTTPException(status_code=500, detail=str(e))
|
||
|
||
|
||
class SearchRequest(BaseModel):
|
||
prompt: str = Field(..., description="Selected search prompt to run with grounding")
|
||
max_tokens: int = Field(1024, ge=256, le=4096)
|
||
|
||
|
||
class SearchResult(BaseModel):
|
||
title: Optional[str] = None
|
||
url: Optional[str] = None
|
||
snippet: Optional[str] = None
|
||
|
||
|
||
class SearchResponse(BaseModel):
|
||
results: List[SearchResult] = []
|
||
|
||
|
||
@router.post("/search", response_model=SearchResponse)
|
||
async def run_grounded_search(req: SearchRequest) -> SearchResponse:
|
||
"""Run a single grounded Google search via GeminiGroundedProvider and return normalized results."""
|
||
if not GROUNDED_AVAILABLE:
|
||
raise HTTPException(status_code=503, detail="Grounded provider not available")
|
||
|
||
try:
|
||
provider = GeminiGroundedProvider()
|
||
resp = await provider.generate_grounded_content(
|
||
prompt=req.prompt,
|
||
content_type="linkedin_post",
|
||
temperature=0.3,
|
||
max_tokens=req.max_tokens,
|
||
)
|
||
|
||
items: List[SearchResult] = []
|
||
# Normalize 'sources' if present
|
||
for s in (resp.get("sources") or []):
|
||
items.append(SearchResult(
|
||
title=s.get("title") or "Source",
|
||
url=s.get("url") or s.get("link"),
|
||
snippet=s.get("content") or s.get("snippet")
|
||
))
|
||
|
||
# Provide minimal fallback if no structured sources are returned
|
||
if not items and resp.get("content"):
|
||
items.append(SearchResult(title="Generated overview", url=None, snippet=resp.get("content")[:400]))
|
||
|
||
return SearchResponse(results=items[:10])
|
||
except Exception as e:
|
||
logger.error(f"Error in grounded search: {e}")
|
||
raise HTTPException(status_code=500, detail=str(e))
|
||
|
||
|
||
class IdeasRequest(BaseModel):
|
||
seed: str
|
||
persona: Optional[PersonaPayload] = None
|
||
platformPersona: Optional[PlatformPersonaPayload] = None
|
||
results: List[SearchResult] = []
|
||
count: int = 5
|
||
|
||
|
||
class IdeaItem(BaseModel):
|
||
prompt: str
|
||
rationale: Optional[str] = None
|
||
|
||
|
||
class IdeasResponse(BaseModel):
|
||
ideas: List[IdeaItem]
|
||
|
||
|
||
@router.post("/ideas", response_model=IdeasResponse)
|
||
async def generate_brainstorm_ideas(req: IdeasRequest) -> IdeasResponse:
|
||
"""
|
||
Create brainstorm ideas by combining persona, seed, and Google search results.
|
||
Uses gemini_structured_json_response for consistent output.
|
||
"""
|
||
try:
|
||
# Build compact search context
|
||
top_results = req.results[:5]
|
||
sources_block = "\n".join(
|
||
[
|
||
f"- {r.title or 'Source'} | {r.url or ''} | {r.snippet or ''}"
|
||
for r in top_results
|
||
]
|
||
) or "(no sources)"
|
||
|
||
persona_block = ""
|
||
if req.persona:
|
||
persona_block = (
|
||
f"Persona: {req.persona.persona_name or ''} {('(' + req.persona.archetype + ')') if req.persona.archetype else ''}\n"
|
||
)
|
||
|
||
platform_block = ""
|
||
if req.platformPersona and req.platformPersona.content_format_rules:
|
||
limit = req.platformPersona.content_format_rules.get("character_limit")
|
||
platform_block = f"LinkedIn character limit: {limit}" if limit else ""
|
||
|
||
sys_prompt = (
|
||
"You are an enterprise-grade LinkedIn strategist. Generate specific, non-generic "
|
||
"brainstorm prompts suitable for LinkedIn posts or carousels. Use the provided web "
|
||
"sources to ground ideas and the persona to align tone and style."
|
||
)
|
||
|
||
prompt = f"""
|
||
SEED IDEA: {req.seed}
|
||
{persona_block}
|
||
{platform_block}
|
||
|
||
RECENT WEB SOURCES (top {len(top_results)}):
|
||
{sources_block}
|
||
|
||
TASK:
|
||
- Propose {req.count} LinkedIn-ready brainstorm prompts tailored to the persona and grounded in the sources.
|
||
- Each prompt should be specific and actionable for 2024–2025.
|
||
- Prefer thought-leadership angles, contrarian takes with evidence, or practical playbooks.
|
||
- Avoid generic phrases like "latest trends" unless qualified by entities.
|
||
|
||
Return JSON with an array named ideas where each item has:
|
||
- prompt: the exact text the user can use to generate a post
|
||
- rationale: 1–2 sentence why this works for the audience/persona
|
||
""".strip()
|
||
|
||
schema = {
|
||
"type": "object",
|
||
"properties": {
|
||
"ideas": {
|
||
"type": "array",
|
||
"items": {
|
||
"type": "object",
|
||
"properties": {
|
||
"prompt": {"type": "string"},
|
||
"rationale": {"type": "string"},
|
||
},
|
||
},
|
||
}
|
||
},
|
||
}
|
||
|
||
result = gemini_structured_json_response(
|
||
prompt=prompt,
|
||
schema=schema,
|
||
temperature=0.2,
|
||
top_p=0.9,
|
||
top_k=40,
|
||
max_tokens=2048,
|
||
system_prompt=sys_prompt,
|
||
)
|
||
|
||
ideas: List[IdeaItem] = []
|
||
if isinstance(result, dict) and isinstance(result.get("ideas"), list):
|
||
for item in result["ideas"]:
|
||
if isinstance(item, dict) and item.get("prompt"):
|
||
ideas.append(IdeaItem(prompt=item["prompt"], rationale=item.get("rationale")))
|
||
|
||
if not ideas:
|
||
# Fallback basic ideas from seed if model returns nothing
|
||
ideas = [
|
||
IdeaItem(prompt=f"Explain why {req.seed} matters now with 2 recent stats", rationale="Timely and data-backed."),
|
||
IdeaItem(prompt=f"Common pitfalls in {req.seed} and how to avoid them", rationale="Actionable and experience-based."),
|
||
IdeaItem(prompt=f"A step-by-step playbook to implement {req.seed}", rationale="Practical value."),
|
||
IdeaItem(prompt=f"Case study: measurable impact of {req.seed}", rationale="Story + ROI."),
|
||
IdeaItem(prompt=f"Contrarian take: what most get wrong about {req.seed}", rationale="Thought leadership.")
|
||
]
|
||
|
||
return IdeasResponse(ideas=ideas[: req.count])
|
||
except Exception as e:
|
||
logger.error(f"Error generating brainstorm ideas: {e}")
|
||
raise HTTPException(status_code=500, detail=str(e))
|
||
|
||
|