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
161 lines
6.0 KiB
Python
161 lines
6.0 KiB
Python
import os
|
|
import asyncio
|
|
from typing import Any, Dict, List
|
|
from dataclasses import dataclass
|
|
from loguru import logger
|
|
import random
|
|
|
|
from services.llm_providers.main_text_generation import llm_text_gen
|
|
|
|
|
|
@dataclass
|
|
class WritingSuggestion:
|
|
text: str
|
|
confidence: float
|
|
sources: List[Dict[str, Any]]
|
|
|
|
|
|
class WritingAssistantService:
|
|
"""
|
|
Minimal writing assistant that combines Exa search with Gemini continuation.
|
|
- Exa provides relevant sources with content snippets
|
|
- Gemini generates a short, cited continuation based on current text and sources
|
|
"""
|
|
|
|
def __init__(self) -> None:
|
|
# COST CONTROL: Daily usage limits
|
|
self.daily_api_calls = 0
|
|
self.daily_limit = 50 # Max 50 API calls per day (~$2.50 max cost)
|
|
self.last_reset_date = None
|
|
|
|
def _get_cached_suggestion(self, text: str) -> WritingSuggestion | None:
|
|
"""No cached suggestions - always use real API calls for authentic results."""
|
|
return None
|
|
|
|
def _check_daily_limit(self) -> bool:
|
|
"""Check if we're within daily API usage limits."""
|
|
import datetime
|
|
|
|
today = datetime.date.today()
|
|
|
|
# Reset counter if it's a new day
|
|
if self.last_reset_date != today:
|
|
self.daily_api_calls = 0
|
|
self.last_reset_date = today
|
|
|
|
# Check if we've exceeded the limit
|
|
if self.daily_api_calls >= self.daily_limit:
|
|
return False
|
|
|
|
# Increment counter for this API call
|
|
self.daily_api_calls += 1
|
|
logger.info(f"Writing assistant API call #{self.daily_api_calls}/{self.daily_limit} today")
|
|
return True
|
|
|
|
async def suggest(self, text: str, user_id: str | None = None) -> List[WritingSuggestion]:
|
|
if not text or len(text.strip()) < 6:
|
|
return []
|
|
|
|
cached_suggestion = self._get_cached_suggestion(text)
|
|
if cached_suggestion:
|
|
return [cached_suggestion]
|
|
|
|
if not self._check_daily_limit():
|
|
logger.warning("Daily API limit reached for writing assistant")
|
|
return []
|
|
|
|
if len(text.strip()) < 50:
|
|
return []
|
|
|
|
# 1) Find relevant sources via Exa
|
|
sources = await self._search_sources(text, user_id=user_id)
|
|
|
|
# 2) Generate continuation suggestion via LLM grounded in sources
|
|
suggestion_text, confidence = await self._generate_continuation(text, sources, user_id=user_id)
|
|
|
|
if not suggestion_text:
|
|
return []
|
|
|
|
return [WritingSuggestion(text=suggestion_text.strip(), confidence=confidence, sources=sources)]
|
|
|
|
async def _search_sources(self, text: str, user_id: str = None) -> List[Dict[str, Any]]:
|
|
"""Search for relevant sources using ExaResearchProvider with subscription checks."""
|
|
try:
|
|
from services.blog_writer.research.exa_provider import ExaResearchProvider
|
|
|
|
exa_query = (
|
|
(text[-1000:] if len(text) > 1000 else text)
|
|
+ "\n\nIf you found the above interesting, here's another useful resource to read:"
|
|
)
|
|
|
|
provider = ExaResearchProvider()
|
|
sources = await provider.simple_search(
|
|
query=exa_query,
|
|
num_results=3,
|
|
user_id=user_id,
|
|
)
|
|
|
|
# Normalize keys to match expected format
|
|
normalized = []
|
|
for s in sources:
|
|
normalized.append({
|
|
"title": s.get("title", "Untitled"),
|
|
"url": s.get("url", ""),
|
|
"text": s.get("text", ""),
|
|
"author": s.get("author", ""),
|
|
"published_date": s.get("publishedDate", ""),
|
|
"score": float(s.get("score", 0.5)),
|
|
})
|
|
|
|
if not normalized:
|
|
raise Exception("No relevant sources found from Exa for the current context")
|
|
return normalized
|
|
except Exception as e:
|
|
logger.error(f"WritingAssistant _search_sources error: {e}")
|
|
raise
|
|
|
|
async def _generate_continuation(self, text: str, sources: List[Dict[str, Any]], user_id: str | None = None) -> tuple[str, float]:
|
|
source_blocks: List[str] = []
|
|
for i, s in enumerate(sources[:5]):
|
|
excerpt = (s.get("text", "") or "")
|
|
excerpt = excerpt[:500]
|
|
source_blocks.append(
|
|
f"Source {i+1}: {s.get('title','') or 'Source'}\nURL: {s.get('url','')}\nExcerpt: {excerpt}"
|
|
)
|
|
sources_text = "\n\n".join(source_blocks)
|
|
|
|
system_prompt = (
|
|
"You are an assistive writing continuation bot. "
|
|
"Only produce 1-2 SHORT sentences. Do not repeat or paraphrase the user's stub. "
|
|
"Match tone and topic. Prefer concrete, current facts from the provided sources. "
|
|
"Include exactly one brief citation hint in parentheses with an author (or 'Source') and URL in square brackets, e.g., ((Doe, 2021)[https://example.com])."
|
|
)
|
|
user_prompt = (
|
|
f"User text to continue (do not repeat):\n{text}\n\n"
|
|
f"Relevant sources to inform your continuation:\n{sources_text}\n\n"
|
|
"Return only the continuation text, without quotes."
|
|
)
|
|
|
|
try:
|
|
await asyncio.sleep(random.uniform(0.05, 0.15))
|
|
|
|
ai_resp = llm_text_gen(
|
|
prompt=user_prompt,
|
|
json_struct=None,
|
|
system_prompt=system_prompt,
|
|
user_id=user_id,
|
|
)
|
|
if isinstance(ai_resp, dict) and ai_resp.get("text"):
|
|
suggestion = (ai_resp.get("text", "") or "").strip()
|
|
else:
|
|
suggestion = (str(ai_resp or "")).strip()
|
|
if not suggestion:
|
|
raise Exception("Assistive writer returned empty suggestion")
|
|
confidence = 0.7
|
|
return suggestion, confidence
|
|
except Exception as e:
|
|
logger.error(f"WritingAssistant _generate_continuation error: {e}")
|
|
raise
|
|
|
|
|